update at 2025-09-22 13:55:41

This commit is contained in:
douboer
2025-09-22 13:55:41 +08:00
parent f33b2ee535
commit 0090ce9b93
85 changed files with 20418 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension, MDRendererCallback } from "./extension";
import { NMPSettings } from "src/settings";
import { App, Vault } from "obsidian";
import AssetsManager from "../assets";
import { CalloutRenderer } from "./callouts";
import { WidgetBox } from "./widget-box";
export class Blockquote extends Extension {
callout: CalloutRenderer;
box: WidgetBox;
constructor(app: App, settings: NMPSettings, assetsManager: AssetsManager, callback: MDRendererCallback) {
super(app, settings, assetsManager, callback);
this.callout = new CalloutRenderer(app, settings, assetsManager, callback);
if (settings.isAuthKeyVaild()) {
this.box = new WidgetBox(app, settings, assetsManager, callback);
}
}
async prepare() {
if (!this.marked) {
console.error("marked is not ready");
return;
}
if (this.callout) this.callout.marked = this.marked;
if (this.box) this.box.marked = this.marked;
return;
}
async renderer(token: Tokens.Blockquote) {
if (this.callout.matched(token.text)) {
return await this.callout.renderer(token);
}
if (this.box && this.box.matched(token.text)) {
return await this.box.renderer(token);
}
const body = this.marked.parser(token.tokens);
return `<blockquote>${body}</blockquote>`;
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'blockquote') {
return;
}
token.html = await this.renderer(token as Tokens.Blockquote);
},
extensions: [{
name: 'blockquote',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}

275
src/markdown/callouts.ts Normal file
View File

@@ -0,0 +1,275 @@
/*
* 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 { Tokens, MarkedExtension} from "marked";
import { Extension } from "./extension";
import AssetsManager from "src/assets";
import { wxWidget } from "src/weixin-api";
const icon_note = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-pencil"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>`
const icon_abstract = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-clipboard-list"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>`
const icon_info = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-info"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>`
const icon_todo = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-check-circle-2"><circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path></svg>`
const icon_tip = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-flame"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>`
const icon_success = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-check"><path d="M20 6 9 17l-5-5"></path></svg>`
const icon_question = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><path d="M12 17h.01"></path></svg>`
const icon_warning = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-alert-triangle"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>`
const icon_failure = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-x"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>`
const icon_danger = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-zap"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`
const icon_bug = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-bug"><path d="m8 2 1.88 1.88"></path><path d="M14.12 3.88 16 2"></path><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"></path><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"></path><path d="M12 20v-9"></path><path d="M6.53 9C4.6 8.8 3 7.1 3 5"></path><path d="M6 13H2"></path><path d="M3 21c0-2.1 1.7-3.9 3.8-4"></path><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"></path><path d="M22 13h-4"></path><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"></path></svg>`
const icon_example = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`
const icon_quote = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-quote"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"></path><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"></path></svg>`
/*
note,
abstract, summary, tldr
info
todo
tip
hint, important
success, check, done
question, help, faq
warning, caution, attention
failure, fail, missing
danger, error
bug
example
quote, cite
*/
type CalloutInfo = {icon: string, style: string}
const CalloutTypes = new Map<string, CalloutInfo>(Object.entries({
note: {
icon: icon_note,
style: 'note-callout-note',
},
abstract: {
icon: icon_abstract,
style: 'note-callout-abstract',
},
summary: {
icon: icon_abstract,
style: 'note-callout-abstract',
},
tldr: {
icon: icon_abstract,
style: 'note-callout-abstract',
},
info: {
icon: icon_info,
style: 'note-callout-note',
},
todo: {
icon: icon_todo,
style: 'note-callout-note',
},
tip: {
icon: icon_tip,
style: 'note-callout-abstract',
},
hint: {
icon: icon_tip,
style: 'note-callout-abstract',
},
important: {
icon: icon_tip,
style: 'note-callout-abstract',
},
success: {
icon: icon_success,
style: 'note-callout-success',
},
check: {
icon: icon_success,
style: 'note-callout-success',
},
done: {
icon: icon_success,
style: 'note-callout-success',
},
question: {
icon: icon_question,
style: 'note-callout-question',
},
help: {
icon: icon_question,
style: 'note-callout-question',
},
faq: {
icon: icon_question,
style: 'note-callout-question',
},
warning: {
icon: icon_warning,
style: 'note-callout-question',
},
caution: {
icon: icon_warning,
style: 'note-callout-question',
},
attention: {
icon: icon_warning,
style: 'note-callout-question',
},
failure: {
icon: icon_failure,
style: 'note-callout-failure',
},
fail: {
icon: icon_failure,
style: 'note-callout-failure',
},
missing: {
icon: icon_failure,
style: 'note-callout-failure',
},
danger: {
icon: icon_danger,
style: 'note-callout-failure',
},
error: {
icon: icon_danger,
style: 'note-callout-failure',
},
bug: {
icon: icon_bug,
style: 'note-callout-failure',
},
example: {
icon: icon_example,
style: 'note-callout-example',
},
quote: {
icon: icon_quote,
style: 'note-callout-quote',
},
cite: {
icon: icon_quote,
style: 'note-callout-quote',
}
}));
function GetCallout(type: string) {
return CalloutTypes.get(type);
};
function matchCallouts(text:string) {
const regex = /\[\!(.*?)\]/g;
let m;
if( m = regex.exec(text)) {
return m[1];
}
return "";
}
function GetCalloutTitle(callout:string, text:string) {
let title = callout.charAt(0).toUpperCase() + callout.slice(1).toLowerCase();
let start = text.indexOf(']') + 1;
if (text.indexOf(']-') > 0 || text.indexOf(']+') > 0) {
start = start + 1;
}
let end = text.indexOf('\n');
if (end === -1) end = text.length;
if (start >= end) return title;
const customTitle = text.slice(start, end).trim();
if (customTitle !== '') {
title = customTitle;
}
return title;
}
export class CalloutRenderer extends Extension {
matched(text: string) {
return matchCallouts(text) != '';
}
async renderer(token: Tokens.Blockquote) {
let callout = matchCallouts(token.text);
if (callout == '') {
const body = this.marked.parser(token.tokens);
return `<blockquote>${body}</blockquote>`;;
}
const title = GetCalloutTitle(callout, token.text);
const index = token.text.indexOf('\n');
let body = '';
if (index > 0) {
token.text = token.text.slice(index+1)
body = await this.marked.parse(token.text);
}
const setting = AssetsManager.getInstance().expertSettings.render?.callout as { [key: string]: any };
if (setting && callout.toLocaleLowerCase() in setting) {
const authkey = this.settings.authKey;
const widget = setting[callout.toLocaleLowerCase()];
if (typeof widget === 'number') {
return await wxWidget(authkey, JSON.stringify({
id: `${widget}`,
title,
content: body,
}));
}
if (typeof widget === 'object') {
const {id, style} = widget;
return await wxWidget(authkey, JSON.stringify({
id: `${id}`,
title,
style: style || {},
content: body,
}));
}
}
let info = GetCallout(callout.toLowerCase());
if (info == null) {
const svg = await this.assetsManager.loadIcon(callout);
if (svg) {
info = {icon: svg, style: 'note-callout-custom'}
}
else {
info = GetCallout('note');
}
}
return `<section class="note-callout ${info?.style}"><section class="note-callout-title-wrap"><span class="note-callout-icon">${info?.icon}</span><span class="note-callout-title">${title}<span></section><section class="note-callout-content">${body}</section></section>`;
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'blockquote') {
return;
}
token.html = await this.renderer(token as Tokens.Blockquote);
},
extensions:[{
name: 'blockquote',
level: 'block',
renderer: (token: Tokens.Generic)=> {
return token.html;
},
}]
}
}
}

