/** * 文件:article-render.ts * 作用:文章渲染与转换辅助。 */ import { App, ItemView, Workspace, Notice, sanitizeHTMLToDom, apiVersion, TFile, MarkdownRenderer, FrontMatterCache } from 'obsidian'; import { applyCSS } from './utils'; import { UploadImageToWx } from './imagelib'; import { NMPSettings } from './settings'; import AssetsManager from './assets'; import InlineCSS from './inline-css'; import { wxGetToken, wxAddDraft, wxBatchGetMaterial, DraftArticle, DraftImageMediaId, DraftImages, wxAddDraftImages } from './wechat/weixin-api'; import { MDRendererCallback } from './markdown/extension'; import { MarkedParser } from './markdown/parser'; import { LocalImageManager, LocalFile } from './markdown/local-file'; import { CardDataManager } from './markdown/code'; import { debounce } from './utils'; import { PrepareImageLib, IsImageLibReady, WebpToJPG } from './imagelib'; import { toPng } from 'html-to-image'; import * as path from 'path'; import { stat, readdir } from 'fs/promises'; const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims; // gallery 配置迁移到 NMPSettings(galleryPrePath, galleryNumPic) // 匹配示例:{{}}{{}} // 支持可选 figcaption 以及 mppickall=1/0(无引号数字或布尔),若 mppickall=1 则选取目录内全部图片 // 说明:为保持简单,mppickall 只支持 0/1,不写或写 0 则按限制数量。 // mppickall 允许:mppickall=1 | mppickall='1' | mppickall="1" (0 同理) const GALLERY_SHORTCODE_REGEX = /{{}}\s*{{}}/g; // 块级 gallery: // {{}}\n{{
}}\n...\n{{}} // 需要提取所有 figure 的 src basename 生成多行 wikilink const GALLERY_BLOCK_REGEX = /{{}}([\s\S]*?){{<\/gallery>}}/g; // figure 支持 src 或 link 属性,两者取其一 const FIGURE_IN_GALLERY_REGEX = /{{]*>}}/g; async function listLocalImages(dirAbs: string): Promise { try { const stats = await stat(dirAbs); if (!stats.isDirectory()) return []; } catch { return []; } try { const files = await readdir(dirAbs); return files.filter(f => /(png|jpe?g|gif|bmp|webp|svg)$/i.test(f)).sort(); } catch { return []; } } function pickImages(all: string[], limit: number): string[] { if (all.length <= limit) return all; // 简单:前 n;可扩展为随机 return all.slice(0, limit); } async function transformGalleryShortcodes(md: string, prePath: string, numPic: number): Promise { // 逐个替换(异步)—— 使用 replace + 手动遍历实现 const matches: { full: string; dir: string; caption?: string; pickAll?: boolean }[] = []; md.replace(GALLERY_SHORTCODE_REGEX, (full, dir, caption, q1, q2, plain) => { const flag = q1 || q2 || plain; // 三个捕获组任选其一 matches.push({ full, dir, caption, pickAll: flag === '1' }); return full; }); let result = md; for (const m of matches) { const absDir = path.join(prePath, m.dir.replace(/^\//, '')); // 拼接绝对路径 const imgs = await listLocalImages(absDir); const picked = m.pickAll ? imgs : pickImages(imgs, numPic); if (picked.length === 0) { // 无图则清空短代码(或保留原样,这里按需求替换为空) result = result.replace(m.full, ''); continue; } const repl = picked.map(f => `![[${f}]]`).join('\n'); result = result.replace(m.full, repl); } // 处理无 dir 的块级 gallery(只做本地名提取,不访问文件系统) result = result.replace(GALLERY_BLOCK_REGEX, (full: string, inner: string) => { const names: string[] = []; inner.replace(FIGURE_IN_GALLERY_REGEX, (_f: string, src: string) => { if (!src) return _f; const clean = src.split('#')[0].split('?')[0]; const base = clean.split('/').pop(); if (base && /(png|jpe?g|gif|bmp|webp|svg)$/i.test(base)) { names.push(base); } return _f; }); if (names.length === 0) return ''; return names.map(n => `![[${n}]]`).join('\n'); }); return result; } export class ArticleRender implements MDRendererCallback { app: App; itemView: ItemView; workspace: Workspace; styleEl: HTMLElement; articleDiv: HTMLDivElement; settings: NMPSettings; assetsManager: AssetsManager; articleHTML: string; title: string; _currentTheme: string; _currentHighlight: string; _currentAppId: string; markedParser: MarkedParser; cachedElements: Map = new Map(); debouncedRenderMarkdown: (...args: any[]) => void; originalMarkdown: string | null = null; // 保存去除前处理前的原始 Markdown constructor(app: App, itemView: ItemView, styleEl: HTMLElement, articleDiv: HTMLDivElement) { this.app = app; this.itemView = itemView; this.styleEl = styleEl; this.articleDiv = articleDiv; this.settings = NMPSettings.getInstance(); this.assetsManager = AssetsManager.getInstance(); this.articleHTML = ''; this.title = ''; this._currentTheme = 'default'; this._currentHighlight = 'default'; this.markedParser = new MarkedParser(app, this); this.debouncedRenderMarkdown = debounce(this.renderMarkdown.bind(this), 1000); } set currentTheme(value: string) { this._currentTheme = value; } get currentTheme() { const { theme } = this.getMetadata(); if (theme) { return theme; } return this._currentTheme; } set currentHighlight(value: string) { this._currentHighlight = value; } get currentHighlight() { const { highlight } = this.getMetadata(); if (highlight) { return highlight; } return this._currentHighlight; } isOldTheme() { const theme = this.assetsManager.getTheme(this.currentTheme); if (theme) { return theme.css.indexOf('.note2any') < 0; } return false; } setArticle(article: string) { this.articleDiv.empty(); let className = 'note2any'; // 兼容旧版本样式 if (this.isOldTheme()) { className = this.currentTheme; } const html = `
${article}
`; const doc = sanitizeHTMLToDom(html); if (doc.firstChild) { this.articleDiv.appendChild(doc.firstChild); } } setStyle(css: string) { this.styleEl.empty(); this.styleEl.appendChild(document.createTextNode(css)); } reloadStyle() { this.setStyle(this.getCSS()); } getArticleSection() { return this.articleDiv.querySelector('#article-section') as HTMLElement; } getArticleContent() { const content = this.articleDiv.innerHTML; let html = applyCSS(content, this.getCSS()); // 处理话题多余内容 html = html.replace(/rel="noopener nofollow"/g, ''); html = html.replace(/target="_blank"/g, ''); html = html.replace(/data-leaf=""/g, 'leaf=""'); return CardDataManager.getInstance().restoreCard(html); } getArticleText() { return this.articleDiv.innerText.trimStart(); } errorContent(error: any) { return '

渲染失败!


' + '如需帮助请前往  https://biboer.cn/gitea/gavin/note2any/issues  反馈

' + '如果方便,请提供引发错误的完整Markdown内容。

' + '
Obsidian版本:' + apiVersion + '
错误信息:
' + `${error}`; } async renderMarkdown(af: TFile | null = null) { try { let md = ''; if (af && af.extension.toLocaleLowerCase() === 'md') { md = await this.app.vault.adapter.read(af.path); this.title = af.basename; } else { md = '没有可渲染的笔记或文件不支持渲染'; } this.originalMarkdown = md; // 保存原始内容(含 frontmatter)供封面/摘要自动提取 if (md.startsWith('---')) { md = md.replace(FRONT_MATTER_REGEX, ''); } // 处理 gallery 短代码 -> wikilink 图片列表 md = await transformGalleryShortcodes(md, this.settings.galleryPrePath, this.settings.galleryNumPic); // 自定义行级语法转换: [fig .../] 以及 || 前缀块 md = this.applyCustomInlineBlocks(md); // 将标准 markdown 图片语法转为 wikilink 语法,便于现有 LocalImageManager 识别 if (this.settings.enableMarkdownImageToWikilink) { // 匹配 ![alt](path/to/name.ext);不跨行;忽略包含空格的 URL 末尾注释 // 捕获路径和文件名,文件名取最后一段 md = md.replace(/!\[[^\]]*\]\(([^)\s]+)\)/g, (full, p1) => { try { // 去掉可能的 query/hash const clean = p1.split('#')[0].split('?')[0]; const filename = clean.split('/').pop(); if (!filename) return full; // 无法解析 // 仅当是常见图片扩展时才替换 if (!filename.match(/\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i)) return full; return `![[${filename}]]`; } catch { return full; } }); } this.articleHTML = await this.markedParser.parse(md); this.setStyle(this.getCSS()); this.setArticle(this.articleHTML); await this.processCachedElements(); } catch (e) { console.error(e); this.setArticle(this.errorContent(e)); } } // 自定义 fig 与 || 语法转换 private applyCustomInlineBlocks(md: string): string { const figPattern = /\[fig([^\n\]]*?)\/\]/g; // [fig text/] md = md.replace(figPattern, (_m, inner) => { const content = inner.trim(); return `${content}`; }); // 颜色映射:默认|| 与 ||g, ||r, ||b, ||y const blockStyles: Record = { '': "background-color:#E5E4E2;", 'r': "color:white;background-color:#6F4E37;", 'g': "background-color:#BCE954;", 'b': "background-color:#B6B6B4;", 'y': "background-color:#FFFFC2;", }; const baseStyle = "font-family:'Microsoft YaHei',sans-serif;font-size:14px; padding:10px;border-radius:20px;line-height:30px;"; // 仅匹配行首 ||[flag]? 空格 之后的单行内容,避免吞并后续多行 md = md.split(/\n/).map(line => { // 例如 || 内容, ||r 内容 const m = line.match(/^\|\|(r|g|b|y)?\s+(.*)$/); if (!m) return line; const flag = m[1] || ''; const text = m[2]; const style = baseStyle + (blockStyles[flag] || blockStyles['']); return `

