/* * Copyright (c) 2024-2025 Sun Booshi * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ 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 './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'; const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims; 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; 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('.note-to-mp') < 0; } return false; } setArticle(article: string) { this.articleDiv.empty(); let className = 'note-to-mp'; // 兼容旧版本样式 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://github.com/sunbooshi/note-to-mp/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 = '没有可渲染的笔记或文件不支持渲染'; } if (md.startsWith('---')) { md = md.replace(FRONT_MATTER_REGEX, ''); } 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)); } } 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 ? `.note-to-mp {${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; const metadata = this.app.metadataCache.getFileCache(file); if (metadata?.frontmatter) { const keys = this.assetsManager.expertSettings.frontmatter; const frontmatter = metadata.frontmatter; res.title = this.getFrontmatterValue(frontmatter, keys.title); res.author = this.getFrontmatterValue(frontmatter, keys.author); res.digest = this.getFrontmatterValue(frontmatter, keys.digest); res.content_source_url = this.getFrontmatterValue(frontmatter, keys.content_source_url); res.cover = this.getFrontmatterValue(frontmatter, keys.cover); res.thumb_media_id = this.getFrontmatterValue(frontmatter, keys.thumb_media_id); res.need_open_comment = frontmatter[keys.need_open_comment] ? 1 : undefined; 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'; } } 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()) { data = new Blob([WebpToJPG(await data.arrayBuffer())]); 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); } }