287
src/markdown/code.ts Normal file
View File

@@ -0,0 +1,287 @@
/*
* 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 { Notice } from "obsidian";
import { MarkedExtension, Tokens } from "marked";
import hljs from "highlight.js";
import { MathRendererQueue } from "./math";
import { Extension } from "./extension";
import { UploadImageToWx } from "../imagelib";
import AssetsManager from "src/assets";
import { wxWidget } from "src/weixin-api";
export class CardDataManager {
private cardData: Map<string, string>;
private static instance: CardDataManager;
private constructor() {
this.cardData = new Map<string, string>();
}
// 静态方法,用于获取实例
public static getInstance(): CardDataManager {
if (!CardDataManager.instance) {
CardDataManager.instance = new CardDataManager();
}
return CardDataManager.instance;
}
public setCardData(id: string, cardData: string) {
this.cardData.set(id, cardData);
}
public cleanup() {
this.cardData.clear();
}
public restoreCard(html: string) {
for (const [key, value] of this.cardData.entries()) {
const exp = `<section[^>]*\\sdata-id="${key}"[^>]*>(.*?)<\\/section>`;
const regex = new RegExp(exp, 'gs');
if (!regex.test(html)) {
console.warn('没有公众号信息:', key);
continue;
}
html = html.replace(regex, value);
}
return html;
}
}
const MermaidSectionClassName = 'note-mermaid';
const MermaidImgClassName = 'note-mermaid-img';
export class CodeRenderer extends Extension {
showLineNumber: boolean;
mermaidIndex: number;
async prepare() {
this.mermaidIndex = 0;
}
static srcToBlob(src: string) {
const base64 = src.split(',')[1];
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: 'image/png' });
}
static async uploadMermaidImages(root: HTMLElement, token: string) {
const imgs = root.querySelectorAll('.' + MermaidImgClassName);
for (let img of imgs) {
const src = img.getAttribute('src');
if (!src) continue;
if (src.startsWith('http')) continue;
const blob = CodeRenderer.srcToBlob(img.getAttribute('src')!);
const name = img.id + '.png';
const res = await UploadImageToWx(blob, name, token);
if (res.errcode != 0) {
const msg = `上传图片失败: ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
continue;
}
const url = res.url;
img.setAttribute('src', url);
}
}
replaceSpaces(text: string) {
let result = '';
let inTag = false;
for (let char of text) {
if (char === '<') {
inTag = true;
result += char;
continue;
} else if (char === '>') {
inTag = false;
result += char;
continue;
}
if (inTag) {
result += char;
} else {
if (char === ' ') {
result += '&nbsp;';
} else if (char === '\t') {
result += '&nbsp;&nbsp;&nbsp;&nbsp;';
} else {
result += char;
}
}
}
return result;
}
async codeRenderer(code: string, infostring: string | undefined) {
const lang = (infostring || '').match(/^\S*/)?.[0];
code = code.replace(/\n$/, '');
try {
if (lang && hljs.getLanguage(lang)) {
code = hljs.highlight(code, { language: lang }).value;
}
else {
code = hljs.highlightAuto(code).value;
}
} catch (err) {
console.error(err);
}
code = this.replaceSpaces(code);
const lines = code.split('\n');
let body = '';
let liItems = '';
for (let line in lines) {
let text = lines[line];
if (text.length === 0) {
text = '<br>'
}
body = body + '<code>' + text + '</code>';
liItems = liItems + `<li>${parseInt(line)+1}</li>`;
}
let codeSection = '<section class="code-section code-snippet__fix hljs">';
if (this.settings.lineNumber) {
codeSection = codeSection + '<ul>'
+ liItems
+ '</ul>';
}
let html = '';
if (lang) {
html = codeSection + '<pre style="max-width:1000% !important;" class="hljs language-'
+ lang
+ '">'
+ body
+ '</pre></section>';
}
else {
html = codeSection + '<pre>'
+ body
+ '</pre></section>';
}
if (!this.settings.isAuthKeyVaild()) {
return html;
}
const settings = AssetsManager.getInstance().expertSettings;
const id = settings.render?.code;
if (id && typeof id === 'number') {
const params = JSON.stringify({
id: `${id}`,
content: html,
});
html = await wxWidget(this.settings.authKey, params);
}
return html;
}
static getMathType(lang: string | null) {
if (!lang) return null;
let l = lang.toLowerCase();
l = l.trim();
if (l === 'am' || l === 'asciimath') return 'asciimath';
if (l === 'latex' || l === 'tex') return 'latex';
return null;
}
parseCard(htmlString: string) {
const id = /data-id="([^"]+)"/;
const headimgRegex = /data-headimg="([^"]+)"/;
const nicknameRegex = /data-nickname="([^"]+)"/;
const signatureRegex = /data-signature="([^"]+)"/;
const idMatch = htmlString.match(id);
const headimgMatch = htmlString.match(headimgRegex);
const nicknameMatch = htmlString.match(nicknameRegex);
const signatureMatch = htmlString.match(signatureRegex);
return {
id: idMatch ? idMatch[1] : '',
headimg: headimgMatch ? headimgMatch[1] : '',
nickname: nicknameMatch ? nicknameMatch[1] : '公众号名称',
signature: signatureMatch ? signatureMatch[1] : '公众号介绍'
};
}
renderCard(token: Tokens.Code) {
const { id, headimg, nickname, signature } = this.parseCard(token.text);
if (id === '') {
return '<span>公众号卡片数据错误没有id</span>';
}
CardDataManager.getInstance().setCardData(id, token.text);
return `<section data-id="${id}" class="note-mpcard-wrapper"><div class="note-mpcard-content"><img class="note-mpcard-headimg" width="54" height="54" src="${headimg}"></img><div class="note-mpcard-info"><div class="note-mpcard-nickname">${nickname}</div><div class="note-mpcard-signature">${signature}</div></div></div><div class="note-mpcard-foot">公众号</div></section>`;
}
renderMermaid(token: Tokens.Code) {
try {
const meraidIndex = this.mermaidIndex;
const containerId = `mermaid-${meraidIndex}`;
this.callback.cacheElement('mermaid', containerId, token.raw);
this.mermaidIndex += 1;
return `<section id="${containerId}" class="${MermaidSectionClassName}"></section>`;
} catch (error) {
console.error(error.message);
return '<span>mermaid渲染失败</span>';
}
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'code') return;
if (this.settings.isAuthKeyVaild()) {
const type = CodeRenderer.getMathType(token.lang ?? '');
if (type) {
token.html = await MathRendererQueue.getInstance().render(token, false, type);
return;
}
if (token.lang && token.lang.trim().toLocaleLowerCase() == 'mermaid') {
token.html = this.renderMermaid(token as Tokens.Code);
return;
}
}
if (token.lang && token.lang.trim().toLocaleLowerCase() == 'mpcard') {
token.html = this.renderCard(token as Tokens.Code);
return;
}
token.html = await this.codeRenderer(token.text, token.lang);
},
extensions: [{
name: 'code',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}

78
src/markdown/commnet.ts Normal file
View File

@@ -0,0 +1,78 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const commentRegex = /^%%([\s\S]*?)%%/;
export class Comment extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'CommentInline',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('%%');
if (index === -1) return;
return index;
}
},
tokenizer(src: string) {
const match = src.match(commentRegex);
if (match) {
return {
type: 'CommentInline',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return '';
}
},
{
name: 'CommentBlock',
level: 'block',
tokenizer(src: string) {
const match = src.match(commentRegex);
if (match) {
return {
type: 'CommentBlock',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return '';
}
},
]
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const BlockMarkRegex = /^\^[0-9A-Za-z-]+$/;
export class EmbedBlockMark extends Extension {
allLinks:string[] = [];
async prepare() {
this.allLinks = [];
}
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'EmbedBlockMark',
level: 'inline',
start(src: string) {
let index = src.indexOf('^');
if (index === -1) {
return;
}
return index;
},
tokenizer(src: string) {
const match = src.match(BlockMarkRegex);
if (match) {
return {
type: 'EmbedBlockMark',
raw: match[0],
text: match[0]
};
}
},
renderer: (token: Tokens.Generic) => {
return `<span data-txt="${token.text}"></span}`;
}
}]
}
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
export class EmptyLineRenderer extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'emptyline',
level: 'block',
tokenizer(src: string) {
const match = /^\n\n+/.exec(src);
if (match) {
console.log('mathced src: ', src)
return {
type: "emptyline",
raw: match[0],
};
}
},
renderer: (token: Tokens.Generic) => {
return '<p><br></p>'.repeat(token.raw.length - 1);
},
}]
}
}
}

