Luogu Article2PDF

将洛谷专栏快速打印为 PDF。

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Luogu Article2PDF
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  将洛谷专栏快速打印为 PDF。
// @author       Murasame & Gemini
// @match        *://www.luogu.com.cn/article/*
// @match        *://www.luogu.com/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('gemini_pdf_config');
            return saved ? { ...DEFAULT_CONFIG, ...JSON.parse(saved) } : DEFAULT_CONFIG;
        } catch (e) {
            return DEFAULT_CONFIG;
        }
    }

    function saveConfig(cfg) {
        localStorage.setItem('gemini_pdf_config', JSON.stringify(cfg));
    }

    function createSettingsModal() {
        if (document.getElementById('gemini-pdf-modal')) return;

        const overlay = document.createElement('div');
        overlay.id = 'gemini-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 = 'none';
        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';

        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" 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" 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" 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" 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-save" style="padding: 8px 16px; border: none; background: #3498db; color: #fff; border-radius: 4px; cursor: pointer;">保存</button>
            </div>
        `;

        overlay.appendChild(modal);
        document.body.appendChild(overlay);

        document.getElementById('cfg-cancel').onclick = () => overlay.style.display = 'none';
        document.getElementById('cfg-save').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.style.display = 'none';
            if (window.Swal) {
                Swal.fire({
                    icon: 'success',
                    title: '保存成功',
                    text: '设置已保存!下次点击“打印为 PDF”时生效。',
                    confirmButtonColor: '#3498db',
                    timer: 2000
                });
            }
        };
    }

    function openSettingsModal() {
        createSettingsModal();
        const cfg = getConfig();
        document.getElementById('cfg-mainFont').value = cfg.mainFont;
        document.getElementById('cfg-codeFont').value = cfg.codeFont;
        document.getElementById('cfg-border').checked = cfg.showCodeBlockBorder;
        document.getElementById('cfg-linenum').checked = cfg.showLineNumbers;
        document.getElementById('gemini-pdf-modal').style.display = 'flex';
    }

    function injectPrintButton() {
        if (document.getElementById('gemini-print-wrapper')) return;

        const metaDiv = document.querySelector('.metas');
        const content = document.querySelector('.lfe-marked');

        if (!metaDiv || !content) return;

        const printWrapper = document.createElement('div');
        printWrapper.id = 'gemini-print-wrapper';
        printWrapper.style.marginLeft = '1em';

        const labelDiv = document.createElement('div');
        labelDiv.className = 'label';
        labelDiv.innerText = '操作';
        labelDiv.setAttribute('data-v-71eca628', '');

        const actionDiv = document.createElement('div');
        actionDiv.style.display = 'flex';

        const printBtn = document.createElement('span');
        printBtn.innerText = '打印为 PDF';
        printBtn.style.cursor = 'pointer';
        printBtn.style.color = '#3498db';
        printBtn.style.transition = 'color 0.2s';
        printBtn.onmouseover = () => printBtn.style.color = '#2980b9';
        printBtn.onmouseout = () => printBtn.style.color = '#3498db';

        const settingsBtn = document.createElement('span');
        settingsBtn.innerText = '(设置)';
        settingsBtn.style.cursor = 'pointer';
        settingsBtn.style.color = '#7f8c8d';
        settingsBtn.style.transition = 'color 0.2s';
        settingsBtn.onmouseover = () => settingsBtn.style.color = '#34495e';
        settingsBtn.onmouseout = () => settingsBtn.style.color = '#7f8c8d';

        actionDiv.appendChild(printBtn);
        actionDiv.appendChild(settingsBtn);
        printWrapper.appendChild(labelDiv);
        printWrapper.appendChild(actionDiv);
        metaDiv.appendChild(printWrapper);

        settingsBtn.addEventListener('click', openSettingsModal);

        printBtn.addEventListener('click', () => {
            if (!content) {
                if (window.Swal) {
                    Swal.fire({
                        icon: 'error',
                        title: '未检测到内容',
                        text: '未找到 .lfe-marked 元素,请确保页面已完全加载!',
                        confirmButtonColor: '#3498db'
                    });
                }
                return;
            }

            const CONFIG = getConfig();
            const hiddenElements = [];
            const detailsStates = [];
            const pageBreakStates = [];
            const modifiedTables = [];
            const preStates = [];

            content.querySelectorAll('details').forEach(details => {
                detailsStates.push({ el: details, isOpen: details.hasAttribute('open') });
                details.setAttribute('open', '');
            });

            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.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.classList.add('gemini-print-table-full');
                    if (table.parentElement && table.parentElement.tagName === 'DIV') {
                        table.parentElement.classList.add('gemini-print-wrapper-full');
                    }
                }
            });

            content.querySelectorAll('pre').forEach(pre => {
                const code = pre.querySelector('code');
                if (!code) return;

                preStates.push({
                    el: pre,
                    cssText: pre.style.cssText,
                    originalHTML: code.innerHTML,
                    classList: Array.from(pre.classList)
                });

                if (CONFIG.showCodeBlockBorder) {
                    pre.classList.add('gemini-print-pre-border');
                }

                if (CONFIG.showLineNumbers) {
                    const codeStyle = window.getComputedStyle(code);
                    const fontFamily = CONFIG.codeFont || codeStyle.fontFamily;
                    let htmlContent = code.innerHTML.replace(/\n$/, '');
                    let lines = htmlContent.split('\n');
                    while (lines.length > 0) {
                        let lastLineText = lines[lines.length - 1].replace(/<[^>]*>/g, '').trim();
                        if (lastLineText === '') {
                            lines.pop();
                        } else {
                            break;
                        }
                    }

                    let newHTML = lines.map((line, index) => {
                        let num = index + 1;
                        let fontSizeStyle = codeStyle.fontSize;
                        return `<div class="gemini-code-line"><span class="gemini-line-num" style="font-size: ${fontSizeStyle}; font-family: ${fontFamily};">${num}</span><span class="gemini-line-content">${line}</span></div>`;
                    }).join('');

                    code.innerHTML = newHTML;
                }
            });

            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 !== 'gemini-pdf-modal') {
                        hiddenElements.push({ el: sibling, display: sibling.style.display });
                        sibling.style.display = 'none';
                    }
                }
                hiddenElements.push({
                    el: curr, margin: curr.style.margin, padding: curr.style.padding,
                    width: curr.style.width, maxWidth: curr.style.maxWidth, isAncestor: true
                });
                curr.style.margin = '0';
                curr.style.padding = '0';
                curr.style.width = '100%';
                curr.style.maxWidth = '100%';

                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; }
                    pre, blockquote, tr, img { page-break-inside: avoid; }
                    .lfe-marked { width: 100% !important; max-width: none !important; }
                    details[open] { display: block !important; }

                    .gemini-print-wrapper-full { overflow: visible !important; display: block !important; width: 99% !important; }
                    .gemini-print-table-full { width: 99% !important; max-width: 99% !important; display: table !important; table-layout: auto !important; }

                    ${CONFIG.mainFont ? `.lfe-marked { font-family: ${CONFIG.mainFont} !important; }` : ''}
                    ${CONFIG.codeFont ? `.lfe-marked pre, .lfe-marked code { font-family: ${CONFIG.codeFont} !important; }` : ''}

                    .gemini-print-pre-border {
                        border: 1px solid #d1d5db !important;
                        border-radius: 6px !important;
                        box-shadow: none !important;
                    }
                    .lfe-marked pre {
                        padding: 0 !important;
                        background: #ffffff !important;
                        -webkit-print-color-adjust: exact !important;
                        print-color-adjust: exact !important;
                    }
                    .gemini-line-num {
                        flex-shrink: 0 !important;
                        width: 3.5em !important;
                        text-align: right !important;
                        padding-right: 0.8em !important;
                        box-sizing: border-box !important;
                        color: #9ca3af !important;
                        user-select: none !important;
                        background-color: #f8f9fa !important;
                        border-right: 1px solid #d1d5db !important;
                    }
                    .gemini-code-line:first-child .gemini-line-num,
                    .gemini-code-line:first-child .gemini-line-content {
                        padding-top: 1em !important;
                    }
                    .gemini-code-line:last-child .gemini-line-num,
                    .gemini-code-line:last-child .gemini-line-content {
                        padding-bottom: 1em !important;
                    }
                    .gemini-code-line {
                        display: flex !important;
                        align-items: stretch !important;
                        width: 100% !important;
                        page-break-inside: avoid !important;
                    }
                    .gemini-line-content {
                        flex-grow: 1 !important;
                        padding-left: 0.8em !important;
                        white-space: pre-wrap !important;
                        word-wrap: break-word !important;
                        overflow-wrap: anywhere !important;
                        min-height: 1.5em;
                    }
                }
            `;
            document.head.appendChild(printStyle);

            setTimeout(() => {
                window.print();

                hiddenElements.forEach(item => {
                    if (item.isAncestor) {
                        item.el.style.margin = item.margin; item.el.style.padding = item.padding;
                        item.el.style.width = item.width; item.el.style.maxWidth = item.maxWidth;
                    } else {
                        item.el.style.display = item.display;
                    }
                });
                detailsStates.forEach(item => {
                    if (item.isOpen) item.el.setAttribute('open', '');
                    else item.el.removeAttribute('open');
                });
                pageBreakStates.forEach(item => {
                    item.el.style.cssText = item.cssText;
                });
                modifiedTables.forEach(table => {
                    table.classList.remove('gemini-print-table-full');
                    if (table.parentElement) {
                        table.parentElement.classList.remove('gemini-print-wrapper-full');
                    }
                });
                preStates.forEach(item => {
                    item.el.style.cssText = item.cssText;
                    item.el.className = item.classList.join(' ');
                    if (item.originalHTML !== undefined) {
                        const code = item.el.querySelector('code');
                        if (code) code.innerHTML = item.originalHTML;
                    }
                });

                document.head.removeChild(printStyle);
            }, 500);
        });
    }

    const observer = new MutationObserver(() => injectPrintButton());
    observer.observe(document.body, { childList: true, subtree: true });
    setTimeout(injectPrintButton, 1000);
})();