// ==UserScript==
// @name Markdown文件阅读器
// @namespace http://tampermonkey.net/
// @version 2.0
// @description 纯离线处理Markdown,内置解析器,完美支持本地文件,右上角一键切换预览与源码。
// @author MRBANK
// @match file:///*
// @match *://*/*.md
// @match *://*/*.markdown
// @grant GM_addStyle
// @run-at document-start
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjMjgyYTM2IiBzdHJva2U9IiM1MGZhN2IiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHRleHQgeD0iMTIiIHk9IjE2LjUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9ImJvbGQiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiM1MGZhN2IiPk08L3RleHQ+PC9zdmc+
// ==/UserScript==
(function() {
'use strict';
// ========== 配置 ==========
const CONFIG = {
autoRender: true,
ignoreHosts: ['google.com', 'baidu.com', 'bing.com', 'github.com', 'twitter.com']
};
// ========== 内置 Markdown 解析引擎 ==========
const MarkdownParser = {
parse(md) {
let html = md;
html = this.parseCodeBlocks(html);
html = this.escapeHtml(html);
// Headers
html = html.replace(/^######\s+(.+)$/gm, '
$1
');
html = html.replace(/^#####\s+(.+)$/gm, '$1
');
html = html.replace(/^####\s+(.+)$/gm, '$1
');
html = html.replace(/^###\s+(.+)$/gm, '$1
');
html = html.replace(/^##\s+(.+)$/gm, '$1
');
html = html.replace(/^#\s+(.+)$/gm, '$1
');
// HR
html = html.replace(/^(-{3,}|_{3,}|\*{3,})$/gm, '
');
// Images & Links
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '
');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
// Styles
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1');
html = html.replace(/\*\*(.+?)\*\*/g, '$1');
html = html.replace(/\*(.+?)\*/g, '$1');
html = html.replace(/~~(.+?)~~/g, '$1');
html = html.replace(/`([^`]+)`/g, '$1');
// Blockquotes, Lists, Tables
html = this.parseBlockquotes(html);
html = this.parseLists(html);
html = this.parseTables(html);
html = this.restoreCodeBlocks(html);
html = this.cleanupHtmlArtifacts(html);
// Paragraphs
html = this.wrapParagraphs(html);
return html;
},
escapeHtml(text) {
return text.replace(/&/g, '&').replace(//g, '>');
},
codeBlocks: [],
parseCodeBlocks(text) {
this.codeBlocks = [];
return text.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
const index = this.codeBlocks.push({ lang, code: code.trim() }) - 1;
return `CODEBLOCKPLACEHOLDER${index}BLOCK`;
});
},
restoreCodeBlocks(text) {
return text.replace(/CODEBLOCKPLACEHOLDER(\d+)BLOCK/g, (match, index) => {
const block = this.codeBlocks[index];
const escapedCode = block.code.replace(/&/g, '&').replace(//g, '>');
return `${this.highlight(escapedCode, block.lang)}
`;
});
},
highlight(code, lang) {
const keywords = {
js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'import', 'export'],
python: ['def', 'class', 'return', 'if', 'else', 'import', 'from', 'as', 'True', 'False'],
default: []
};
const kwList = keywords[lang] || keywords.default;
const placeholderStore = [];
const stash = (text) => `HIGHLIGHTPLACEHOLDER${placeholderStore.push(text) - 1}TOKEN`;
let res = code;
res = res.replace(/(["'`])(?:(?!\1)[^\\]|\\.)*?\1/g, match => stash(`${match}`));
res = res.replace(/(\/\/.*$|#.*$)/gm, match => stash(`${match}`));
res = res.replace(/\b(\d+\.?\d*)\b/g, '$1');
kwList.forEach(k => {
res = res.replace(new RegExp(`\\b(${k})\\b`, 'g'), '$1');
});
res = res.replace(/HIGHLIGHTPLACEHOLDER(\d+)TOKEN/g, (match, index) => placeholderStore[Number(index)] || '');
return res;
},
parseBlockquotes(text) {
const lines = text.split('\n');
let html = [];
let inQuote = false;
lines.forEach(line => {
if (line.startsWith('> ') || line.startsWith('> ')) {
if (!inQuote) { html.push(''); inQuote = true; }
html.push(line.replace(/^(?:>|>)\s*/, ''));
} else {
if (inQuote) { html.push('
'); inQuote = false; }
html.push(line);
}
});
if (inQuote) html.push('');
return html.join('\n');
},
parseLists(text) {
const lines = text.split('\n');
let html = [];
let listType = null;
lines.forEach(line => {
const ulMatch = line.match(/^[\s]*[-*+]\s+(.+)/);
const olMatch = line.match(/^[\s]*\d+\.\s+(.+)/);
if (ulMatch) {
if (listType !== 'ul') { if (listType) html.push(`${listType}>`); html.push(''); listType = 'ul'; }
html.push(`- ${ulMatch[1]}
`);
} else if (olMatch) {
if (listType !== 'ol') { if (listType) html.push(`${listType}>`); html.push(''); listType = 'ol'; }
html.push(`- ${olMatch[1]}
`);
} else {
if (listType) { html.push(`${listType}>`); listType = null; }
html.push(line);
}
});
if (listType) html.push(`${listType}>`);
return html.join('\n');
},
parseTables(text) {
return text.replace(/^(\|.+\|)\s*\n\|[-:\s|]+\|\s*\n((?:\|.+\|\s*\n?)+)/gm, (m, h, b) => {
const headerCells = h.split('|').slice(1, -1).map(s => `${s.trim()} | `).join('');
const bodyRows = b.trim().split('\n').map(r => {
const rowCells = r.split('|').slice(1, -1).map(c => `${c.trim()} | `).join('');
return `${rowCells}
`;
}).join('');
return `${headerCells}
${bodyRows}
`;
});
},
wrapParagraphs(text) {
const blocks = text.split(/\n\n+/);
return blocks.map(block => {
block = block.trim();
if (!block) return '';
if (/^<(h[1-6]|ul|ol|pre|blockquote|table|hr|div)\b/i.test(block)) {
return block;
}
return `${block.replace(/\n/g, '
')}
`;
}).join('\n');
},
cleanupHtmlArtifacts(html) {
return html
.replace(/<(\/)?span class="(md-hl-(?:num|str|kw|cmt))">/g, '<$1span class="$2">')
.replace(/<(\/)?(strong|em|del|code|blockquote|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|p|a|img|hr|div)([^&]*)>/g, '<$1$2$3>')
.replace(/<br>/g, '
')
.replace(/<\/span>/g, '')
.replace(/<\/a>/g, '')
.replace(/<\/code>/g, '')
.replace(/<\/strong>/g, '')
.replace(/<\/em>/g, '')
.replace(/<\/del>/g, '')
.replace(/"/g, '"');
}
};
// ========== 样式 ==========
GM_addStyle(`
/* 控制面板容器 - 深色背景 */
#md-control-panel {
position: fixed;
top: 12px;
right: 12px;
z-index: 2147483647;
display: flex;
background: #2b2b2b; /* 深色背景 */
padding: 4px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
border: 1px solid #444;
}
/* 按钮通用样式 */
.md-btn-control {
background: transparent;
color: #999;
border: none;
padding: 6px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease-in-out;
line-height: 1.5;
}
/* 按钮悬浮效果 */
.md-btn-control:hover {
color: #fff;
}
/* 激活状态 - 浅灰色背景白色文字 */
.md-btn-control.active {
background: #555; /* 浅灰色背景 */
color: #fff; /* 白色文字 */
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
/* Markdown 渲染页面样式 */
.md-rendered-body {
background: #282a36;
color: #f8f8f2;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
padding: 40px 20px;
max-width: 900px;
margin: 0 auto;
}
.md-rendered-body h1, .md-rendered-body h2, .md-rendered-body h3,
.md-rendered-body h4, .md-rendered-body h5, .md-rendered-body h6 {
color: #bd93f9;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.md-rendered-body h1 { font-size: 2em; border-bottom: 1px solid #444; padding-bottom: .3em; }
.md-rendered-body h2 { font-size: 1.5em; border-bottom: 1px solid #444; padding-bottom: .3em; }
.md-rendered-body p { margin-bottom: 16px; }
.md-rendered-body a { color: #8be9fd; text-decoration: none; }
.md-rendered-body a:hover { text-decoration: underline; }
.md-rendered-body code {
background: #44475a;
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
color: #50fa7b;
}
.md-rendered-body pre {
background: #1e1f29;
border-radius: 6px;
padding: 16px;
overflow: auto;
line-height: 1.45;
font-size: 14px;
}
.md-rendered-body pre code {
background: transparent;
padding: 0;
color: #f8f8f2;
}
/* 语法高亮 */
.md-hl-kw { color: #ff79c6; }
.md-hl-str { color: #f1fa8c; }
.md-hl-num { color: #bd93f9; }
.md-hl-cmt { color: #6272a4; font-style: italic; }
.md-rendered-body blockquote {
border-left: 4px solid #bd93f9;
padding-left: 16px;
margin: 0 0 16px 0;
color: #b0b0b0;
}
.md-rendered-body table {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
}
.md-rendered-body table th, .md-rendered-body table td {
border: 1px solid #444;
padding: 8px 12px;
}
.md-rendered-body table th { background: #44475a; font-weight: 600; }
.md-rendered-body table tr:nth-child(even) { background: rgba(40, 42, 54, 0.5); }
.md-rendered-body img {
max-width: 100%;
border-radius: 4px;
margin: 10px 0;
}
.md-rendered-body hr {
border: 0;
height: 1px;
background-image: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0));
margin: 2em 0;
}
`);
// ========== 逻辑控制 ==========
let isRendered = false;
let originalContent = '';
// 创建控制面板
const panel = document.createElement('div');
panel.id = 'md-control-panel';
// 创建按钮
const btnPreview = document.createElement('button');
btnPreview.className = 'md-btn-control active'; // 默认激活预览
btnPreview.textContent = 'Preview';
const btnSource = document.createElement('button');
btnSource.className = 'md-btn-control';
btnSource.textContent = 'Markdown';
const btnExportWord = document.createElement('button');
btnExportWord.className = 'md-btn-control';
btnExportWord.textContent = 'Export .doc';
panel.appendChild(btnPreview);
panel.appendChild(btnSource);
panel.appendChild(btnExportWord);
// 判断逻辑
const url = window.location.href;
const isLocalFile = window.location.protocol === 'file:';
const isMarkdownFile = url.endsWith('.md') || url.endsWith('.markdown') || url.endsWith('.txt');
const isPlainText = document.body ? (document.body.childElementCount === 1 && document.body.firstElementChild.tagName === 'PRE') : false;
function init() {
document.body.appendChild(panel);
originalContent = document.body.innerText;
if (isLocalFile || isMarkdownFile || isPlainText) {
renderMarkdown();
} else {
// 如果不是自动渲染,默认处于源码模式,所以 Markdown 按钮应该是激活状态
btnSource.classList.add('active');
btnPreview.classList.remove('active');
}
}
function getMarkdownSource() {
return document.body.dataset.mdSource || originalContent || document.body.innerText || '';
}
function getDocumentTitle() {
const pathname = decodeURIComponent(window.location.pathname || 'document');
const filename = pathname.split('/').pop() || 'document';
return filename.replace(/\.(md|markdown|txt)$/i, '') || 'document';
}
function getExportTimestamp() {
const now = new Date();
const pad = (num) => String(num).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function escapeHtmlForWord(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function buildWordHtml(title, html) {
const exportTime = getExportTimestamp();
return `
${escapeHtmlForWord(title)}
${html}
`;
}
function normalizeMixedText(text) {
return text
.replace(/\u00a0/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/([\u4e00-\u9fff])\s+([A-Za-z0-9@#&])/g, '$1 $2')
.replace(/([A-Za-z0-9@#&])\s+([\u4e00-\u9fff])/g, '$1 $2')
.replace(/([\u4e00-\u9fff])\s+([,。!?;:、])/g, '$1$2')
.replace(/([(《“‘])\s+/g, '$1')
.replace(/\s+([)》。!?;:,、”’])/g, '$1')
.replace(/\s+([,.!?;:])/g, '$1')
.replace(/([,.!?;:])(?!\s|$|[)\]}>"'])/g, '$1 ')
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')')
.replace(/\[\s+/g, '[')
.replace(/\s+\]/g, ']')
.replace(/\{\s+/g, '{')
.replace(/\s+\}/g, '}')
.trim();
}
function normalizeBlockHtml(html) {
return html
.replace(/ /g, ' ')
.replace(/\s*
\s*/gi, '
')
.replace(/(
){3,}/gi, '
')
.replace(/[ \t]{2,}/g, ' ')
.replace(/(?:
\s*)+$/gi, '')
.replace(/^(?:\s*
)+/gi, '')
.trim();
}
function simplifyWordHtml(html) {
const container = document.createElement('div');
container.innerHTML = html;
container.querySelectorAll('pre code').forEach(codeEl => {
const plainCode = codeEl.textContent || '';
const newCode = document.createElement('code');
newCode.textContent = plainCode
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd();
codeEl.replaceWith(newCode);
});
container.querySelectorAll('code:not(pre code)').forEach(codeEl => {
codeEl.textContent = (codeEl.textContent || '').replace(/\s+/g, ' ').trim();
});
container.querySelectorAll('br + br').forEach(br => br.remove());
container.querySelectorAll('p, li, blockquote, td, th').forEach(el => {
el.innerHTML = normalizeBlockHtml(el.innerHTML);
});
container.querySelectorAll('p, li, blockquote, td, th, h1, h2, h3, h4, h5, h6').forEach(el => {
if (el.children.length === 0) {
el.textContent = normalizeMixedText(el.textContent || '');
}
});
container.querySelectorAll('li').forEach(li => {
li.innerHTML = normalizeBlockHtml(li.innerHTML);
if (!li.innerHTML) {
li.remove();
}
});
container.querySelectorAll('ul, ol').forEach(list => {
const items = list.querySelectorAll(':scope > li');
if (items.length === 0) {
list.remove();
}
});
container.querySelectorAll('blockquote').forEach(quote => {
quote.innerHTML = normalizeBlockHtml(quote.innerHTML);
});
container.querySelectorAll('td, th').forEach(cell => {
cell.innerHTML = normalizeBlockHtml(cell.innerHTML);
});
container.querySelectorAll('p').forEach(p => {
if (!p.textContent.trim() && !p.querySelector('img, br, code')) {
p.remove();
}
});
container.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src') || '';
const alt = normalizeMixedText(img.getAttribute('alt') || '图片');
const fallback = document.createElement('div');
fallback.className = 'md-image-fallback';
fallback.innerHTML = src
? `图片:${escapeHtmlForWord(alt)}
路径:${escapeHtmlForWord(src)}`
: `图片:${escapeHtmlForWord(alt)}`;
img.replaceWith(fallback);
});
container.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
if (attr.name === 'class' || attr.name === 'style') {
el.removeAttribute(attr.name);
}
});
});
let cleanedHtml = container.innerHTML;
cleanedHtml = cleanedHtml
.replace(/ /g, ' ')
.replace(/ /g, ' ')
.replace(/\u00a0/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.replace(/>\s+<')
.replace(/<(p|li|blockquote|td|th)>
<\/(p|li|blockquote|td|th)>/gi, '')
.replace(/<(ul|ol)>\s*<\/(ul|ol)>/gi, '')
.trim();
cleanedHtml = cleanedHtml
.replace(/\s*<\/p>/gi, '')
.replace(/
- \s*<\/li>/gi, '')
.replace(/
\s*<\/blockquote>/gi, '')
.replace(/| \s*<\/td>/gi, ' | | ')
.replace(/\s*<\/th>/gi, ' | | ')
.replace(/
\s*<\/li>/gi, '
')
.replace(/
\s*<\/p>/gi, '')
.replace(/
\s*<\/blockquote>/gi, '')
.replace(/<\/li>\s*- /gi, '
- ')
.replace(/<\/p>\s*
/gi, '
')
.replace(/<\/blockquote>\s*
/gi, '
');
return cleanedHtml;
}
function downloadBlob(filename, content, mimeType, addBom = true) {
const blob = content instanceof Blob
? content
: new Blob(addBom ? ['\ufeff', content] : [content], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
}
function exportWord() {
const source = getMarkdownSource();
const html = MarkdownParser.parse(source);
const simplifiedHtml = simplifyWordHtml(html);
const title = getDocumentTitle();
const wordHtml = buildWordHtml(title, simplifiedHtml);
downloadBlob(`${title}.doc`, wordHtml, 'application/msword;charset=utf-8');
}
function renderMarkdown() {
if (isRendered) return;
if (!document.body.dataset.mdSource) {
document.body.dataset.mdSource = originalContent;
}
const html = MarkdownParser.parse(document.body.dataset.mdSource);
document.body.innerHTML = '';
document.body.className = 'md-rendered-body';
document.body.innerHTML = html;
document.body.appendChild(panel);
// 更新按钮状态
btnPreview.classList.add('active');
btnSource.classList.remove('active');
isRendered = true;
}
function showSource() {
if (!isRendered) return;
document.body.className = '';
document.body.innerHTML = '';
const pre = document.createElement('pre');
pre.style.whiteSpace = 'pre-wrap';
pre.style.wordWrap = 'break-word';
pre.style.fontFamily = 'monospace';
pre.textContent = document.body.dataset.mdSource || originalContent;
document.body.appendChild(pre);
document.body.appendChild(panel);
// 更新按钮状态
btnSource.classList.add('active');
btnPreview.classList.remove('active');
isRendered = false;
}
// 绑定事件
btnPreview.addEventListener('click', () => {
if (!isRendered) renderMarkdown();
});
btnSource.addEventListener('click', () => {
if (isRendered) showSource();
});
btnExportWord.addEventListener('click', () => {
try {
exportWord();
} catch (error) {
console.error('导出 Word 失败:', error);
alert('导出 Word 失败,请打开控制台查看错误信息。');
}
});
// 等待DOM加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();