Luogu.me article2PDF
感谢 CuteMurasame 的这篇文章为本文提供了灵感!
由于 Article2PDF「不支持从 luogu.me 打印成 PDF」,于是 DeepSeek-V3.2(和我)花了一个下午时间写了这段针对保存站文章的脚本。
食用方法与 Article2PDF 类似,具体的可以去看 Article2PDF 的介绍文章。需要注意的是该插件没有独立的「设置」功能,设置面板将在点击「打印为 PDF」时弹出。
:::info[Source Code] 基本由 DeepSeek 实现。
// ==UserScript==
// @name Luogu.me Article2PDF
// @namespace http://tampermonkey.net/
// @version 1.2
// @description 将 luogu.me 专栏快速打印为 PDF。
// @author Gemini 3.1 Pro & Murasame & DeepSeek-V3.2 & ShootingStar
// @match *://www.luogu.me/article/*
// @require https://fastly.jsdelivr.net/npm/sweetalert2@11
// @license MIT
// ==/UserScript==
(function() {
'use strict';
if (typeof window.Swal === 'undefined') {
const swalScript = document.createElement('script');
swalScript.src = 'https://fastly.jsdelivr.net/npm/sweetalert2@11';
document.head.appendChild(swalScript);
}
const DEFAULT_CONFIG = {
mainFont: 'Lato, "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", sans-serif',
codeFont: '"Fira Code", Consolas, Monaco, monospace',
showCodeBlockBorder: true,
showLineNumbers: true
};
function getConfig() {
try {
const saved = localStorage.getItem('luogu_me_pdf_config');
return saved ? { ...DEFAULT_CONFIG, ...JSON.parse(saved) } : DEFAULT_CONFIG;
} catch (e) {
return DEFAULT_CONFIG;
}
}
function saveConfig(cfg) {
localStorage.setItem('luogu_me_pdf_config', JSON.stringify(cfg));
}
// 创建设置弹窗(返回 Promise)
function showSettingsModal() {
return new Promise((resolve) => {
// 如果已经存在弹窗,先移除
const existingModal = document.getElementById('luogu-me-pdf-modal');
if (existingModal) {
existingModal.remove();
}
const overlay = document.createElement('div');
overlay.id = 'luogu-me-pdf-modal';
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '999999';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
const modal = document.createElement('div');
modal.style.backgroundColor = '#fff';
modal.style.padding = '24px 32px';
modal.style.borderRadius = '8px';
modal.style.width = '400px';
modal.style.boxShadow = '0 10px 25px rgba(0,0,0,0.2)';
modal.style.fontFamily = 'sans-serif';
const cfg = getConfig();
modal.innerHTML = `
<h3 style="margin-top:0; margin-bottom: 20px; font-size: 18px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px;">PDF 打印设置</h3>
<div style="margin-bottom: 15px;">
<label style="display:block; font-size: 14px; margin-bottom: 5px; color: #555;">正文字体(留空使用默认):</label>
<input id="cfg-mainFont" type="text" value="${cfg.mainFont.replace(/"/g, '"')}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;" />
</div>
<div style="margin-bottom: 15px;">
<label style="display:block; font-size: 14px; margin-bottom: 5px; color: #555;">代码块字体:</label>
<input id="cfg-codeFont" type="text" value="${cfg.codeFont.replace(/"/g, '"')}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;" />
</div>
<div style="margin-bottom: 15px; display: flex; align-items: center;">
<input id="cfg-border" type="checkbox" ${cfg.showCodeBlockBorder ? 'checked' : ''} style="margin-right: 8px; width: 16px; height: 16px;" />
<label for="cfg-border" style="font-size: 14px; color: #333; cursor: pointer;">为代码块添加边框</label>
</div>
<div style="margin-bottom: 25px; display: flex; align-items: center;">
<input id="cfg-linenum" type="checkbox" ${cfg.showLineNumbers ? 'checked' : ''} style="margin-right: 8px; width: 16px; height: 16px;" />
<label for="cfg-linenum" style="font-size: 14px; color: #333; cursor: pointer;">显示代码块行号</label>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button id="cfg-cancel" style="padding: 8px 16px; border: none; background: #e0e0e0; color: #333; border-radius: 4px; cursor: pointer;">取消</button>
<button id="cfg-print" style="padding: 8px 16px; border: none; background: #3498db; color: #fff; border-radius: 4px; cursor: pointer;">打印 PDF</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
document.getElementById('cfg-cancel').onclick = () => {
overlay.remove();
resolve(null); // 用户取消
};
document.getElementById('cfg-print').onclick = () => {
const newCfg = {
mainFont: document.getElementById('cfg-mainFont').value,
codeFont: document.getElementById('cfg-codeFont').value,
showCodeBlockBorder: document.getElementById('cfg-border').checked,
showLineNumbers: document.getElementById('cfg-linenum').checked
};
saveConfig(newCfg);
overlay.remove();
resolve(newCfg); // 用户确认打印
};
});
}
console.log('Luogu.me Article2PDF 已加载');
// 获取文章内容
function getArticleContent() {
return document.getElementById('render-content');
}
// 获取按钮容器 - 精确定位到包含操作按钮的那一行
function getButtonContainer() {
// 方法1: 直接查找包含特定按钮的容器
const possibleContainers = [
document.querySelector('a[href*="/article/"]')?.closest('.sixteen.wide.column'),
document.querySelector('a[onclick="copyMarkdown()"]')?.closest('.sixteen.wide.column'),
document.querySelector('#save-btn')?.closest('.sixteen.wide.column'),
document.querySelector('#request-deletion-btn')?.closest('.sixteen.wide.column')
];
// 返回第一个找到的容器
for (const container of possibleContainers) {
if (container) return container;
}
// 方法2: 如果上面的方法没找到,遍历所有 sixteen wide column
const columns = document.querySelectorAll('.sixteen.wide.column');
for (const column of columns) {
// 检查是否包含任何按钮
const buttons = column.querySelectorAll('.ui.button');
if (buttons.length >= 2) { // 至少有2个按钮才认为是操作栏
return column;
}
}
return null;
}
// 展开自定义折叠块
function expandCustomBlocks(content) {
const blockStates = [];
content.querySelectorAll('.md-block').forEach(block => {
const body = block.querySelector('.md-block-body');
const title = block.querySelector('.md-block-title');
const toggleBtn = title ? title.querySelector('.toggle-btn') : null;
if (body) {
blockStates.push({
block: block,
body: body,
originalDisplay: body.style.display,
toggleBtn: toggleBtn
});
body.style.display = 'block';
if (toggleBtn) {
toggleBtn.style.transform = 'rotate(90deg)';
}
}
});
return blockStates;
}
// 处理代码块
function processCodeBlocks(content, CONFIG) {
const preStates = [];
content.querySelectorAll('.code-container').forEach(container => {
const pre = container.querySelector('pre');
const code = pre ? pre.querySelector('code') : null;
if (!code) return;
// 保存原始状态
preStates.push({
container: container,
code: code,
originalHTML: code.innerHTML
});
// 处理行号 - 只有当用户选择不显示行号时,才移除行号
if (!CONFIG.showLineNumbers) {
const lines = code.querySelectorAll('span.line');
if (lines.length > 0) {
let codeText = '';
lines.forEach(line => {
codeText += line.innerHTML + '\n';
});
codeText = codeText.replace(/\n$/, '');
code.innerHTML = codeText;
}
}
// 如果显示行号,什么都不做,保留原有行号
});
return preStates;
}
// 执行打印
async function performPrint(content, CONFIG) {
const hiddenElements = [];
const blockStates = [];
const pageBreakStates = [];
const modifiedTables = [];
// 展开自定义折叠块
const states = expandCustomBlocks(content);
blockStates.push(...states);
// 处理代码块
const preStates = processCodeBlocks(content, CONFIG);
// 处理分页符
let firstPageBreak = null;
content.querySelectorAll('p').forEach(p => {
if (p.textContent.trim() === '===pagebreak===') {
if (!firstPageBreak) firstPageBreak = p;
pageBreakStates.push({ el: p, cssText: p.style.cssText });
p.style.pageBreakAfter = 'always';
p.style.breakAfter = 'always';
p.style.color = 'transparent';
p.style.height = '0';
p.style.margin = '0';
p.style.padding = '0';
p.style.overflow = 'hidden';
}
});
// 处理表格
content.querySelectorAll('table').forEach(table => {
let isBeforeFirstPageBreak = true;
if (firstPageBreak) {
const position = table.compareDocumentPosition(firstPageBreak);
isBeforeFirstPageBreak = !!(position & Node.DOCUMENT_POSITION_FOLLOWING);
}
if (isBeforeFirstPageBreak) {
modifiedTables.push(table);
table.style.width = '100%';
table.style.maxWidth = '100%';
}
});
// 隐藏页面其他元素,只保留内容
let curr = content;
while (curr && curr !== document.body) {
const siblings = curr.parentNode ? curr.parentNode.children : [];
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i];
if (sibling !== curr &&
sibling.tagName !== 'SCRIPT' &&
sibling.tagName !== 'STYLE' &&
sibling.id !== 'luogu-me-pdf-modal') {
hiddenElements.push({
el: sibling,
display: sibling.style.display,
visibility: sibling.style.visibility
});
sibling.style.display = 'none';
sibling.style.visibility = 'hidden';
}
}
curr = curr.parentNode;
}
// 添加打印样式
const printStyle = document.createElement('style');
printStyle.innerHTML = `
@media print {
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
@page {
margin: 1.5cm;
}
body {
background: white !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
/* 隐藏打印按钮本身 */
#luogu-me-pdf-print-btn {
display: none !important;
}
/* 移除最外层容器的限制 */
.card.shadow.outline.md-container {
border: none !important;
box-shadow: none !important;
outline: none !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
max-width: 100% !important;
}
/* 让内容容器占满 */
#render-content {
display: block !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
background: white !important;
}
/* 确保所有父容器都占满宽度 */
.ui.main.container,
.ui.grid,
.sixteen.wide.column,
.card.shadow.outline {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
pre, blockquote, tr, img {
page-break-inside: avoid;
}
/* 展开所有折叠内容 */
.md-block .md-block-body {
display: block !important;
}
/* 表格样式优化 */
table {
width: 100% !important;
max-width: 100% !important;
border-collapse: collapse !important;
}
/* 字体设置 */
${CONFIG.mainFont ? `#render-content { font-family: ${CONFIG.mainFont} !important; }` : ''}
${CONFIG.codeFont ? `.code-container, pre, code, .shiki { font-family: ${CONFIG.codeFont} !important; }` : ''}
/* 代码块边框 */
${CONFIG.showCodeBlockBorder ? `
.code-container {
border: 1px solid #d1d5db !important;
border-radius: 6px !important;
}
` : ''}
/* 代码块样式 */
.code-container {
background: #ffffff !important;
margin: 1em 0 !important;
}
pre {
margin: 0 !important;
padding: 0 !important;
background: #ffffff !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
}
/* 如果不需要行号 */
${!CONFIG.showLineNumbers ? `
span.line {
display: block;
}
` : ''}
/* 隐藏所有干扰元素 */
.ui.left.hover.vertical.sidebar,
.ui.vertical.footer.segment,
.ui.divider,
.meta:not(.user),
[href="/user/login"],
[href="/user/logout"] {
display: none !important;
}
}
`;
document.head.appendChild(printStyle);
// 执行打印
window.print();
// 恢复所有修改
hiddenElements.forEach(item => {
item.el.style.display = item.display;
item.el.style.visibility = item.visibility;
});
// 恢复折叠块
blockStates.forEach(item => {
if (item.body) {
item.body.style.display = item.originalDisplay;
}
if (item.toggleBtn) {
item.toggleBtn.style.transform = '';
}
});
pageBreakStates.forEach(item => {
item.el.style.cssText = item.cssText;
});
modifiedTables.forEach(table => {
table.style.width = '';
table.style.maxWidth = '';
});
// 恢复代码块
preStates.forEach(item => {
item.code.innerHTML = item.originalHTML;
});
document.head.removeChild(printStyle);
}
function injectPrintButton() {
// 检查是否已经添加过按钮
if (document.getElementById('luogu-me-pdf-print-btn')) return;
const content = getArticleContent();
const buttonContainer = getButtonContainer();
if (!content || !buttonContainer) {
console.log('未找到文章内容或按钮容器,稍后重试...', { content, buttonContainer });
return;
}
// 创建打印按钮(蓝色样式)
const printBtn = document.createElement('a');
printBtn.id = 'luogu-me-pdf-print-btn';
printBtn.href = 'javascript:void(0)';
printBtn.className = 'ui blue button'; // 使用 blue 类获取蓝色样式
printBtn.style.marginLeft = '5px';
printBtn.innerHTML = '<i class="ui icon print"></i> 打印为 PDF';
// 添加到按钮容器
buttonContainer.appendChild(printBtn);
// 点击事件:先弹出设置,再打印
printBtn.addEventListener('click', async () => {
const config = await showSettingsModal();
if (config) {
// 用户点击了打印,执行打印
await performPrint(content, config);
}
// 用户点击取消,什么都不做
});
}
// 初始化
setTimeout(injectPrintButton, 1000);
setTimeout(injectPrintButton, 3000);
setTimeout(injectPrintButton, 5000);
const observer = new MutationObserver(() => injectPrintButton());
observer.observe(document.body, { childList: true, subtree: true });
})();
:::
演示专栏生成的 PDF: https://docs.qq.com/pdf/DWXR3amx1RFBxemdE
(由于保存站不支持 cute-table,所以在演示 PDF 中有一部分 :: cute-table 没有渲染,不是插件本身的问题)