55
src/markdown/extension.ts Normal file
View File

@@ -0,0 +1,55 @@
/*
* 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 { NMPSettings } from "src/settings";
import { Marked, MarkedExtension } from "marked";
import { App, Vault } from "obsidian";
import AssetsManager from "../assets";
export interface MDRendererCallback {
settings: NMPSettings;
updateElementByID(id:string, html:string):void; // 改为异步渲染后已废弃
cacheElement(category: string, id: string, data: string): void;
}
export abstract class Extension {
app: App;
vault: Vault;
assetsManager: AssetsManager
settings: NMPSettings;
callback: MDRendererCallback;
marked: Marked;
constructor(app: App, settings: NMPSettings, assetsManager: AssetsManager, callback: MDRendererCallback) {
this.app = app;
this.vault = app.vault;
this.settings = settings;
this.assetsManager = assetsManager;
this.callback = callback;
}
async prepare() { return; }
async postprocess(html:string) { return html; }
async beforePublish() { }
async cleanup() { return; }
abstract markedExtension(): MarkedExtension
}

104
src/markdown/footnote.ts Normal file
View File

@@ -0,0 +1,104 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const refRule = /^\[\^([^\]]+)\]/; // 匹配 [^label]
const defRule = /^ *\[\^([^\]]+)\]:/; // 匹配 [^label]:
export class FootnoteRenderer extends Extension {
allDefs: any[] = [];
defCounter = 0;
async prepare() {
this.allDefs = [];
this.defCounter = 0;
}
async postprocess(html: string) {
if (this.allDefs.length == 0) {
return html;
}
let body = '';
for (const def of this.allDefs) {
const {label, content} = def;
const html = await this.marked.parse(content);
const id = `fn-${label}`;
body += `<li id="${id}">${html}</li>`;
}
return html + `<section class="footnotes"><hr><ol>${body}</ol></section>`;
}
markedExtension(): MarkedExtension {
return {
extensions: [
{
name: 'FootnoteRef',
level: 'inline',
start(src) {
const index = src.indexOf('[^');
return index > 0 ? index : -1;
},
tokenizer: (src) => {
const match = src.match(refRule);
if (match) {
return {
type: 'FootnoteRef',
raw: match[0],
text: match[1],
};
}
},
renderer: (token: Tokens.Generic) => {
this.defCounter += 1;
const id = `fnref-${this.defCounter}`;
return `<sup id="${id}">${this.defCounter}</sup>`;
}
},
{
name: 'FootnoteDef',
level: 'block',
tokenizer: (src) => {
const match = src.match(defRule);
if (match) {
const label = match[1].trim();
const end = src.indexOf('\n');
const raw = end === -1 ? src: src.substring(0, end + 1);
const content = raw.substring(match[0].length);
this.allDefs.push({label, content});
return {
type: 'FootnoteDef',
raw: raw,
text: content,
};
}
},
renderer: (token: Tokens.Generic) => {
return '';
}
}
]
}
}
}

131
src/markdown/heading.ts Normal file
View File

@@ -0,0 +1,131 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
import AssetsManager from "src/assets";
import { ExpertSettings } from "src/expert-settings";
import { wxWidget } from "src/weixin-api";
export class HeadingRenderer extends Extension {
index = [0, 0, 0, 0];
expertSettings: ExpertSettings;
headingSettings: any[]
async prepare() {
this.index = [0, 0, 0, 0];
this.expertSettings = AssetsManager.getInstance().expertSettings;
this.headingSettings = [undefined, undefined, undefined, undefined];
if (!this.expertSettings.render) {
return;
}
if (this.expertSettings.render.h1) {
this.headingSettings[1] = this.expertSettings.render.h1;
}
if (this.expertSettings.render.h2) {
this.headingSettings[2] = this.expertSettings.render.h2;
}
if (this.expertSettings.render.h3) {
this.headingSettings[3] = this.expertSettings.render.h3;
}
}
async renderWithTemplate(token: Tokens.Heading, template: string) {
const content = await this.marked.parseInline(token.text);
return template.replace('{content}', content);
}
async renderWithWidgetId(token: Tokens.Heading, widgetId: number) {
const authkey = this.settings.authKey;
const content = await this.marked.parseInline(token.text);
const params = JSON.stringify({
id: `${widgetId}`,
title: content,
});
return await wxWidget(authkey, params);
}
async renderWithWidget(token: Tokens.Heading, widgetId: number, counter: boolean|undefined, len: number|undefined, style: object|undefined = undefined) {
const authkey = this.settings.authKey;
let title = token.text;
if (counter === undefined) {
counter = false;
}
if (len === undefined) {
len = 1;
}
if (style === undefined) {
style = new Map<string, string>();
}
if (counter) {
title = `${this.index[token.depth]}`;
if (title.length < len) {
title = title.padStart(len, '0');
}
}
const content = await this.marked.parseInline(token.text);
const params = JSON.stringify({
id: `${widgetId}`,
title,
style,
content: '<p>' + content + '</p>',
});
return await wxWidget(authkey, params);
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'heading') {
return;
}
const setting = this.headingSettings[token.depth];
this.index[token.depth] += 1;
if (setting) {
if (typeof setting === 'string') {
token.html = await this.renderWithTemplate(token as Tokens.Heading, setting);
}
else if (typeof setting === 'number') {
token.html = await this.renderWithWidgetId(token as Tokens.Heading, setting);
}
else {
const { id, counter, len, style } = setting;
token.html = await this.renderWithWidget(token as Tokens.Heading, id, counter, len, style);
}
return;
}
const body = await this.marked.parseInline(token.text);
token.html = `<h${token.depth}>${body}</h${token.depth}>`;
},
extensions: [{
name: 'heading',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}

122
src/markdown/icons.ts Normal file
View File

@@ -0,0 +1,122 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const iconsRegex = /^\[:(.*?):\]/;
export class SVGIcon extends Extension {
isNumeric(str: string): boolean {
return !isNaN(Number(str)) && str.trim() !== '';
}
getSize(size: string) {
const items = size.split('x');
let width, height;
if (items.length == 2) {
width = items[0];
height = items[1];
}
else {
width = items[0];
height = items[0];
}
width = this.isNumeric(width) ? width+'px' : width;
height = this.isNumeric(height) ? height+'px' : height;
return {width, height};
}
renderStyle(items: string[]) {
let size = '';
let color = '';
if (items.length == 3) {
size = items[1];
color = items[2];
}
else if (items.length == 2) {
if (items[1].startsWith('#')) {
color = items[1];
}
else {
size = items[1];
}
}
let style = '';
if (size.length > 0) {
const {width, height} = this.getSize(size);
style += `width:${width};height:${height};`;
}
if (color.length > 0) {
style += `color:${color};`;
}
return style.length > 0 ? `style="${style}"` : '';
}
async render(text: string) {
const items = text.split('|');
const name = items[0];
const svg = await this.assetsManager.loadIcon(name);
const body = svg==='' ? '未找到图标' + name : svg;
const style = this.renderStyle(items);
return `<span class="note-svg-icon" ${style}>${body}</span>`
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'SVGIcon') {
return;
}
token.html = await this.render(token.text);
},
extensions: [{
name: 'SVGIcon',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('[:');
if (index === -1) return;
return index;
}
},
tokenizer(src: string) {
const match = src.match(iconsRegex);
if (match) {
return {
type: 'SVGIcon',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return token.html;
}
}]
}
}
}

69
src/markdown/link.ts Normal file
View File

@@ -0,0 +1,69 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
export class LinkRenderer extends Extension {
allLinks:string[] = [];
async prepare() {
this.allLinks = [];
}
async postprocess(html: string) {
if (this.settings.linkStyle !== 'footnote'
|| this.allLinks.length == 0) {
return html;
}
const links = this.allLinks.map((href, i) => {
return `<li>${href}&nbsp;↩</li>`;
});
return `${html}<seciton class="footnotes"><hr><ol>${links.join('')}</ol></section>`;
}
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'link',
level: 'inline',
renderer: (token: Tokens.Link) => {
if (token.href.startsWith('mailto:')) {
return token.text;
}
if (token.text.indexOf(token.href) === 0
|| (token.href.indexOf('https://mp.weixin.qq.com/mp') === 0)
|| (token.href.indexOf('https://mp.weixin.qq.com/s') === 0)) {
return `<a href="${token.href}">${token.text}</a>`;
}
this.allLinks.push(token.href);
if (this.settings.linkStyle == 'footnote') {
return `<a>${token.text}<sup>[${this.allLinks.length}]</sup></a>`;
}
else {
return `<a>${token.text}[${token.href}]</a>`;
}
}
}]
}
}
}

831
src/markdown/local-file.ts Normal file
View File

@@ -0,0 +1,831 @@
/*
* 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 { Token, Tokens, MarkedExtension } from "marked";
import { Notice, TAbstractFile, TFile, Vault, MarkdownView, requestUrl, Platform } from "obsidian";
import { Extension } from "./extension";
import { NMPSettings } from "../settings";
import { IsImageLibReady, PrepareImageLib, WebpToJPG, UploadImageToWx } from "../imagelib";
declare module 'obsidian' {
interface Vault {
config: {
attachmentFolderPath: string;
newLinkFormat: string;
useMarkdownLinks: boolean;
};
}
}
const LocalFileRegex = /^!\[\[(.*?)\]\]/;
interface ImageInfo {
resUrl: string;
filePath: string;
url: string | null;
media_id: string | null;
}
export class LocalImageManager {
private images: Map<string, ImageInfo>;
private static instance: LocalImageManager;
private constructor() {
this.images = new Map<string, ImageInfo>();
}
// 静态方法,用于获取实例
public static getInstance(): LocalImageManager {
if (!LocalImageManager.instance) {
LocalImageManager.instance = new LocalImageManager();
}
return LocalImageManager.instance;
}
public setImage(path: string, info: ImageInfo): void {
if (!this.images.has(path)) {
this.images.set(path, info);
}
}
isWebp(file: TFile | string): boolean {
if (file instanceof TFile) {
return file.extension.toLowerCase() === 'webp';
}
const name = file.toLowerCase();
return name.endsWith('.webp');
}
async uploadLocalImage(token: string, vault: Vault, type: string = '') {
const keys = this.images.keys();
await PrepareImageLib();
const result = [];
for (let key of keys) {
const value = this.images.get(key);
if (value == null) continue;
if (value.url != null) continue;
const file = vault.getFileByPath(value.filePath);
if (file == null) continue;
let fileData = await vault.readBinary(file);
let name = file.name;
if (this.isWebp(file)) {
if (IsImageLibReady()) {
fileData = WebpToJPG(fileData);
name = name.toLowerCase().replace('.webp', '.jpg');
}
else {
console.error('wasm not ready for webp');
}
}
const res = await UploadImageToWx(new Blob([fileData]), name, token, type);
if (res.errcode != 0) {
const msg = `上传图片失败: ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
}
value.url = res.url;
value.media_id = res.media_id;
result.push(res);
}
return result;
}
checkImageExt(filename: string ): boolean {
const name = filename.toLowerCase();
if (name.endsWith('.jpg')
|| name.endsWith('.jpeg')
|| name.endsWith('.png')
|| name.endsWith('.gif')
|| name.endsWith('.bmp')
|| name.endsWith('.tiff')
|| name.endsWith('.svg')
|| name.endsWith('.webp')) {
return true;
}
return false;
}
getImageNameFromUrl(url: string, type: string): string {
try {
// 创建URL对象
const urlObj = new URL(url);
// 获取pathname部分
const pathname = urlObj.pathname;
// 获取最后一个/后的内容作为文件名
let filename = pathname.split('/').pop() || '';
filename = decodeURIComponent(filename);
if (!this.checkImageExt(filename)) {
filename = filename + this.getImageExt(type);
}
return filename;
} catch (e) {
// 如果URL解析失败尝试简单的字符串处理
const queryIndex = url.indexOf('?');
if (queryIndex !== -1) {
url = url.substring(0, queryIndex);
}
return url.split('/').pop() || '';
}
}
getImageExtFromBlob(blob: Blob): string {
// MIME类型到文件扩展名的映射
const mimeToExt: { [key: string]: string } = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/bmp': '.bmp',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/tiff': '.tiff'
};
// 获取MIME类型
const mimeType = blob.type.toLowerCase();
// 返回对应的扩展名,如果找不到则返回空字符串
return mimeToExt[mimeType] || '';
}
base64ToBlob(src: string) {
const items = src.split(',');
if (items.length != 2) {
throw new Error('base64格式错误');
}
const mineType = items[0].replace('data:', '');
const base64 = items[1];
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return {blob: new Blob([byteArray], { type: mineType }), ext: this.getImageExt(mineType)};
}
async uploadImageFromUrl(url: string, token: string, type: string = '') {
try {
const rep = await requestUrl(url);
await PrepareImageLib();
let data = rep.arrayBuffer;
let blob = new Blob([data]);
let filename = this.getImageNameFromUrl(url, rep.headers['content-type']);
if (filename == '' || filename == null) {
filename = 'remote_img' + this.getImageExtFromBlob(blob);
}
if (this.isWebp(filename)) {
if (IsImageLibReady()) {
data = WebpToJPG(data);
blob = new Blob([data]);
filename = filename.toLowerCase().replace('.webp', '.jpg');
}
else {
console.error('wasm not ready for webp');
}
}
return await UploadImageToWx(blob, filename, token, type);
}
catch (e) {
console.error(e);
throw new Error('上传图片失败:' + e.message + '|' + url);
}
}
getImageExt(type: string): string {
const mimeToExt: { [key: string]: string } = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/bmp': '.bmp',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/tiff': '.tiff'
};
return mimeToExt[type] || '.jpg';
}
getMimeType(ext: string): string {
const extToMime: { [key: string]: string } = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.tiff': 'image/tiff'
};
return extToMime[ext.toLowerCase()] || 'image/jpeg';
}
getImageInfos(root: HTMLElement) {
const images = root.getElementsByTagName('img');
const result = [];
for (let i = 0; i < images.length; i++) {
const img = images[i];
const res = this.images.get(img.src);
if (res) {
result.push(res);
}
}
return result;
}
async uploadRemoteImage(root: HTMLElement, token: string, type: string = '') {
const images = root.getElementsByTagName('img');
const result = [];
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (img.src.includes('mmbiz.qpic.cn')) continue;
// 移动端本地图片不通过src上传
if (img.src.startsWith('http://localhost/') && Platform.isMobileApp) {
continue;
}
if (img.src.startsWith('http')) {
const res = await this.uploadImageFromUrl(img.src, token, type);
if (res.errcode != 0) {
const msg = `上传图片失败: ${img.src} ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
}
const info = {
resUrl: img.src,
filePath: "",
url: res.url,
media_id: res.media_id,
};
this.images.set(img.src, info);
result.push(res);
}
else if (img.src.startsWith('data:image/')) {
const {blob, ext} = this.base64ToBlob(img.src);
if (!img.id) {
img.id = `local-img-${i}`;
}
const name = img.id + ext;
const res = await UploadImageToWx(blob, name, token);
if (res.errcode != 0) {
const msg = `上传图片失败: ${res.errcode} ${res.errmsg}`;
new Notice(msg);
console.error(msg);
continue;
}
const info = {
resUrl: '#' + img.id,
filePath: "",
url: res.url,
media_id: res.media_id,
};
this.images.set('#' + img.id, info);
result.push(res);
}
}
return result;
}
replaceImages(root: HTMLElement) {
const images = root.getElementsByTagName('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
let value = this.images.get(img.src);
if (value == null) {
if (!img.id) {
console.error('miss image id, ' + img.src);
continue;
}
value = this.images.get('#' + img.id);
}
if (value == null) continue;
if (value.url == null) continue;
img.setAttribute('src', value.url);
}
}
arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
async localImagesToBase64(vault: Vault) {
const keys = this.images.keys();
const result = new Map<string, string>();
for (let key of keys) {
const value = this.images.get(key);
if (value == null) continue;
const file = vault.getFileByPath(value.filePath);
if (file == null) continue;
let fileData = await vault.readBinary(file);
const base64 = this.arrayBufferToBase64(fileData);
const mimeType = this.getMimeType(file.extension);
const data = `data:${mimeType};base64,${base64}`;
result.set(value.resUrl, data);
}
return result;
}
async downloadRemoteImage(url: string) {
try {
const rep = await requestUrl(url);
let data = rep.arrayBuffer;
let blob = new Blob([data]);
let ext = this.getImageExtFromBlob(blob);
if (ext == '' || ext == null) {
const filename = this.getImageNameFromUrl(url, rep.headers['content-type']);
ext = '.' + filename.split('.').pop() || 'jpg';
}
const base64 = this.arrayBufferToBase64(data);
const mimeType = this.getMimeType(ext);
return `data:${mimeType};base64,${base64}`;
}
catch (e) {
console.error(e);
return '';
}
}
async remoteImagesToBase64(root: HTMLElement) {
const images = root.getElementsByTagName('img');
const result = new Map<string, string>();
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (!img.src.startsWith('http')) continue;
const base64 = await this.downloadRemoteImage(img.src);
if (base64 == '') continue;
result.set(img.src, base64);
}
return result;
}
async embleImages(root: HTMLElement, vault: Vault) {
const localImages = await this.localImagesToBase64(vault);
const remoteImages = await this.remoteImagesToBase64(root);
const result = root.cloneNode(true) as HTMLElement;
const images = result.getElementsByTagName('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (img.src.startsWith('http')) {
const base64 = remoteImages.get(img.src);
if (base64 != null) {
img.setAttribute('src', base64);
}
}
else {
const base64 = localImages.get(img.src);
if (base64 != null) {
img.setAttribute('src', base64);
}
}
}
return result.innerHTML;
}
async cleanup() {
this.images.clear();
}
}
export class LocalFile extends Extension{
index: number = 0;
public static fileCache: Map<string, string> = new Map<string, string>();
generateId() {
this.index += 1;
return `fid-${this.index}`;
}
getImagePath(path: string) {
const res = this.assetsManager.getResourcePath(path);
if (res == null) {
console.error('找不到文件:' + path);
return '';
}
const info = {
resUrl: res.resUrl,
filePath: res.filePath,
media_id: null,
url: null
};
LocalImageManager.getInstance().setImage(res.resUrl, info);
return res.resUrl;
}
isImage(file: string) {
file = file.toLowerCase();
return file.endsWith('.png')
|| file.endsWith('.jpg')
|| file.endsWith('.jpeg')
|| file.endsWith('.gif')
|| file.endsWith('.bmp')
|| file.endsWith('.webp');
}
parseImageLink(link: string) {
if (link.includes('|')) {
const parts = link.split('|');
const path = parts[0];
if (!this.isImage(path)) return null;
let width = null;
let height = null;
if (parts.length == 2) {
const size = parts[1].toLowerCase().split('x');
width = parseInt(size[0]);
if (size.length == 2 && size[1] != '') {
height = parseInt(size[1]);
}
}
return { path, width, height };
}
if (this.isImage(link)) {
return { path: link, width: null, height: null };
}
return null;
}
getHeaderLevel(line: string) {
const match = line.trimStart().match(/^#{1,6}/);
if (match) {
return match[0].length;
}
return 0;
}
async getFileContent(file: TAbstractFile, header: string | null, block: string | null) {
const content = await this.app.vault.adapter.read(file.path);
if (header == null && block == null) {
return content;
}
let result = '';
const lines = content.split('\n');
if (header) {
let level = 0;
let append = false;
for (let line of lines) {
if (append) {
if (level == this.getHeaderLevel(line)) {
break;
}
result += line + '\n';
continue;
}
if (!line.trim().startsWith('#')) continue;
const items = line.trim().split(' ');
if (items.length != 2) continue;
if (header.trim() != items[1].trim()) continue;
if (this.getHeaderLevel(line)) {
result += line + '\n';
level = this.getHeaderLevel(line);
append = true;
}
}
}
function isStructuredBlock(line: string) {
const trimmed = line.trim();
return trimmed.startsWith('-') || trimmed.startsWith('>') || trimmed.startsWith('|') || trimmed.match(/^\d+\./);
}
if (block) {
let stopAtEmpty = false;
let totalLen = 0;
let structured = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.indexOf(block) >= 0) {
result = line.replace(block, '').trim();
// 标记和结构化内容位于同一行的时候只返回当前的条目
if (isStructuredBlock(line)) {
break;
}
// 向上查找内容
for (let j = i - 1; j >= 0; j--) {
const l = lines[j];
if (l.startsWith('#')) {
break;
}
if (l.trim() == '') {
if (stopAtEmpty) break;
if (j < i - 1 && totalLen > 0) break;
stopAtEmpty = true;
result = l + '\n' + result;
continue;
}
else {
stopAtEmpty = true;
}
if (structured && !isStructuredBlock(l)) {
break;
}
if (totalLen === 0 && isStructuredBlock(l)) {
structured = true;
}
totalLen += result.length;
result = l + '\n' + result;
}
break;
}
}
}
return result;
}
parseFileLink(link: string) {
const info = link.split('|')[0];
const items = info.split('#');
let path = items[0];
let header = null;
let block = null;
if (items.length == 2) {
if (items[1].startsWith('^')) {
block = items[1];
} else {
header = items[1];
}
}
return { path, head: header, block };
}
async renderFile(link: string, id: string) {
let { path, head: header, block} = this.parseFileLink(link);
let file = null;
if (path === '') {
file = this.app.workspace.getActiveFile();
}
else {
if (!path.endsWith('.md')) {
path = path + '.md';
}
file = this.assetsManager.searchFile(path);
}
if (file == null) {
const msg = '找不到文件:' + path;
console.error(msg)
return msg;
}
let content = await this.getFileContent(file, header, block);
if (content.startsWith('---')) {
content = content.replace(/^(---)$.+?^(---)$.+?/ims, '');
}
const body = await this.marked.parse(content);
return body;
}
static async readBlob(src: string) {
return await fetch(src).then(response => response.blob())
}
static async getExcalidrawUrl(data: string) {
const url = 'https://obplugin.sunboshi.tech/math/excalidraw';
const req = await requestUrl({
url,
method: 'POST',
contentType: 'application/json',
headers: {
authkey: NMPSettings.getInstance().authKey
},
body: JSON.stringify({ data })
});
if (req.status != 200) {
console.error(req.status);
return null;
}
return req.json.url;
}
parseLinkStyle(link: string) {
let filename = '';
let style = 'style="width:100%;height:100%"';
let postion = 'left';
const postions = ['left', 'center', 'right'];
if (link.includes('|')) {
const items = link.split('|');
filename = items[0];
let size = '';
if (items.length == 2) {
if (postions.includes(items[1])) {
postion = items[1];
}
else {
size = items[1];
}
}
else if (items.length == 3) {
size = items[1];
if (postions.includes(items[1])) {
size = items[2];
postion = items[1];
}
else {
size = items[1];
postion = items[2];
}
}
if (size != '') {
const sizes = size.split('x');
if (sizes.length == 2) {
style = `style="width:${sizes[0]}px;height:${sizes[1]}px;"`
}
else {
style = `style="width:${sizes[0]}px;"`
}
}
}
else {
filename = link;
}
return { filename, style, postion };
}
parseExcalidrawLink(link: string) {
let classname = 'note-embed-excalidraw-left';
const postions = new Map<string, string>([
['left', 'note-embed-excalidraw-left'],
['center', 'note-embed-excalidraw-center'],
['right', 'note-embed-excalidraw-right']
])
let {filename, style, postion} = this.parseLinkStyle(link);
classname = postions.get(postion) || classname;
if(filename.endsWith('excalidraw') || filename.endsWith('excalidraw.md')) {
return { filename, style, classname };
}
return null;
}
static async renderExcalidraw(html: string) {
try {
const src = await this.getExcalidrawUrl(html);
let svg = '';
if (src === '') {
svg = '渲染失败';
console.log('Failed to get Excalidraw URL');
}
else {
const blob = await this.readBlob(src);
if (blob.type === 'image/svg+xml') {
svg = await blob.text();
}
else {
svg = '暂不支持' + blob.type;
}
}
return svg;
} catch (error) {
console.error(error.message);
return '渲染失败:' + error.message;
}
}
parseSVGLink(link: string) {
let classname = 'note-embed-svg-left';
const postions = new Map<string, string>([
['left', 'note-embed-svg-left'],
['center', 'note-embed-svg-center'],
['right', 'note-embed-svg-right']
])
let {filename, style, postion} = this.parseLinkStyle(link);
classname = postions.get(postion) || classname;
return { filename, style, classname };
}
async renderSVGFile(filename: string, id: string) {
const file = this.assetsManager.searchFile(filename);
if (file == null) {
const msg = '找不到文件:' + file;
console.error(msg)
return msg;
}
const content = await this.getFileContent(file, null, null);
LocalFile.fileCache.set(filename, content);
return content;
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type !== 'LocalImage') {
return;
}
// 渲染本地图片
let item = this.parseImageLink(token.href);
if (item) {
const src = this.getImagePath(item.path);
const width = item.width ? `width="${item.width}"` : '';
const height = item.height? `height="${item.height}"` : '';
token.html = `<img src="${src}" alt="${token.text}" ${width} ${height} />`;
return;
}
const info = this.parseExcalidrawLink(token.href);
if (info) {
if (!NMPSettings.getInstance().isAuthKeyVaild()) {
token.html = "<span>请设置注册码</span>";
return;
}
const id = this.generateId();
this.callback.cacheElement('excalidraw', id, token.raw);
token.html = `<span class="${info.classname}"><span class="note-embed-excalidraw" id="${id}" ${info.style}></span></span>`
return;
}
if (token.href.endsWith('.svg') || token.href.includes('.svg|')) {
const info = this.parseSVGLink(token.href);
const id = this.generateId();
let svg = '渲染中';
if (LocalFile.fileCache.has(info.filename)) {
svg = LocalFile.fileCache.get(info.filename) || '渲染失败';
}
else {
svg = await this.renderSVGFile(info.filename, id) || '渲染失败';
}
token.html = `<span class="${info.classname}"><span class="note-embed-svg" id="${id}" ${info.style}>${svg}</span></span>`
return;
}
const id = this.generateId();
const content = await this.renderFile(token.href, id);
const tag = this.callback.settings.embedStyle === 'quote' ? 'blockquote' : 'section';
token.html = `<${tag} class="note-embed-file" id="${id}">${content}</${tag}>`
},
extensions:[{
name: 'LocalImage',
level: 'block',
start: (src: string) => {
const index = src.indexOf('![[');
if (index === -1) return;
return index;
},
tokenizer: (src: string) => {
const matches = src.match(LocalFileRegex);
if (matches == null) return;
const token: Token = {
type: 'LocalImage',
raw: matches[0],
href: matches[1],
text: matches[1]
};
return token;
},
renderer: (token: Tokens.Generic) => {
return token.html;
}
}]};
}
}

208
src/markdown/math.ts Normal file
View File

@@ -0,0 +1,208 @@
/*
* 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 { MarkedExtension, Token, Tokens } from "marked";
import { requestUrl } from "obsidian";
import { Extension } from "./extension";
import { NMPSettings } from "src/settings";
const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1/;
const blockRule = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/;
const svgCache = new Map<string, string>();
export function cleanMathCache() {
svgCache.clear();
}
export class MathRendererQueue {
private host = 'https://obplugin.sunboshi.tech';
private static instance: MathRendererQueue;
private mathIndex: number = 0;
// 静态方法,用于获取实例
public static getInstance(): MathRendererQueue {
if (!MathRendererQueue.instance) {
MathRendererQueue.instance = new MathRendererQueue();
}
return MathRendererQueue.instance;
}
private constructor() {
}
async getMathSVG(expression: string, inline: boolean, type: string) {
try {
let success = false;
let path = '';
if (type === 'asciimath') {
path = '/math/am';
}
else {
path = '/math/tex';
}
const url = `${this.host}${path}`;
const res = await requestUrl({
url,
method: 'POST',
contentType: 'application/json',
headers: {
authkey: NMPSettings.getInstance().authKey
},
body: JSON.stringify({
expression,
inline
})
})
let svg = ''
if (res.status === 200) {
svg = res.text;
success = true;
}
else {
console.error('render error: ' + res.json.msg)
svg = '渲染失败: ' + res.json.msg;
}
return { svg, success };
}
catch (err) {
console.log(err.msg);
const svg = '渲染失败: ' + err.message;
return { svg, success: false };
}
}
generateId() {
this.mathIndex += 1;
return `math-id-${this.mathIndex}`;
}
async render(token: Tokens.Generic, inline: boolean, type: string) {
if (!NMPSettings.getInstance().isAuthKeyVaild()) {
return '<span>注册码无效或已过期</span>';
}
const id = this.generateId();
let svg = '渲染中';
const expression = token.text;
if (svgCache.has(token.text)) {
svg = svgCache.get(expression) as string;
}
else {
const res = await this.getMathSVG(expression, inline, type)
if (res.success) {
svgCache.set(expression, res.svg);
}
svg = res.svg;
}
const className = inline ? 'inline-math-svg' : 'block-math-svg';
const body = inline ? svg : `<section class="block-math-section">${svg}</section>`;
return `<span id="${id}" class="${className}">${body}</span>`;
}
}
export class MathRenderer extends Extension {
async renderer(token: Tokens.Generic, inline: boolean, type: string = '') {
if (type === '') {
type = this.settings.math;
}
return await MathRendererQueue.getInstance().render(token, inline, type);
}
markedExtension(): MarkedExtension {
return {
async: true,
walkTokens: async (token: Tokens.Generic) => {
if (token.type === 'InlineMath' || token.type === 'BlockMath') {
token.html = await this.renderer(token, token.type === 'InlineMath', token.displayMode ? 'latex' : 'asciimath');
}
},
extensions: [
this.inlineMath(),
this.blockMath()
]
}
}
inlineMath() {
return {
name: 'InlineMath',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('$');
if (index === -1) {
return;
}
const possibleKatex = indexSrc.substring(index);
if (possibleKatex.match(inlineRule)) {
return index;
}
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
}
},
tokenizer(src: string, tokens: Token[]) {
const match = src.match(inlineRule);
if (match) {
return {
type: 'InlineMath',
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2
};
}
},
renderer: (token: Tokens.Generic) => {
return token.html;
}
}
}
blockMath() {
return {
name: 'BlockMath',
level: 'block',
tokenizer(src: string) {
const match = src.match(blockRule);
if (match) {
return {
type: 'BlockMath',
raw: match[0],
text: match[2].trim(),
displayMode: match[1].length === 2
};
}
},
renderer: (token: Tokens.Generic) => {
return token.html;
}
};
}
}

167
src/markdown/parser.ts Normal file
View File

@@ -0,0 +1,167 @@
/*
* 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 { Marked } from "marked";
import { NMPSettings } from "src/settings";
import { App, Vault } from "obsidian";
import AssetsManager from "../assets";
import { Extension, MDRendererCallback } from "./extension";
import { Blockquote} from "./blockquote";
import { CodeRenderer } from "./code";
import { EmbedBlockMark } from "./embed-block-mark";
import { SVGIcon } from "./icons";
import { LinkRenderer } from "./link";
import { LocalFile, LocalImageManager } from "./local-file";
import { MathRenderer } from "./math";
import { TextHighlight } from "./text-highlight";
import { Comment } from "./commnet";
import { Topic } from "./topic";
import { HeadingRenderer } from "./heading";
import { FootnoteRenderer } from "./footnote";
import { EmptyLineRenderer } from "./empty-line";
import { cleanUrl } from "../utils";
const markedOptiones = {
gfm: true,
breaks: true,
};
const customRenderer = {
hr(): string {
return '<hr>';
},
list(body: string, ordered: boolean, start: number | ''): string {
const type = ordered ? 'ol' : 'ul';
const startatt = (ordered && start !== 1) ? (' start="' + start + '"') : '';
return '<' + type + startatt + ' class="list-paddingleft-1">' + body + '</' + type + '>';
},
listitem(text: string, task: boolean, checked: boolean): string {
return `<li><section><span data-leaf="">${text}<span></section></li>`;
},
image(href: string, title: string | null, text: string): string {
const cleanHref = cleanUrl(href);
if (cleanHref === null) {
return text;
}
href = cleanHref;
if (!href.startsWith('http')) {
const res = AssetsManager.getInstance().getResourcePath(decodeURI(href));
if (res) {
href = res.resUrl;
const info = {
resUrl: res.resUrl,
filePath: res.filePath,
media_id: null,
url: null
};
LocalImageManager.getInstance().setImage(res.resUrl, info);
}
}
let out = '';
if (NMPSettings.getInstance().useFigcaption) {
out = `<figure style="display: flex; flex-direction: column; align-items: center;"><img src="${href}" alt="${text}"`;
if (title) {
out += ` title="${title}"`;
}
if (text.length > 0) {
out += `><figcaption>${text}</figcaption></figure>`;
}
else {
out += '></figure>'
}
}
else {
out = `<img src="${href}" alt="${text}"`;
if (title) {
out += ` title="${title}"`;
}
out += '>';
}
return out;
}
};
export class MarkedParser {
extensions: Extension[] = [];
marked: Marked;
app: App;
vault: Vault;
constructor(app: App, callback: MDRendererCallback) {
this.app = app;
this.vault = app.vault;
const settings = NMPSettings.getInstance();
const assetsManager = AssetsManager.getInstance();
this.extensions.push(new LocalFile(app, settings, assetsManager, callback));
this.extensions.push(new Blockquote(app, settings, assetsManager, callback));
this.extensions.push(new EmbedBlockMark(app, settings, assetsManager, callback));
this.extensions.push(new SVGIcon(app, settings, assetsManager, callback));
this.extensions.push(new LinkRenderer(app, settings, assetsManager, callback));
this.extensions.push(new TextHighlight(app, settings, assetsManager, callback));
this.extensions.push(new CodeRenderer(app, settings, assetsManager, callback));
this.extensions.push(new Comment(app, settings, assetsManager, callback));
this.extensions.push(new Topic(app, settings, assetsManager, callback));
this.extensions.push(new HeadingRenderer(app, settings, assetsManager, callback));
this.extensions.push(new FootnoteRenderer(app, settings, assetsManager, callback));
if (settings.enableEmptyLine) {
this.extensions.push(new EmptyLineRenderer(app, settings, assetsManager, callback));
}
if (settings.isAuthKeyVaild()) {
this.extensions.push(new MathRenderer(app, settings, assetsManager, callback));
}
}
async buildMarked() {
this.marked = new Marked();
this.marked.use(markedOptiones);
for (const ext of this.extensions) {
this.marked.use(ext.markedExtension());
ext.marked = this.marked;
await ext.prepare();
}
this.marked.use({renderer: customRenderer});
}
async prepare() {
this.extensions.forEach(async ext => await ext.prepare());
}
async postprocess(html: string) {
let result = html;
for (let ext of this.extensions) {
result = await ext.postprocess(result);
}
return result;
}
async parse(content: string) {
if (!this.marked) await this.buildMarked();
await this.prepare();
let html = await this.marked.parse(content);
html = await this.postprocess(html);
return html;
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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 { Token, Tokens, Lexer, MarkedExtension } from "marked";
import { Extension } from "./extension";
const highlightRegex = /^==(.*?)==/;
export class TextHighlight extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'InlineHighlight',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('==');
if (index === -1) return;
return index;
}
},
tokenizer(src: string, tokens: Token[]) {
const match = src.match(highlightRegex);
if (match) {
return {
type: 'InlineHighlight',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
const lexer = new Lexer();
const tokens = lexer.lex(token.text);
// TODO: 优化一下
let body = this.parser.parse(tokens)
body = body.replace('<p>', '')
body = body.replace('</p>', '')
return `<span class="note-highlight">${body}</span>`;
}
}]
};
}
}

61
src/markdown/topic.ts Normal file
View File

@@ -0,0 +1,61 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
const topicRegex = /^#([^\s#]+)/;
export class Topic extends Extension {
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'Topic',
level: 'inline',
start(src: string) {
let index;
let indexSrc = src;
while (indexSrc) {
index = indexSrc.indexOf('#');
if (index === -1) return;
return index;
}
},
tokenizer(src: string) {
const match = src.match(topicRegex);
if (match) {
return {
type: 'Topic',
raw: match[0],
text: match[1],
};
}
},
renderer(token: Tokens.Generic) {
return `<a class="wx_topic_link" style="color: #576B95 !important;" data-topic="1">${'#' + token.text.trim()}</a>`;
}
},
]
}
}
}

179
src/markdown/widget-box.ts Normal file
View File

@@ -0,0 +1,179 @@
/*
* 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 { Tokens, MarkedExtension } from "marked";
import { Extension } from "./extension";
import { NMPSettings } from "src/settings";
import { uevent } from "src/utils";
import { wxWidget } from "src/weixin-api";
const widgetCache = new Map<string, string>();
export function cleanWidgetCache() {
widgetCache.clear();
}
export class WidgetBox extends Extension {
mapToString(map: Map<string, string>): string {
if (map.size === 0) return "";
return Array.from(map.entries())
.map(([key, value]) => `${key}=${value}`)
.join("&"); // 用 "&" 连接键值对,可换成其他分隔符
}
calcKey(id: string, title: string, style: Map<string, string>, content: string) {
const styleStr = this.mapToString(style);
const key = `${id}-${title}-${styleStr}-${content}`;
return key;
}
cacheWidget(id: string, title: string, style: Map<string, string>, content: string, result: string) {
const key = this.calcKey(id, title, style, content);
widgetCache.set(key, result);
}
getWidget(id: string, title: string, style: Map<string, string>, content: string) {
const key = this.calcKey(id, title, style, content);
if (!widgetCache.has(key)) {
return null;
}
return widgetCache.get(key);
}
getBoxTitle(text: string) {
let start = text.indexOf(']') + 1;
let end = text.indexOf('\n');
if (end === -1) end = text.length;
if (start >= end) return '';
return text.slice(start, end).trim();
}
getBoxId(text: string) {
const regex = /\[#(.*?)\]/g;
let m;
if( m = regex.exec(text)) {
return m[1];
}
return "";
}
matched(text: string) {
return this.getBoxId(text) != "";
}
parseStyle(text: string) {
const style = text.split(':').map((s) => s.trim());
if (style.length != 2) return null;
const key = style[0];
const value = style[1];
return {key, value};
}
parseBox(text: string) {
const lines = text.split('\n');
let style = new Map<string, string>();
let content = [];
let isStyle = false;
for (let line of lines) {
if (line === '===') {
isStyle = !isStyle;
continue;
}
if (isStyle) {
const s = this.parseStyle(line);
if (s) style.set(s.key, s.value);
} else {
content.push(line);
}
}
const contentStr = content.join('\n');
return { style, contentStr };
}
async reqContent(id: string, title: string, style: Map<string, string>, content: string) {
const params = JSON.stringify({
id,
title,
style: Object.fromEntries(style),
content
});
return wxWidget(NMPSettings.getInstance().authKey, params)
}
processColor(style: Map<string, string>) {
const keys = style.keys();
for (let key of keys) {
if (key.includes('color')) {
const value = style.get(key);
if (!value) continue;
if (value.startsWith('rgb') || value.startsWith('#')) {
continue;
}
style.set(key, '#' + value);
}
}
}
async renderer(token: Tokens.Blockquote) {
let boxId = this.getBoxId(token.text);
if (boxId == '') {
const body = this.marked.parser(token.tokens);
return `<blockquote>${body}</blockquote>`;;
}
const title = this.getBoxTitle(token.text);
let style = new Map<string, string>();
let content = '';
const index = token.text.indexOf('\n');
if (index > 0) {
const pared = this.parseBox(token.text.slice(index + 1))
style = pared.style;
content = await this.marked.parse(pared.contentStr);
}
this.processColor(style);
const cached = this.getWidget(boxId, title, style, content);
if (cached) {
uevent('render-widgets-cached');
return cached;
}
else {
const reqContent = await this.reqContent(boxId, title, style, content);
this.cacheWidget(boxId, title, style, content, reqContent);
uevent('render-widgets');
return reqContent;
}
}
markedExtension(): MarkedExtension {
return {
extensions: [{
name: 'blockquote',
level: 'block',
renderer: (token: Tokens.Generic) => {
return token.html;
},
}]
}
}
}