obsidian-img2other/main.js

557 lines
17 KiB
JavaScript
Raw Normal View History

/* eslint-disable */
const { Plugin, Notice, TFile } = require('obsidian');
/* ========== 图片优化配置 ========== */
const IMAGE_CONFIG = {
// 图片压缩最大宽度(像素)- 超过此宽度的图片会被等比缩放
MAX_WIDTH: 800,
// 图片压缩质量0-1 之间1 为最高质量)
// 推荐值0.8-0.85,在质量和文件大小之间取得良好平衡
QUALITY: 0.8,
// 显示最大宽度(像素)- 图片在编辑器中的最大显示宽度
DISPLAY_MAX_WIDTH: 600
};
/* ========== 安全大图转 base64 ========== */
async function buf2base64(buf) {
// 使用更直接的方式转换为 base64避免数据损失
return new Promise((resolve, reject) => {
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
resolve(btoa(binary));
});
}
/* ========== 图片压缩和尺寸限制 ========== */
async function compressImage(buf, ext, maxWidth = IMAGE_CONFIG.MAX_WIDTH, quality = IMAGE_CONFIG.QUALITY) {
return new Promise((resolve, reject) => {
try {
// 创建 Blob
const mime = getMime(ext);
const blob = new Blob([buf], { type: mime });
// 创建图片元素
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
// 释放临时 URL
URL.revokeObjectURL(url);
// 计算新尺寸(保持宽高比)
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
// 如果图片本来就很小,且不需要转换格式,直接返回
if (width === img.width && (ext.toLowerCase() === 'jpg' || ext.toLowerCase() === 'jpeg' || ext.toLowerCase() === 'png')) {
resolve({ data: buf, needsConversion: false });
return;
}
// 创建 Canvas 进行压缩
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// 转换为 BlobJPEG 格式压缩效果更好)
canvas.toBlob(
(compressedBlob) => {
if (!compressedBlob) {
reject(new Error('图片压缩失败'));
return;
}
// 将 Blob 转为 ArrayBuffer
const reader = new FileReader();
reader.onload = () => {
resolve({
data: reader.result,
needsConversion: true,
mime: 'image/jpeg' // 统一转为 JPEG
});
};
reader.onerror = reject;
reader.readAsArrayBuffer(compressedBlob);
},
'image/jpeg', // 使用 JPEG 格式
quality // 压缩质量
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('图片加载失败'));
};
img.src = url;
} catch (err) {
reject(err);
}
});
}
/* ========== mime 映射 ========== */
const getMime = ext => ({
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
bmp: 'image/bmp',
ico: 'image/x-icon',
tiff: 'image/tiff',
tif: 'image/tiff'
}[ext.toLowerCase()] || 'application/octet-stream');
/* ========== Markdown 转 HTML保留完整排版 ========== */
function markdownToHtml(md) {
let html = md;
// 转义 HTML 特殊字符(用于代码块等)
const escapeHtml = (text) => {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// 1. 代码块(先处理,避免内部语法被转换)
const codeBlocks = [];
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
codeBlocks.push(`<pre><code class="language-${lang || ''}">${escapeHtml(code.trim())}</code></pre>`);
return placeholder;
});
// 2. 行内代码
const inlineCodes = [];
html = html.replace(/`([^`]+)`/g, (match, code) => {
const placeholder = `___INLINE_CODE_${inlineCodes.length}___`;
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
return placeholder;
});
// 3. 表格处理Markdown 表格)
const tables = [];
html = html.replace(/^\|(.+)\|[ \t]*\n\|[-:\s|]+\|[ \t]*\n((?:\|.+\|[ \t]*\n?)*)/gm, (match, header, body) => {
const placeholder = `___TABLE_${tables.length}___`;
// 处理表头
const headers = header.split('|').map(h => h.trim()).filter(h => h);
const headerRow = headers.map(h => `<th>${h}</th>`).join('');
// 处理表体
const rows = body.trim().split('\n').map(row => {
const cells = row.split('|').map(c => c.trim()).filter(c => c);
return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
}).join('\n');
const tableHtml = `<table><thead><tr>${headerRow}</tr></thead><tbody>${rows}</tbody></table>`;
tables.push(tableHtml);
return placeholder;
});
// 4. 标题h1-h6
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
// 5. 引用块
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// 合并连续的引用块
html = html.replace(/(<\/blockquote>\n<blockquote>)/g, '\n');
// 6. 分割线
html = html.replace(/^(?:---|\*\*\*|___)$/gm, '<hr />');
// 7. 列表处理(改进版)
// 无序列表
html = html.replace(/^[\*\-\+]\s+(.+)$/gm, '<li>$1</li>');
// 有序列表
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li class="ordered">$1</li>');
// 合并列表项
html = html.replace(/(<li>(?:(?!<li class="ordered">).)*?<\/li>\n?)+/g, '<ul>$&</ul>');
html = html.replace(/(<li class="ordered">.*?<\/li>\n?)+/g, (match) => {
return '<ol>' + match.replace(/ class="ordered"/g, '') + '</ol>';
});
// 8. 文本样式(顺序很重要)
// 粗体+斜体(***text*** 或 ___text___
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>');
// 粗体(**text** 或 __text__
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体(*text* 或 _text_
html = html.replace(/\*([^\s*](?:.*?[^\s*])?)\*/g, '<em>$1</em>');
html = html.replace(/_([^\s_](?:.*?[^\s_])?)_/g, '<em>$1</em>');
// 删除线
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 高亮(==text==
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
// 9. 链接 [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// 10. 段落处理(改进版)
const lines = html.split('\n');
const processed = [];
let inParagraph = false;
for (let line of lines) {
const trimmed = line.trim();
// 如果是块级元素或占位符,不需要包裹 <p>
if (trimmed.match(/^<(h[1-6]|ul|ol|li|blockquote|pre|hr|div|img|table|___)/)) {
if (inParagraph) {
processed.push('</p>');
inParagraph = false;
}
processed.push(line);
} else if (trimmed === '') {
if (inParagraph) {
processed.push('</p>');
inParagraph = false;
}
} else {
if (!inParagraph) {
processed.push('<p>');
inParagraph = true;
}
processed.push(line);
}
}
if (inParagraph) {
processed.push('</p>');
}
html = processed.join('\n');
// 11. 还原占位符
// 还原表格
tables.forEach((table, i) => {
html = html.replace(`___TABLE_${i}___`, table);
});
// 还原代码块
codeBlocks.forEach((code, i) => {
html = html.replace(`___CODE_BLOCK_${i}___`, code);
});
// 还原行内代码
inlineCodes.forEach((code, i) => {
html = html.replace(`___INLINE_CODE_${i}___`, code);
});
// 12. 清理
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/\n{3,}/g, '\n\n');
return html;
}
/* ========== 复制全文 + 内嵌图片 ========== */
async function copyActiveFileWithImages(plugin) {
const file = plugin.app.workspace.getActiveFile();
if (!file || file.extension !== 'md') {
new Notice('请先打开一篇 Markdown 笔记');
return;
}
let md = await plugin.app.vault.read(file);
const baseFolder = file.parent.path;
const assetsFolder = `${baseFolder}/${file.basename}.assets`;
/* 1. Wiki 语法 ![[xxx.png]] */
md = await replaceAsync(md, /!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g,
async (_, raw) => {
const name = raw.split('|')[0].trim();
return await inlineImage(name, '');
});
/* 2. 标准语法 ![alt](url) */
md = await replaceAsync(md, /!\[([^\]]*)\]\(([^)]+)\)/g,
async (_, alt, src) => {
src = decodeURIComponent(src).trim();
return await inlineImage(src, alt);
});
/* 3. 转换为 HTML */
const htmlContent = markdownToHtml(md);
/* 4. 生成完整的 HTML 文档(包含样式) */
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* 通用样式 - 适配多平台 */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
/* 标题样式 */
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
/* 段落 */
p {
margin-top: 0;
margin-bottom: 16px;
}
/* 图片样式 - 关键优化 */
img {
max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px;
width: 100%;
height: auto;
display: block;
margin: 15px auto;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* 代码块 */
pre {
background-color: #f6f8fa;
border-radius: 6px;
padding: 16px;
overflow: auto;
margin: 16px 0;
}
code {
background-color: rgba(175, 184, 193, 0.2);
padding: 0.2em 0.4em;
border-radius: 6px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
pre code {
background-color: transparent;
padding: 0;
}
/* 引用块 */
blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 16px 0;
}
/* 列表 */
ul, ol {
padding-left: 2em;
margin: 16px 0;
}
li {
margin: 0.25em 0;
}
/* 分割线 */
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
/* 链接 */
a {
color: #0366d6;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* 删除线 */
del {
text-decoration: line-through;
opacity: 0.7;
}
/* 高亮 */
mark {
background-color: #fff3cd;
padding: 0.2em 0;
}
/* 表格(如果有) */
table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
th, td {
border: 1px solid #dfe2e5;
padding: 6px 13px;
}
th {
background-color: #f6f8fa;
font-weight: 600;
}
</style>
</head>
<body>
${htmlContent}
</body>
</html>
`.trim();
/* 5. 写入剪贴板 - 多格式支持 */
try {
if (navigator.clipboard && window.ClipboardItem) {
// 准备多种格式,提高平台兼容性
const clipboardData = {
'text/plain': new Blob([md], { type: 'text/plain' }),
'text/html': new Blob([fullHtml], { type: 'text/html' })
};
const clipboardItem = new ClipboardItem(clipboardData);
await navigator.clipboard.write([clipboardItem]);
new Notice('✅ 已复制全文(含图片和样式)!\n支持飞书、微信公众号、知乎等平台');
} else {
// 降级:仅支持纯文本
await navigator.clipboard.writeText(md);
new Notice('⚠️ 已复制纯文本格式(浏览器不支持富文本)');
}
} catch (err) {
console.error('[bulk-copy] 复制失败', err);
new Notice('❌ 复制失败,请检查浏览器权限设置');
}
/* ----------- 单张图内嵌 ----------- */
async function inlineImage(imgName, alt) {
const candidates = [
`${assetsFolder}/${imgName}`,
`${baseFolder}/${imgName}`,
imgName.startsWith('/') ? imgName.slice(1) : imgName
];
let imgFile = candidates
.map(p => plugin.app.vault.getAbstractFileByPath(p))
.find(f => f instanceof TFile);
if (!imgFile) { // 全库搜文件名
const all = plugin.app.vault.getFiles();
imgFile = all.find(f => f.name === imgName);
}
if (!imgFile) {
console.warn('[bulk-copy] 未找到图片文件', imgName);
return `![${alt}](${imgName})`;
}
try {
const buf = await plugin.app.vault.readBinary(imgFile);
const ext = imgFile.extension;
const mime = getMime(ext);
// 特殊处理 SVG 文件(不压缩)
if (ext.toLowerCase() === 'svg') {
// 对于 SVG直接读取文本内容
const svgContent = await plugin.app.vault.read(imgFile);
const base64Svg = btoa(unescape(encodeURIComponent(svgContent)));
// 限制 SVG 最大宽度
return `<img src="data:${mime};base64,${base64Svg}" alt="${alt}" style="max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px; width: 100%; height: auto; display: block; margin: 15px auto;" />`;
}
// GIF 动图不压缩,保持动画效果
if (ext.toLowerCase() === 'gif') {
const data = await buf2base64(buf);
const altText = alt ? alt.replace(/"/g, '&quot;') : '';
const imgStyle = `max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px; width: 100%; height: auto; display: block; margin: 15px auto;`;
return `<img src="data:${mime};base64,${data}" alt="${altText}" style="${imgStyle}" />`;
}
// 处理其他图片格式:压缩图片
const compressed = await compressImage(buf, ext, IMAGE_CONFIG.MAX_WIDTH, IMAGE_CONFIG.QUALITY);
let finalMime = mime;
let finalData;
if (compressed.needsConversion) {
// 图片已被压缩,使用压缩后的数据
finalMime = compressed.mime;
finalData = await buf2base64(compressed.data);
} else {
// 图片无需压缩,使用原始数据
finalData = await buf2base64(compressed.data);
}
if (!finalData) {
throw new Error('Base64 转换失败');
}
// 添加内联样式,限制图片显示宽度
// 某些平台(如微信公众号)会过滤外部样式,所以使用内联样式
const imgStyle = `max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px; width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 4px;`;
const altText = alt ? alt.replace(/"/g, '&quot;') : '';
return `<img src="data:${finalMime};base64,${finalData}" alt="${altText}" style="${imgStyle}" />`;
} catch (e) {
console.warn('[bulk-copy] 读取失败', imgName, e);
return `![${alt}](${imgName})`;
}
}
}
/* ========== 异步 replace ========== */
async function replaceAsync(str, regex, asyncFn) {
const promises = [];
str.replace(regex, (match, ...args) => {
promises.push(asyncFn(match, ...args));
return match;
});
const reps = await Promise.all(promises);
return str.replace(regex, () => reps.shift());
}
/* ========== 插件入口 ========== */
module.exports = class BulkCopyImagesPlugin extends Plugin {
onload() {
this.addCommand({
id: 'bulk-copy-with-images',
name: '复制全文并内嵌本地图片',
hotkeys: [{ modifiers: ['Ctrl', 'Shift'], key: 'c' }],
callback: () => copyActiveFileWithImages(this)
});
}
onunload() {}
};