${text}

`; }).join('\n'); return md; } getCSS() { try { const theme = this.assetsManager.getTheme(this.currentTheme); const highlight = this.assetsManager.getHighlight(this.currentHighlight); const customCSS = this.settings.customCSSNote.length > 0 || this.settings.useCustomCss ? this.assetsManager.customCSS : ''; const baseCSS = this.settings.baseCSS ? `.note2any {${this.settings.baseCSS}}` : ''; return `${InlineCSS}\n\n${highlight!.css}\n\n${theme!.css}\n\n${baseCSS}\n\n${customCSS}`; } catch (error) { console.error(error); new Notice(`获取样式失败${this.currentTheme}|${this.currentHighlight},请检查主题是否正确安装。`); } return ''; } updateStyle(styleName: string) { this.currentTheme = styleName; this.setStyle(this.getCSS()); } updateHighLight(styleName: string) { this.currentHighlight = styleName; this.setStyle(this.getCSS()); } getFrontmatterValue(frontmatter: FrontMatterCache, key: string) { const value = frontmatter[key]; if (value instanceof Array) { return value[0]; } return value; } getMetadata() { let res: DraftArticle = { title: '', author: undefined, digest: undefined, content: '', content_source_url: undefined, cover: undefined, thumb_media_id: '', need_open_comment: undefined, only_fans_can_comment: undefined, pic_crop_235_1: undefined, pic_crop_1_1: undefined, appid: undefined, theme: undefined, highlight: undefined, } const file = this.app.workspace.getActiveFile(); if (!file) return res; // 避免频繁刷屏:仅当路径变化或超过 3s 再输出一次 try { const globalAny = window as any; const now = Date.now(); if (!globalAny.__note2any_lastPathLog || globalAny.__note2any_lastPathLog.path !== file.path || now - globalAny.__note2any_lastPathLog.time > 3000) { console.log('[note2any] active file path:', file.path); globalAny.__note2any_lastPathLog = { path: file.path, time: now }; } } catch {} const metadata = this.app.metadataCache.getFileCache(file); if (metadata?.frontmatter) { const keys = this.assetsManager.expertSettings.frontmatter; const frontmatter = metadata.frontmatter; // frontmatter 优先:如果存在 title/author 则直接取之 const fmTitle = this.getFrontmatterValue(frontmatter, keys.title) || frontmatter['title']; const fmAuthor = this.getFrontmatterValue(frontmatter, keys.author) || frontmatter['author']; if (fmTitle) res.title = fmTitle; if (fmAuthor) res.author = fmAuthor; res.digest = this.getFrontmatterValue(frontmatter, keys.digest); res.content_source_url = this.getFrontmatterValue(frontmatter, keys.content_source_url); if (!this.settings.ignoreFrontmatterImage) { let fmCover = this.getFrontmatterValue(frontmatter, keys.cover) || frontmatter['cover'] || frontmatter['image']; if (typeof fmCover === 'string') { fmCover = fmCover.trim(); if (fmCover === '') { fmCover = undefined; } else if (/^https?:\/\//i.test(fmCover)) { // 远程 URL,保持原样(后续逻辑支持 http 上传) } else { // 本地路径:可能是 /img/xxx.png 或 相对路径 foo/bar.png const clean = fmCover.replace(/^"|"$/g, '').split('#')[0].split('?')[0]; const base = clean.split('/').pop(); if (base) { fmCover = `![[${base}]]`; } } } res.cover = fmCover; } else { res.cover = undefined; // 忽略 frontmatter } res.thumb_media_id = this.getFrontmatterValue(frontmatter, keys.thumb_media_id); if (frontmatter[keys.need_open_comment] !== undefined) { res.need_open_comment = frontmatter[keys.need_open_comment] ? 1 : 0; } res.only_fans_can_comment = frontmatter[keys.only_fans_can_comment] ? 1 : undefined; res.appid = this.getFrontmatterValue(frontmatter, keys.appid); if (res.appid && !res.appid.startsWith('wx')) { res.appid = this.settings.wxInfo.find(wx => wx.name === res.appid)?.appid; } res.theme = this.getFrontmatterValue(frontmatter, keys.theme); res.highlight = this.getFrontmatterValue(frontmatter, keys.highlight); if (frontmatter[keys.crop]) { res.pic_crop_235_1 = '0_0_1_0.5'; res.pic_crop_1_1 = '0_0.525_0.404_1'; } } else if (this.originalMarkdown?.startsWith('---')) { // 元数据缓存未命中时的手动轻量解析(不引入 yaml 依赖,逐行扫描直到第二个 ---) try { const lines = this.originalMarkdown.split(/\r?\n/); if (lines[0].trim() === '---') { let i = 1; for (; i < lines.length; i++) { const line = lines[i]; if (line.trim() === '---') break; const m = line.match(/^(\w[\w-]*):\s*(.*)$/); // key: value 形式 if (!m) continue; const k = m[1].toLowerCase(); const v = m[2].trim(); if (k === 'title' && !res.title) res.title = v; if (k === 'author' && !res.author) res.author = v; if (!this.settings.ignoreFrontmatterImage) { if ((k === 'image' || k === 'cover') && !res.cover && v) { let val = v.replace(/^"|"$/g, '').trim(); if (val) { if (/^https?:\/\//i.test(val)) { res.cover = val; // URL } else { const clean = val.split('#')[0].split('?')[0]; const base = clean.split('/').pop(); if (base) res.cover = `![[${base}]]`; } } } } } } } catch (err) { console.warn('fallback frontmatter parse failed', err); } } // 如果未显式指定封面,尝试从正文首图( markdown 或 wikilink ) 提取,按出现顺序优先 if (!res.cover && this.originalMarkdown) { let body = this.originalMarkdown; if (body.startsWith('---')) body = body.replace(FRONT_MATTER_REGEX, ''); // 同时匹配两种形式并比较 index const mdImgPattern = /!\[[^\]]*\]\(([^)\s]+)\)/g; // group1 为路径 const wikilinkPattern = /!\[\[(.+?)\]\]/g; // group1 为文件名或 path interface Candidate { idx:number; basename:string; } const candidates: Candidate[] = []; let m: RegExpExecArray | null; while ((m = mdImgPattern.exec(body)) !== null) { const rawPath = m[1]; if (/^https?:\/\//i.test(rawPath)) continue; // 跳过远程 const clean = rawPath.split('#')[0].split('?')[0]; const basename = clean.split('/').pop(); if (basename && /\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i.test(basename)) { candidates.push({ idx: m.index, basename }); } } while ((m = wikilinkPattern.exec(body)) !== null) { const inner = m[1].trim(); const clean = inner.split('#')[0].split('?')[0]; const basename = clean.split('/').pop(); if (basename && /\.(png|jpe?g|gif|bmp|webp|svg|tiff)$/i.test(basename)) { candidates.push({ idx: m.index, basename }); } } if (candidates.length > 0) { candidates.sort((a,b)=> a.idx - b.idx); res.cover = `![[${candidates[0].basename}]]`; } else if (!res.cover) { // 没有找到任何图片候选,应用默认封面(如果配置了) const def = this.settings.defaultCoverPic?.trim(); if (def) { if (/^!\[\[.*\]\]$/.test(def) || /^https?:\/\//i.test(def)) { res.cover = def; } else { const base = def.split('/').pop(); if (base) res.cover = `![[${base}]]`; } if (res.cover) { try { console.log('[note2any] use default cover:', def, '->', res.cover); } catch {} } } } } if (res.need_open_comment === undefined) { res.need_open_comment = this.settings.needOpenComment ? 1 : 0; } return res; } async uploadVaultCover(name: string, token: string) { const LocalFileRegex = /^!\[\[(.*?)\]\]/; const matches = name.match(LocalFileRegex); let fileName = ''; if (matches && matches.length > 1) { fileName = matches[1]; } else { fileName = name; } const vault = this.app.vault; const file = this.assetsManager.searchFile(fileName) as TFile; if (!file) { throw new Error('找不到封面文件: ' + fileName); } const fileData = await vault.readBinary(file); return await this.uploadCover(new Blob([fileData]), file.name, token); } async uploadCover(data: Blob, filename: string, token: string) { if (filename.toLowerCase().endsWith('.webp')) { await PrepareImageLib(); if (IsImageLibReady()) { const jpgUint8 = WebpToJPG(await data.arrayBuffer()); // 使用底层 ArrayBuffer 构造 Blob,避免 TypeScript 在某些配置下对 ArrayBufferLike 的严格类型检查报错 data = new Blob([jpgUint8.buffer as ArrayBuffer], { type: 'image/jpeg' }); filename = filename.toLowerCase().replace('.webp', '.jpg'); } } const res = await UploadImageToWx(data, filename, token, 'image'); if (res.media_id) { return res.media_id; } console.error('upload cover fail: ' + res.errmsg); throw new Error('上传封面失败: ' + res.errmsg); } async getDefaultCover(token: string) { const res = await wxBatchGetMaterial(token, 'image'); if (res.item_count > 0) { return res.item[0].media_id; } return ''; } async getToken(appid: string) { const secret = this.getSecret(appid); const res = await wxGetToken(this.settings.authKey, appid, secret); if (res.status != 200) { const data = res.json; throw new Error('获取token失败: ' + data.message); } const token = res.json.token; if (token === '') { throw new Error('获取token失败: ' + res.json.message); } return token; } async uploadImages(appid: string) { if (!this.settings.authKey) { throw new Error('请先设置注册码(AuthKey)'); } let metadata = this.getMetadata(); if (metadata.appid) { appid = metadata.appid; } if (!appid || appid.length == 0) { throw new Error('请先选择公众号'); } // 获取token const token = await this.getToken(appid); if (token === '') { return; } await this.cachedElementsToImages(); const lm = LocalImageManager.getInstance(); // 上传图片 await lm.uploadLocalImage(token, this.app.vault); // 上传图床图片 await lm.uploadRemoteImage(this.articleDiv, token); // 替换图片链接 lm.replaceImages(this.articleDiv); await this.copyArticle(); } async copyArticle() { const content = this.getArticleContent(); await navigator.clipboard.write([new ClipboardItem({ 'text/html': new Blob([content], { type: 'text/html' }) })]) } getSecret(appid: string) { for (const wx of this.settings.wxInfo) { if (wx.appid === appid) { return wx.secret.replace('SECRET', ''); } } return ''; } async postArticle(appid:string, localCover: File | null = null) { if (!this.settings.authKey) { throw new Error('请先设置注册码(AuthKey)'); } let metadata = this.getMetadata(); if (metadata.appid) { appid = metadata.appid; } if (!appid || appid.length == 0) { throw new Error('请先选择公众号'); } // 获取token const token = await this.getToken(appid); if (token === '') { throw new Error('获取token失败,请检查网络链接!'); } await this.cachedElementsToImages(); const lm = LocalImageManager.getInstance(); // 上传图片 await lm.uploadLocalImage(token, this.app.vault); // 上传图床图片 await lm.uploadRemoteImage(this.articleDiv, token); // 替换图片链接 lm.replaceImages(this.articleDiv); // 上传封面 let mediaId = metadata.thumb_media_id; if (!mediaId) { if (metadata.cover) { // 上传仓库里的图片 if (metadata.cover.startsWith('http')) { const res = await LocalImageManager.getInstance().uploadImageFromUrl(metadata.cover, token, 'image'); if (res.media_id) { mediaId = res.media_id; } else { throw new Error('上传封面失败:' + res.errmsg); } } else { mediaId = await this.uploadVaultCover(metadata.cover, token); } } else if (localCover) { mediaId = await this.uploadCover(localCover, localCover.name, token); } else { mediaId = await this.getDefaultCover(token); } } if (mediaId === '') { throw new Error('请先上传图片或者设置默认封面'); } metadata.title = metadata.title || this.title; metadata.content = this.getArticleContent(); metadata.thumb_media_id = mediaId; // 创建草稿 const res = await wxAddDraft(token, metadata); if (res.status != 200) { console.error(res.text); throw new Error(`创建草稿失败, https状态码: ${res.status} 可能是文章包含异常内容,请尝试手动复制到公众号编辑器!`); } const draft = res.json; if (draft.media_id) { return draft.media_id; } else { console.error(JSON.stringify(draft)); throw new Error('发布失败!' + draft.errmsg); } } async postImages(appid: string) { if (!this.settings.authKey) { throw new Error('请先设置注册码(AuthKey)'); } let metadata = this.getMetadata(); if (metadata.appid) { appid = metadata.appid; } if (!appid || appid.length == 0) { throw new Error('请先选择公众号'); } // 获取token const token = await this.getToken(appid); if (token === '') { throw new Error('获取token失败,请检查网络链接!'); } const imageList: DraftImageMediaId[] = []; const lm = LocalImageManager.getInstance(); // 上传图片 await lm.uploadLocalImage(token, this.app.vault, 'image'); // 上传图床图片 await lm.uploadRemoteImage(this.articleDiv, token, 'image'); const images = lm.getImageInfos(this.articleDiv); for (const image of images) { if (!image.media_id) { console.warn('miss media id:', image.resUrl); continue; } imageList.push({ image_media_id: image.media_id, }); } if (imageList.length === 0) { throw new Error('没有图片需要发布!'); } const content = this.getArticleText(); const imagesData: DraftImages = { article_type: 'newspic', title: metadata.title || this.title, content: content, need_open_commnet: metadata.need_open_comment ?? 0, only_fans_can_comment: metadata.only_fans_can_comment || 0, image_info: { image_list: imageList, } } // 创建草稿 const res = await wxAddDraftImages(token, imagesData); if (res.status != 200) { console.error(res.text); throw new Error(`创建图片/文字失败, https状态码: ${res.status} ${res.text}!`); } const draft = res.json; if (draft.media_id) { return draft.media_id; } else { console.error(JSON.stringify(draft)); throw new Error('发布失败!' + draft.errmsg); } } async exportHTML() { await this.cachedElementsToImages(); const lm = LocalImageManager.getInstance(); const content = await lm.embleImages(this.articleDiv, this.app.vault); const globalStyle = await this.assetsManager.getStyle(); const html = applyCSS(content, this.getCSS() + globalStyle); const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = this.title + '.html'; a.click(); URL.revokeObjectURL(url); a.remove(); } async processCachedElements() { const af = this.app.workspace.getActiveFile(); if (!af) { console.error('当前没有打开文件,无法处理缓存元素'); return; } for (const [key, value] of this.cachedElements) { const [category, id] = key.split(':'); if (category === 'mermaid' || category === 'excalidraw') { const container = this.articleDiv.querySelector('#' + id) as HTMLElement; if (container) { await MarkdownRenderer.render(this.app, value, container, af.path, this.itemView); } } } } async cachedElementsToImages() { for (const [key, cached] of this.cachedElements) { const [category, elementId] = key.split(':'); const container = this.articleDiv.querySelector(`#${elementId}`) as HTMLElement; if (!container) continue; if (category === 'mermaid') { await this.replaceMermaidWithImage(container, elementId); } else if (category === 'excalidraw') { await this.replaceExcalidrawWithImage(container, elementId); } } } private async replaceMermaidWithImage(container: HTMLElement, id: string) { const mermaidContainer = container.querySelector('.mermaid') as HTMLElement; if (!mermaidContainer || !mermaidContainer.children.length) return; const svg = mermaidContainer.querySelector('svg'); if (!svg) return; try { const pngDataUrl = await toPng(mermaidContainer.firstElementChild as HTMLElement, { pixelRatio: 2 }); const img = document.createElement('img'); img.id = `img-${id}`; img.src = pngDataUrl; img.style.width = `${svg.clientWidth}px`; img.style.height = 'auto'; container.replaceChild(img, mermaidContainer); } catch (error) { console.warn(`Failed to render Mermaid diagram: ${id}`, error); } } private async replaceExcalidrawWithImage(container: HTMLElement, id: string) { const innerDiv = container.querySelector('div') as HTMLElement; if (!innerDiv) return; if (NMPSettings.getInstance().excalidrawToPNG) { const originalImg = container.querySelector('img') as HTMLImageElement; if (!originalImg) return; const style = originalImg.getAttribute('style') || ''; try { const pngDataUrl = await toPng(originalImg, { pixelRatio: 2 }); const img = document.createElement('img'); img.id = `img-${id}`; img.src = pngDataUrl; img.setAttribute('style', style); container.replaceChild(img, container.firstChild!); } catch (error) { console.warn(`Failed to render Excalidraw image: ${id}`, error); } } else { const svg = await LocalFile.renderExcalidraw(innerDiv.innerHTML); this.updateElementByID(id, svg); } } updateElementByID(id: string, html: string): void { const item = this.articleDiv.querySelector('#' + id) as HTMLElement; if (!item) return; const doc = sanitizeHTMLToDom(html); item.empty(); if (doc.childElementCount > 0) { for (const child of doc.children) { item.appendChild(child.cloneNode(true)); // 使用 cloneNode 复制节点以避免移动它 } } else { item.innerText = '渲染失败'; } } cacheElement(category: string, id: string, data: string): void { const key = category + ':' + id; this.cachedElements.set(key, data); } }