Files
note2any/src/article-render.ts
2025-10-16 14:24:10 +08:00

864 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 文件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 配置迁移到 NMPSettingsgalleryPrePath, galleryNumPic
// 匹配示例:{{<gallery dir="/img/guanzhan/1" figcaption="毕业展" mppickall=1/>}}{{<load-photoswipe>}}
// 支持可选 figcaption 以及 mppickall=1/0无引号数字或布尔若 mppickall=1 则选取目录内全部图片
// 说明为保持简单mppickall 只支持 0/1不写或写 0 则按限制数量。
// mppickall 允许mppickall=1 | mppickall='1' | mppickall="1" 0 同理)
const GALLERY_SHORTCODE_REGEX = /{{<gallery\s+dir="([^"]+)"(?:\s+figcaption="([^"]*)")?(?:\s+mppickall=(?:"(1|0)"|'(1|0)'|(1|0)))?\s*\/?>}}\s*{{<load-photoswipe>}}/g;
// 块级 gallery
// {{<gallery>}}\n{{<figure src="/img/a.png" caption=".." >}}\n...\n{{</gallery>}}
// 需要提取所有 figure 的 src basename 生成多行 wikilink
const GALLERY_BLOCK_REGEX = /{{<gallery>}}([\s\S]*?){{<\/gallery>}}/g;
// figure 支持 src 或 link 属性,两者取其一
const FIGURE_IN_GALLERY_REGEX = /{{<figure\s+(?:src|link)="([^"]+)"[^>]*>}}/g;
async function listLocalImages(dirAbs: string): Promise<string[]> {
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<string> {
// 逐个替换(异步)—— 使用 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<string, string> = 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 = `<section class="${className}" id="article-section">${article}</section>`;
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 '<h1>渲染失败!</h1><br/>'
+ '如需帮助请前往&nbsp;&nbsp;<a href="https://biboer.cn/gitea/gavin/note2any/issues">https://biboer.cn/gitea/gavin/note2any/issues</a>&nbsp;&nbsp;反馈<br/><br/>'
+ '如果方便请提供引发错误的完整Markdown内容。<br/><br/>'
+ '<br/>Obsidian版本' + apiVersion
+ '<br/>错误信息:<br/>'
+ `${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 `<span style="font-style: italic; font-size: 14px; background-color: #f5f5f5; padding: 2px;">${content}</span>`;
});
// 颜色映射:默认|| 与 ||g, ||r, ||b, ||y
const blockStyles: Record<string, string> = {
'': "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 `<p style="${style}">${text}</p>`;
}).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);
}
}