// ==UserScript== // @name 粉笔题库一键排版打印(可自选题目导出) // @namespace http://tampermonkey.net/ // @version 70.76 // @description 粉笔网试卷一键排版打印,支持标准封面、自定义页边距、字体与分页策略 // @author Gdz // @match *://spa.fenbi.com/* // @match *://www.fenbi.com/spa/* // @grant GM_xmlhttpRequest // @connect cloud1-8g137o5da8d5e269-1388442749.ap-shanghai.app.tcloudbase.com // ==/UserScript== /* jshint esversion: 8 */ (function() { 'use strict'; // 扩展被自动注入、工具栏点击补注入或油猴同时运行时,只保留一个控制面板。 const existingPanel = document.getElementById('tm-panel'); if (existingPanel) { existingPanel.style.display = 'block'; return; } // 插件版本号:优先从油猴(GM_info)自动读取,书签等环境回退到默认值 const PLUGIN_VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '70.75'; // ===== 付费墙总开关 ===== // false = 推广期:全功能免费开放,免卡密免验证(不调后端、不扣次数),全参数可调 // true = 恢复收费:需输入卡密验证;Pro 卡密解锁高级参数(改回 true 即恢复原付费逻辑) const PAY_WALL = false; function getCurrentPaperTitle() { let inputVal = document.getElementById('tm-coverTitle')?.value; if (inputVal && inputVal !== "正在自动读取当前试卷..." && !inputVal.includes("2026年天津市")) { return inputVal; } try { const titleEl = document.querySelector('.header-title'); if (titleEl) { let t = titleEl.getAttribute('title') || titleEl.innerText.trim(); if (t) return t; } const otherTitle = document.querySelector('.paper-name, .header-center .title, title'); if (otherTitle && otherTitle.innerText.trim()) { return otherTitle.innerText.trim(); } } catch (e) {} return "2026年公务员录用考试《行政职业能力测验》试卷"; } // 只依据粉笔页面自身的试卷标题判断地区;用户可编辑的封面名称不参与判定。 function isGuangdongSourcePaper() { const titleNodes = document.querySelectorAll('.header-title, .paper-name, .header-center .title'); const pageTitles = Array.from(titleNodes).map(node => (node.getAttribute('title') || '') + ' ' + (node.textContent || '')) .filter(value => value.trim()); return /广东/.test(pageTitles.length ? pageTitles.join(' ') : (document.title || '')); } // 打印驱动会把 document.title 当作默认文件名;清理 Windows 文件名禁用字符。 function getPrintFileName(title) { const cleaned = String(title || '') .replace(/[\u0000-\u001f<>:"/\\|?*]+/g, ' ') .replace(/\s+/g, ' ') .replace(/[.\s]+$/g, '') .trim(); return (cleaned || '粉笔试卷').slice(0, 120); } // 从练习页或查看试卷页 URL 中提取答题记录 ID,用于生成答案二维码。 function getPaperId() { const match = window.location.href.match(/\/(?:exercise|view\/paper)\/([^?&#\/]+)/); return match ? match[1] : null; } // 生成粉笔答案二维码图片URL(使用在线二维码API) function getAnswerQrCodeUrl() { const paperId = getPaperId(); if (!paperId) return null; const answerUrl = "https://www.fenbi.com/exercise/answercard/" + paperId; return "https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=0&data=" + encodeURIComponent(answerUrl); } const styleElem = document.createElement('style'); styleElem.innerHTML = ` #tm-loading-mask { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); z-index: 9999999; display: none; align-items: center; justify-content: center; font-family: "Microsoft YaHei", sans-serif; } #tm-loading-box { background: white; padding: 30px 40px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.3); text-align: center; max-width: 350px; } .tm-spinner { width: 45px; height: 45px; border: 4px solid #f3f3f3; border-top: 4px solid #1677ff; border-radius: 50%; animation: tm-spin 0.8s linear infinite; margin: 0 auto 15px auto; } @keyframes tm-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } #tm-loading-text { font-size: 15px; font-weight: bold; color: #333; margin-bottom: 5px; } #tm-loading-sub { font-size: 12px; color: #666; line-height: 1.5; } /* ===== 面板现代样式 ===== */ .tm-panel { position: fixed; top: 120px; left: max(10px, calc(100vw - 308px)); width: min(288px, calc(100vw - 10px)); background: #fff; border: 1px solid #eef0f4; border-radius: 16px; box-shadow: 0 16px 48px rgba(15,23,42,0.14); padding: 16px 16px 14px; z-index: 999999; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: #1f2937; box-sizing: border-box; max-height: calc(100vh - 140px); overflow-y: auto; scrollbar-width: thin; } .tm-panel::-webkit-scrollbar { width: 6px; } .tm-panel::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } .tm-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .tm-logo { display: flex; align-items: center; gap: 8px; } .tm-dot { width: 10px; height: 10px; border-radius: 3px; background: linear-gradient(135deg, #2563eb, #7c3aed); } .tm-title { font-size: 15px; font-weight: 700; color: #0f172a; letter-spacing: 0.2px; } .tm-actions { display: flex; align-items: center; gap: 2px; } .tm-link-btn { font-size: 12px; color: #2563eb; cursor: pointer; user-select: none; padding: 4px 8px; border-radius: 8px; transition: background 0.15s; white-space: nowrap; } .tm-link-btn:hover { background: #eff6ff; } .tm-x { font-size: 18px; color: #94a3b8; cursor: pointer; user-select: none; line-height: 1; padding: 2px 7px; border-radius: 8px; transition: color 0.15s, background 0.15s; } .tm-x:hover { color: #0f172a; background: #f1f5f9; } .tm-field { margin-bottom: 12px; } .tm-label { display: block; font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 6px; } .tm-input, .tm-select { width: 100%; padding: 9px 12px; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 10px; font-size: 13px; color: #0f172a; background: #fff; outline: none; transition: border-color 0.15s, box-shadow 0.15s; font-family: inherit; } .tm-input:focus, .tm-select:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.12); } .tm-count-line { font-size: 12px; color: #64748b; margin-bottom: 12px; } .tm-count { color: #ea580c; font-weight: 700; } .tm-pro-badge { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; vertical-align: 1px; margin-left: 4px; } .tm-pro-badge.pro { background: linear-gradient(135deg, #f59e0b, #f97316); color: #fff; } .tm-pro-badge.free { background: #e2e8f0; color: #64748b; } .tm-field.tm-locked { opacity: .45; pointer-events: none; } .tm-btn { width: 100%; padding: 11px; background: linear-gradient(135deg, #2563eb, #4f46e5); color: #fff; border: none; border-radius: 11px; cursor: pointer; font-size: 14px; font-weight: 700; letter-spacing: 0.5px; transition: transform 0.12s, filter 0.12s; font-family: inherit; box-shadow: 0 6px 16px rgba(37,99,235,0.25); } .tm-btn:hover { filter: brightness(1.05); transform: translateY(-1px); } .tm-btn:active { transform: translateY(0); } .tm-adv { display: none; margin-top: 14px; padding-top: 14px; border-top: 1px dashed #e2e8f0; } .tm-check { display: flex; align-items: center; font-size: 13px; color: #334155; cursor: pointer; user-select: none; gap: 8px; margin: 0; } .tm-check input { width: 16px; height: 16px; accent-color: #2563eb; margin: 0; } .tm-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.4; } .tm-vip-box { padding: 12px; background: #f8fafc; border: 1px solid #eef0f4; border-radius: 12px; } .tm-btn-green { width: 100%; padding: 9px; background: linear-gradient(135deg, #16a34a, #22c55e); color: #fff; border: none; border-radius: 10px; cursor: pointer; font-size: 12px; font-weight: 700; font-family: inherit; margin-top: 8px; transition: filter 0.15s; } .tm-btn-green:hover { filter: brightness(1.05); } .tm-footer { margin-top: 12px; font-size: 11px; color: #cbd5e1; text-align: center; } .tm-row { display: flex; gap: 8px; } .tm-row .tm-field { flex: 1; } /* ===== 自选题目导出(次级入口按钮) ===== */ .tm-btn2 { width: 100%; margin-top: 8px; padding: 10px; background: #fff; color: #2563eb; border: 1px solid #c7d2fe; border-radius: 11px; cursor: pointer; font-size: 13px; font-weight: 700; letter-spacing: 0.3px; font-family: inherit; transition: background 0.15s, border-color 0.15s; } .tm-btn2:hover { background: #eff6ff; border-color: #93c5fd; } /* ===== 自选题目导出:勾选界面(深色,沿用 tm-* 命名) ===== */ #tm-pickui, #tm-pickui * { box-sizing: border-box; } #tm-pickui button { font-family: inherit; margin: 0; letter-spacing: normal; text-transform: none; line-height: normal; min-width: 0; } #tm-pickui.tm-pickui { position: fixed; inset: 0; z-index: 2147483000; display: none; align-items: center; justify-content: center; background: rgba(8,10,14,0.66); font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; } #tm-pickui.tm-pickui.show { display: flex; } #tm-pickui .tm-pick-box { display: flex; flex-direction: column; width: min(900px, 94vw); max-height: 88vh; background: #1b1e25; border: 1px solid #2e333d; border-radius: 12px; overflow: hidden; box-shadow: 0 18px 50px rgba(0,0,0,0.55); } #tm-pickui .tm-pick-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #181b21; border-bottom: 1px solid #2e333d; flex: 0 0 auto; } #tm-pickui .tm-pick-brand { display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 700; color: #e6eaf0; } #tm-pickui .tm-pick-sub { font-size: 12px; font-weight: 400; color: #8b95a3; } #tm-pickui .tm-pick-x { font-size: 19px; color: #6b7583; cursor: pointer; padding: 2px 8px; border-radius: 6px; line-height: 1; } #tm-pickui .tm-pick-x:hover { color: #e6eaf0; background: #2b323d; } #tm-pickui .tm-pick-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 16px; border-bottom: 1px solid #262b34; flex: 0 0 auto; } #tm-pickui .tm-pick-batch { background: #252a33; color: #a8b2bf; border: 1px solid #3a4049; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; padding: 7px 12px; white-space: nowrap; transition: background 0.15s; } #tm-pickui .tm-pick-batch:hover { background: #2f353f; color: #d7dce3; } #tm-pickui .tm-pick-batch-orange { background: #f59e0b; color: #1a1d23; border: 1px solid #f59e0b; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 700; padding: 7px 12px; white-space: nowrap; transition: background 0.15s, border-color 0.15s; } #tm-pickui .tm-pick-batch-orange:hover { background: #ff8f1f; border-color: #ff8f1f; } #tm-pickui .tm-pick-batch-orange:active { background: #e08b06; border-color: #e08b06; } #tm-pickui .tm-pick-range { width: 230px; flex: 0 0 auto; padding: 7px 10px; border: 1px solid #3a4049; border-radius: 6px; font-size: 13px; color: #e6eaf0; background: #22262e; outline: none; font-family: inherit; } #tm-pickui .tm-pick-range::placeholder { color: #5c6674; } #tm-pickui .tm-pick-range:focus { border-color: #4dd0e1; box-shadow: 0 0 0 3px rgba(77,208,225,0.15); } #tm-pickui .tm-pick-cnt { margin-left: auto; font-size: 12px; color: #8b95a3; white-space: nowrap; } #tm-pickui .tm-pick-boards { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 9px 16px; border-bottom: 1px solid #262b34; flex: 0 0 auto; background: #1d2129; } #tm-pickui .tm-pick-boards-tt { font-size: 12px; font-weight: 700; color: #8b95a3; flex: 0 0 auto; } #tm-pickui .tm-pick-boards-hint { font-size: 12px; color: #6b7583; } #tm-pickui .tm-pick-board { display: flex; align-items: center; gap: 6px; background: #252a33; border: 1px solid #3a4049; border-radius: 6px; padding: 5px 10px; cursor: pointer; transition: background 0.15s, border-color 0.15s; margin: 0; } #tm-pickui .tm-pick-board:hover { background: #2f353f; border-color: #4a525f; } #tm-pickui .tm-pick-board input { width: 14px; height: 14px; accent-color: #648CFC; margin: 0; flex: 0 0 auto; } #tm-pickui .tm-pick-board-nm { font-size: 12px; font-weight: 600; color: #d7dce3; white-space: nowrap; } #tm-pickui .tm-pick-board-rg { font-size: 11px; color: #8aa6fd; white-space: nowrap; } #tm-pickui .tm-pick-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 8px; scrollbar-width: thin; } #tm-pickui .tm-pick-list::-webkit-scrollbar { width: 8px; } #tm-pickui .tm-pick-list::-webkit-scrollbar-thumb { background: #333a45; border-radius: 4px; } #tm-pickui .tm-pick-list::-webkit-scrollbar-thumb:hover { background: #414a58; } #tm-pickui .tm-pick-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 8px; border-radius: 7px; cursor: pointer; color: #d7dce3; margin: 0; } #tm-pickui .tm-pick-item:hover { background: #22262e; } #tm-pickui .tm-pick-item input { width: 15px; height: 15px; accent-color: #648CFC; margin: 3px 0 0; flex: 0 0 auto; } #tm-pickui .tm-pick-seq { flex: 0 0 auto; min-width: 30px; font-size: 12px; font-weight: 700; color: #8aa6fd; text-align: right; line-height: 1.7; } #tm-pickui .tm-pick-no { flex: 0 0 auto; font-size: 11px; color: #8b95a3; background: #22262e; border: 1px solid #323845; border-radius: 5px; padding: 1px 6px; white-space: nowrap; line-height: 1.5; } #tm-pickui .tm-pick-txt { flex: 1 1 auto; font-size: 13px; line-height: 1.55; color: #d7dce3; word-break: break-word; } #tm-pickui .tm-pick-empty { padding: 22px; text-align: center; font-size: 13px; color: #8b95a3; } #tm-pickui .tm-pick-foot { flex: 0 0 auto; border-top: 1px solid #2e333d; padding: 10px 16px 12px; background: #181b21; } #tm-pickui .tm-pick-opts { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; } #tm-pickui .tm-pick-opts label { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #d7dce3; cursor: pointer; user-select: none; margin: 0; } #tm-pickui .tm-pick-opts input { width: 15px; height: 15px; accent-color: #648CFC; margin: 0; } #tm-pickui .tm-pick-hint { font-size: 11px; color: #6b7583; line-height: 1.6; margin-bottom: 10px; } #tm-pickui .tm-pick-hint b { color: #a8b2bf; } #tm-pickui .tm-pick-btns { display: flex; align-items: center; gap: 10px; } #tm-pickui .tm-pick-ok { flex: 2; padding: 10px; background: #2F7FE0; color: #fff; border: 0; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 700; transition: background 0.15s; } #tm-pickui .tm-pick-ok:hover { background: #4f9af0; } #tm-pickui .tm-pick-cancel { flex: 1; padding: 10px; background: #252a33; color: #a8b2bf; border: 1px solid #3a4049; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600; transition: background 0.15s; } #tm-pickui .tm-pick-cancel:hover { background: #2f353f; color: #d7dce3; } `; document.head.appendChild(styleElem); const mask = document.createElement('div'); mask.id = 'tm-loading-mask'; mask.innerHTML = `
正在拼命排版并生成真卷...
大文件渲染需要几秒钟,请稍候,系统马上为您唤起打印窗口。
`; document.body.appendChild(mask); // 1. 创建悬浮控制面板 const panel = document.createElement('div'); panel.id = 'tm-panel'; panel.className = 'tm-panel'; panel.innerHTML = `
高级设置 ▾ ×
${PAY_WALL ? '剩余打印次数:-- ' : '全功能免费开放'}
${PAY_WALL ? `
` : ''}
1px=0.75pt(Word 字号单位),注意换算
用仿宋建议字号:18px
取消后模块顺序排列;若新模块的首题放不完整,仍会自动换页。
自动识别所有大图(题干/选项/资料分析图表)按此比例缩放;小图(如分数公式)不缩放
`; document.body.appendChild(panel); function syncGuangdongControl() { const row = document.getElementById('tm-guangdong-judgement-row'); const box = document.getElementById('tm-guangdong-hide-judgement'); if (!row || !box) return; const allowed = isGuangdongSourcePaper(); row.style.display = allowed ? '' : 'none'; if (!allowed) box.checked = false; } syncGuangdongControl(); // 试卷标题常在面板加载后才由粉笔填入,短时复查并在点击打印时再次严格校验。 const guangdongTitleTimer = setInterval(syncGuangdongControl, 1000); setTimeout(() => clearInterval(guangdongTitleTimer), 30000); // Edge 扩展图标可重新显示或收起面板;油猴环境没有 chrome.runtime,会自动跳过。 try { if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (!message || message.type !== 'fenbi-print-toggle-panel') return; const hidden = panel.style.display === 'none'; panel.style.display = hidden ? 'block' : 'none'; sendResponse({ visible: hidden }); }); } } catch (_) {} // ===== 面板拖动(标题栏拖动,位置记忆 + 缩放钳制不丢失) ===== (function panelDrag() { const st = { on: false, dx: 0, dy: 0 }; const head = panel.querySelector('.tm-head'); if (!head) return; head.style.cursor = 'move'; function clamp() { const w = panel.offsetWidth, h = panel.offsetHeight; let x = parseFloat(panel.style.left); let y = parseFloat(panel.style.top); if (isNaN(x)) x = Math.max(10, window.innerWidth - w - 20); if (isNaN(y)) y = 120; x = Math.max(0, Math.min(x, window.innerWidth - w)); y = Math.max(0, Math.min(y, window.innerHeight - 40)); panel.style.left = x + 'px'; panel.style.top = y + 'px'; } head.addEventListener('mousedown', (e) => { if (e.target && (e.target.id === 'tm-close' || e.target.id === 'tm-toggle')) return; const r = panel.getBoundingClientRect(); st.on = true; st.dx = e.clientX - r.left; st.dy = e.clientY - r.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!st.on) return; let x = e.clientX - st.dx, y = e.clientY - st.dy; const w = panel.offsetWidth, h = panel.offsetHeight; x = Math.max(0, Math.min(x, window.innerWidth - w)); y = Math.max(0, Math.min(y, window.innerHeight - 40)); panel.style.left = x + 'px'; panel.style.top = y + 'px'; }); document.addEventListener('mouseup', () => { if (!st.on) return; st.on = false; try { localStorage.setItem('fb_panel_pos', JSON.stringify({ left: panel.style.left, top: panel.style.top })); } catch (e) {} }); // 恢复位置 + 窗口缩放钳制 try { const s = JSON.parse(localStorage.getItem('fb_panel_pos') || '{}'); if (s.left && s.top) { panel.style.left = s.left; panel.style.top = s.top; } } catch (e) {} clamp(); window.addEventListener('resize', clamp); })(); // 侧边栏:折叠/展开高级设置 + 关闭(刷新后自动重新出现) const tmAdv = document.getElementById('tm-advanced'); const tmToggle = document.getElementById('tm-toggle'); if (tmToggle && tmAdv) { tmToggle.addEventListener('click', () => { syncGuangdongControl(); const hidden = tmAdv.style.display === 'none'; tmAdv.style.display = hidden ? 'block' : 'none'; tmToggle.innerText = hidden ? '收起设置 ▴' : '高级设置 ▾'; }); } document.getElementById('tm-close')?.addEventListener('click', () => { panel.style.display = 'none'; }); // ===== 设置持久化:保存到本地,下次自动恢复 ===== function collectSettings() { const cur = { hasCover: document.getElementById('tm-hasCover')?.checked ? 1 : 0, margin: document.getElementById('tm-margin')?.value || '', fontFamily: document.getElementById('tm-fontFamily')?.value || 'fangsong', fontSize: document.getElementById('tm-fontSize')?.value || '', spacing: document.getElementById('tm-spacing')?.value || '', lineHeight: document.getElementById('tm-lineHeight')?.value || '', pagination: document.getElementById('tm-pagination')?.value || '', moduleNewPage: document.getElementById('tm-module-new-page')?.checked ? 1 : 0, figScale: document.getElementById('tm-figScale')?.value || '', hang: document.getElementById('tm-indent')?.checked ? 1 : 0, }; if (!currentPro) { // 免费版:锁定字段不覆盖用户保存的 Pro 设置(避免切回 Pro 后丢失自定义值) try { const old = JSON.parse(localStorage.getItem('fb_settings') || '{}'); if (old.spacing) cur.spacing = old.spacing; if (old.pagination) cur.pagination = old.pagination; if (old.figScale) cur.figScale = old.figScale; if (old.hang !== undefined) cur.hang = old.hang; } catch (e) {} } return cur; } function saveSettings() { try { localStorage.setItem('fb_settings', JSON.stringify(collectSettings())); } catch (e) {} } function loadSettings() { try { const s = JSON.parse(localStorage.getItem('fb_settings') || '{}'); if (!s || !Object.keys(s).length) return; if (s.margin) document.getElementById('tm-margin').value = s.margin; if (s.fontSize) document.getElementById('tm-fontSize').value = s.fontSize; if (s.spacing) document.getElementById('tm-spacing').value = s.spacing; if (s.lineHeight) document.getElementById('tm-lineHeight').value = s.lineHeight; if (s.pagination) document.getElementById('tm-pagination').value = s.pagination; if (s.moduleNewPage !== undefined) document.getElementById('tm-module-new-page').checked = !!s.moduleNewPage; if (s.figScale) document.getElementById('tm-figScale').value = s.figScale; if (s.fontFamily) document.getElementById('tm-fontFamily').value = s.fontFamily; const hc = document.getElementById('tm-hasCover'); if (hc) hc.checked = s.hasCover ? true : false; const ind = document.getElementById('tm-indent'); if (ind && s.hang !== undefined) ind.checked = s.hang ? true : false; } catch (e) {} } ['tm-margin','tm-fontSize','tm-spacing','tm-lineHeight','tm-pagination','tm-module-new-page','tm-figScale','tm-indent','tm-fontFamily'].forEach(id => { const el = document.getElementById(id); if (el) { el.addEventListener('input', saveSettings); el.addEventListener('change', saveSettings); } }); const hcEl = document.getElementById('tm-hasCover'); if (hcEl) hcEl.addEventListener('change', saveSettings); loadSettings(); setTimeout(() => { let current = getCurrentPaperTitle(); if(current) document.getElementById('tm-coverTitle').value = current; // 初始化 if (!PAY_WALL) { // 推广期:全功能免费开放,免卡密,直接解锁 applyProState(true); } else { // 恢复收费后:读取保存的卡密 let savedKey = storeGet('fb_plugin_vip_key'); if (savedKey) { storeSet('fb_plugin_vip_key', savedKey); // 回写,跨子域同步 document.getElementById('tm-vip-key').value = savedKey; // 查询并显示剩余次数 queryLeftCount(savedKey); } else { // 没有保存的卡密 → 自动开通免费试用(每设备3次) tryFreeTrial(); } } }, 800); // 查询卡密剩余次数(不扣次数) let currentPro = false; // 当前卡密是否为 Pro // Pro 状态应用:徽标 + 锁定高级功能(免费卡固定参数:间距20px/非悬挂/智能分页/图片缩放80%) function applyProState(pro) { if (!PAY_WALL) pro = true; // 推广期:全功能解锁 currentPro = !!pro; const badge = document.getElementById('tm-pro-badge'); if (badge) { badge.style.display = 'inline-block'; badge.className = 'tm-pro-badge ' + (currentPro ? 'pro' : 'free'); badge.innerText = currentPro ? 'PRO' : '免费版'; } const lockedIds = ['tm-spacing','tm-pagination','tm-indent','tm-figScale']; // 免费版:固定参数直接写回面板显示(与打印实际参数一致,杜绝显示值不严谨) if (!currentPro) { const fp = document.getElementById('tm-figScale'); if (fp) fp.value = 70; const sp = document.getElementById('tm-spacing'); if (sp) sp.value = 15; const pg = document.getElementById('tm-pagination'); if (pg) pg.value = 'ultra'; const ind = document.getElementById('tm-indent'); if (ind) ind.checked = false; } else { // Pro:恢复用户自己保存的设置 try { const s = JSON.parse(localStorage.getItem('fb_settings') || '{}'); if (s.spacing) document.getElementById('tm-spacing').value = s.spacing; if (s.lineHeight) document.getElementById('tm-lineHeight').value = s.lineHeight; if (s.pagination) document.getElementById('tm-pagination').value = s.pagination; if (s.figScale) document.getElementById('tm-figScale').value = s.figScale; const ind = document.getElementById('tm-indent'); if (ind && s.hang !== undefined) ind.checked = s.hang ? true : false; } catch (e) {} } lockedIds.forEach(id => { const el = document.getElementById(id); if (!el) return; const field = el.closest('.tm-field'); if (!field) return; if (currentPro) { field.classList.remove('tm-locked'); } else { field.classList.add('tm-locked'); let tag = field.querySelector('.tm-lock-tag'); if (!tag) { tag = document.createElement('span'); tag.className = 'tm-lock-tag'; tag.style.cssText = 'font-size:10px;color:#e10602;margin-left:6px;font-weight:600;pointer-events:auto;'; tag.innerText = '🔒 Pro'; tag.title = '购买 Pro 卡密解锁(免费版固定:紧密排列/间距15px/非悬挂/图片70%)'; tag.addEventListener('click', () => openPayModal()); field.querySelector('.tm-label')?.appendChild(tag); } } }); } async function queryLeftCount(vipKey) { if (!PAY_WALL) return; // 推广期:无卡密,不查询 if (!vipKey) return; try { const resp = await fetch("https://cloud1-8g137o5da8d5e269-1388442749.ap-shanghai.app.tcloudbase.com/api/query-key", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: vipKey }) }); const res = await resp.json(); if (res.success) { document.getElementById('tm-left-count').innerText = res.leftCount + ' 次'; applyProState(res.pro); // 验证成功即保存卡密,避免手动输入新卡密后刷新又恢复成旧的 storeSet('fb_plugin_vip_key', vipKey); } else { document.getElementById('tm-left-count').innerText = '卡密无效'; } } catch (e) { document.getElementById('tm-left-count').innerText = ''; } } // ===== 设备指纹:canvas 渲染差异 + 硬件特征 → 32位哈希(清cookie/换IP 不变,换浏览器才变) ===== function getFingerprint() { try { var parts = []; var cv = document.createElement('canvas'); cv.width = 200; cv.height = 60; var ctx = cv.getContext('2d'); if (ctx) { ctx.textBaseline = 'top'; ctx.font = '14px Arial'; ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20); ctx.fillStyle = '#069'; ctx.fillText('粉笔打印助手 FENBI-PRINT 68', 2, 15); ctx.fillStyle = 'rgba(102,204,0,0.7)'; ctx.fillText('FENBI-PRINT 68', 4, 17); var dataUrl = cv.toDataURL('image/png'); parts.push('c:' + hashStr(dataUrl.slice(-1200))); } parts.push('hw:' + (navigator.hardwareConcurrency || '?')); parts.push('dm:' + (navigator.deviceMemory || '?')); parts.push('scr:' + (screen.width || 0) + 'x' + (screen.height || 0) + 'x' + (screen.colorDepth || 0)); parts.push('lang:' + (navigator.language || '')); parts.push('tz:' + (Intl && Intl.DateTimeFormat ? Intl.DateTimeFormat().resolvedOptions().timeZone : '')); parts.push('plat:' + (navigator.platform || '')); parts.push('ua:' + (navigator.userAgent || '').slice(0, 80)); return hashStr(parts.join('|')); } catch (e) { return ''; } } function hashStr(str) { var h1 = 0xdeadbeef, h2 = 0x41c6ce57, i; for (i = 0; i < str.length; i++) { var ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); var s = (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); return ('0000000' + s).slice(-8); } // ===== 本地存储:localStorage 优先,cookie 兜底(跨页面/隐私模式更稳,防重复领试用) ===== function storeGet(key) { // localStorage 优先:同一域内读写一定同步(cookie 写入可能被浏览器策略拦截, // 若 cookie 优先会一直读到旧值)。cookie 仅作跨子域兜底。 try { var v = localStorage.getItem(key); if (v) return v; } catch (e) {} try { var m = document.cookie.match(new RegExp('(?:^|; )' + key + '=([^;]*)')); if (m && m[1]) return decodeURIComponent(m[1]); } catch (e) {} return null; } function storeSet(key, val) { try { localStorage.setItem(key, val); } catch (e) {} try { document.cookie = key + '=' + encodeURIComponent(val) + '; max-age=31536000; path=/; domain=.fenbi.com'; } catch (e) {} } // 免费试用:首次使用自动发放3次打印额度 async function tryFreeTrial() { let deviceId = storeGet('fb_device_id'); if (!deviceId) { deviceId = 'dev_' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); storeSet('fb_device_id', deviceId); } try { const resp = await fetch("https://cloud1-8g137o5da8d5e269-1388442749.ap-shanghai.app.tcloudbase.com/api/free-trial", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceId, fp: getFingerprint() }) }); const res = await resp.json(); if (res.success) { // 试用成功,自动填入卡密(免费版) document.getElementById('tm-vip-key').value = res.key; storeSet('fb_plugin_vip_key', res.key); applyProState(res.pro === true); // 免费试用 → 免费版 document.getElementById('tm-left-count').innerText = (res.count || 3) + ' 次'; document.getElementById('tm-buy-btn').innerText = '试用已开通,用完再买 (0.99元/10次)'; } } catch (e) { /* 静默失败,用户可手动购买 */ } } if (PAY_WALL) { // 购买卡密按钮 document.getElementById('tm-buy-btn').addEventListener('click', () => { openPayModal(); }); // 卡密输入框变化时查询剩余次数(防抖 500ms) let keyInputTimer = null; document.getElementById('tm-vip-key').addEventListener('input', (e) => { const val = e.target.value.trim(); clearTimeout(keyInputTimer); if (val.length < 4) { document.getElementById('tm-left-count').innerText = ''; return; } // 输入即保存:刷新后立即恢复刚输入的卡密(不依赖验证完成) storeSet('fb_plugin_vip_key', val); keyInputTimer = setTimeout(() => queryLeftCount(val), 500); }); } document.getElementById('tm-btn').addEventListener('click', async () => { // 推广期免费:跳过卡密验证,直接排版打印 if (!PAY_WALL) { applyProState(true); startPrintFlow(); return; } const vipKey = document.getElementById('tm-vip-key').value.trim(); if (!vipKey) { if (confirm("您还没有卡密,是否立即购买?(0.99元/10次)")) { openPayModal(); } return; } // 保存卡密到本地,下次免输入 storeSet('fb_plugin_vip_key', vipKey); // 按钮变为 loading 状态 const btn = document.getElementById('tm-btn'); const originalText = btn.innerText; btn.innerText = "正在验证卡密..."; btn.style.background = "#ccc"; btn.disabled = true; // 【真实 API 请求】 const API_URL = "https://cloud1-8g137o5da8d5e269-1388442749.ap-shanghai.app.tcloudbase.com"; try { const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: vipKey }) }); const res = await response.json(); // 恢复按钮状态 btn.innerText = originalText; btn.style.background = "#1677ff"; btn.disabled = false; if (!res.success) { alert("验证失败:" + res.msg); return; } // 验证成功,显示剩余次数 document.getElementById('tm-left-count').innerText = `${res.leftCount} 次`; applyProState(res.pro === true); // 放行,开始原有的排版打印逻辑 startPrintFlow(); } catch(e) { btn.innerText = originalText; btn.style.background = "#1677ff"; btn.disabled = false; alert("请求发生异常"); } }); // 「自选题目导出」入口:走与主按钮完全相同的提取/排版链路,仅在拼装前插入一次勾选过滤。 document.getElementById('tm-pick-btn').addEventListener('click', () => { if (!PAY_WALL) { applyProState(true); startPrintFlow({ pick: true }); return; } const leftText = document.getElementById('tm-left-count')?.innerText || ''; if (!/\d+\s*次/.test(leftText)) { alert('请先点击「提取试卷并打印」完成卡密验证,再使用自选题目导出。'); return; } applyProState(true); startPrintFlow({ pick: true }); }); function updatePrintProgress(reservedWin, title, detail) { const loadingTitle = document.getElementById('tm-loading-text'); const loadingDetail = document.getElementById('tm-loading-sub'); if (loadingTitle) loadingTitle.textContent = title; if (loadingDetail) loadingDetail.textContent = detail; try { if (reservedWin && !reservedWin.closed) { const popupTitle = reservedWin.document.getElementById('tm-reserved-title'); const popupDetail = reservedWin.document.getElementById('tm-reserved-detail'); if (popupTitle) popupTitle.textContent = title; if (popupDetail) popupDetail.textContent = detail; } } catch (_) {} } function showReservedLoading(reservedWin) { if (!reservedWin || reservedWin.closed) return; const isShenlunPage = window.location.href.includes('shenlun'); let isZhyynlPage = false; try { isZhyynlPage = new URL(window.location.href).searchParams.get('routecs') === 'zhyynl'; } catch (_) {} const essayLoadingTitle = isZhyynlPage ? '正在读取综应材料' : '正在读取申论材料'; try { reservedWin.document.open(); reservedWin.document.write(`粉笔打印助手 · 正在排版

${isShenlunPage || isZhyynlPage ? essayLoadingTitle : '正在整理题目内容'}

请稍候,完成后将在此窗口显示 A4 分页预览。

`); reservedWin.document.close(); } catch (_) {} } // 开始排版打印流程(免费开放或验证通过后调用) // options.pick = true 时,提取完成后先弹出「自选题目」勾选界面,其余流程完全一致。 function startPrintFlow(options) { const pickMode = !!(options && options.pick); mask.style.display = 'flex'; if (pickMode) { const pickLoadingTitle = document.getElementById('tm-loading-text'); const pickLoadingSub = document.getElementById('tm-loading-sub'); if (pickLoadingTitle) pickLoadingTitle.textContent = '正在提取题目数据…'; if (pickLoadingSub) pickLoadingSub.textContent = '请稍候,马上弹出勾选界面'; } // 窗口打开时机: // · 普通导出:必须在用户点击的同步调用栈里预留窗口(Safari/部分 Mac 浏览器会拦截延后的 window.open)。 // · 自选题目导出:点击按钮时【不开任何窗口】,用户全程留在原页面勾选; // 直到点「确定,生成预览」时(依然是真实的用户点击手势),才同步打开预览标签页。 // Chrome 不允许脚本把已激活的新标签页踢回后台,所以「提前开窗再抢焦点」做不到不跳转, // 只能从根上不提前开窗。 let reservedWin = null; if (!pickMode) { try { reservedWin = window.open('', '_blank'); } catch (e) { console.warn('[粉笔打印] 预览弹窗被拦截,改用页内预览。'); } } const loadingGuard = setTimeout(() => { mask.style.display = 'none'; }, 12000); showReservedLoading(reservedWin); let initialEssayTitle = ''; try { const routecs = new URL(window.location.href).searchParams.get('routecs'); initialEssayTitle = routecs === 'zhyynl' ? '正在读取综应材料' : routecs === 'shenlun' || window.location.href.includes('shenlun') ? '正在读取申论材料' : ''; } catch (_) { initialEssayTitle = window.location.href.includes('shenlun') ? '正在读取申论材料' : ''; } updatePrintProgress(reservedWin, initialEssayTitle || '正在整理题目内容', '正在识别当前页面,请稍候…'); // 微任务立即进入提取;后续异步等待由前台预览窗口驱动,避免 Edge 后台计时器节流。 Promise.resolve().then(async () => { try { const hasCover = document.getElementById('tm-hasCover').checked; const coverTitle = getCurrentPaperTitle(); document.getElementById('tm-coverTitle').value = coverTitle; const pageMargin = document.getElementById('tm-margin').value; const fontSize = parseInt(document.getElementById('tm-fontSize').value, 10); // 题目间距:免费版面板已固定为15,Pro 用面板值(applyProState 已保证显示一致) const spacing = parseInt(document.getElementById('tm-spacing').value, 10) || 15; await extractAndBuild(hasCover, coverTitle, pageMargin, fontSize, spacing, reservedWin, pickMode); } catch (e) { console.error('[粉笔打印] 生成预览失败:', e); try { if (reservedWin && !reservedWin.closed) reservedWin.close(); } catch (_) {} alert(e && e.userMessage ? e.userMessage : '生成打印预览失败,请刷新页面后重试。'); } finally { clearTimeout(loadingGuard); mask.style.display = 'none'; const loadingTitle = document.getElementById('tm-loading-text'); const loadingDetail = document.getElementById('tm-loading-sub'); if (loadingTitle) loadingTitle.textContent = '正在拼命排版并生成真卷...'; if (loadingDetail) loadingDetail.textContent = '大文件渲染需要几秒钟,请稍候,系统马上为您唤起打印窗口。'; } }); } // ============ 购买卡密:弹窗支付 + 自动填卡密 ============ const PAY_API_URL = "https://cloud1-8g137o5da8d5e269-1388442749.ap-shanghai.app.tcloudbase.com"; let payPollTimer = null; async function openPayModal() { // 1. 创建订单 let orderData; try { const resp = await fetch(PAY_API_URL + "/api/create-order", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); orderData = await resp.json(); if (!orderData.success) { alert("创建订单失败:" + (orderData.msg || "未知错误")); return; } } catch (e) { alert("网络错误,请稍后重试"); return; } // 2. 创建支付弹窗 const modal = document.createElement("div"); modal.style.cssText = ` position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.6); z-index:99999999; display:flex; align-items:center; justify-content:center; font-family:"Microsoft YaHei",sans-serif; `; modal.innerHTML = `
购买打印卡密
0.99 元 / 10 次打印
${orderData.qrcode ? `支付二维码` : `
${orderData.simulate ? "演示模式" : "点击下方按钮
打开支付宝完成支付"}
` }
${orderData.simulate ? `` : (orderData.payUrl ? `打开支付页面` : "") }
`; document.body.appendChild(modal); // 关闭弹窗 const closeModal = () => { if (payPollTimer) { clearInterval(payPollTimer); payPollTimer = null; } modal.remove(); }; modal.querySelector("#tm-pay-close-btn").addEventListener("click", closeModal); modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); }); // 模拟支付按钮 const simBtn = modal.querySelector("#tm-sim-pay-btn"); if (simBtn) { simBtn.addEventListener("click", async () => { simBtn.disabled = true; simBtn.innerText = "处理中..."; // 模拟模式下订单已秒支付,直接查询即可拿到卡密 await pollOrder(orderData.orderId, modal, closeModal); }); } // 3. 轮询订单状态(真实模式自动轮询;模拟模式等用户点击后查一次) // 防刷:最多轮询 60 次(5 秒间隔 ≈ 5 分钟),超时自动停止;页面切后台时暂停轮询 if (!orderData.simulate) { let pollCount = 0; const MAX_POLLS = 60; payPollTimer = setInterval(() => { pollCount++; if (pollCount > MAX_POLLS) { if (payPollTimer) { clearInterval(payPollTimer); payPollTimer = null; } const tipEl = modal.querySelector('#tm-pay-tip'); if (tipEl) tipEl.innerText = '支付状态查询超时,请重新打开购买弹窗'; return; } if (document.hidden) return; pollOrder(orderData.orderId, modal, closeModal); }, 5000); } } // 轮询订单,支付成功则自动填卡密 async function pollOrder(orderId, modal, closeModal) { try { const resp = await fetch(PAY_API_URL + "/api/query-order?orderId=" + encodeURIComponent(orderId)); const data = await resp.json(); if (!data.success) return; if (data.status === "paid" && data.key) { if (payPollTimer) { clearInterval(payPollTimer); payPollTimer = null; } // 自动填入卡密并保存 document.getElementById("tm-vip-key").value = data.key; storeSet("fb_plugin_vip_key", data.key); // 查询剩余次数 let leftCount = 10; try { const qr = await fetch(PAY_API_URL + "/api/query-key", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: data.key }), }); const qd = await qr.json(); if (qd.success) leftCount = qd.leftCount; } catch (e) {} queryLeftCount(data.key); // 弹窗切换为支付成功视图,明确展示卡密 const box = modal.querySelector("div"); box.innerHTML = `
✓ 支付成功
卡密已自动填入输入框,可直接使用
您的卡密
${data.key}
剩余 ${leftCount} 次打印(每点一次「排版打印」扣 1 次)
`; // 复制卡密按钮 modal.querySelector("#tm-copy-key-btn").addEventListener("click", async () => { try { await navigator.clipboard.writeText(data.key); const btn = modal.querySelector("#tm-copy-key-btn"); btn.innerText = "已复制"; btn.style.background = "#52c41a"; } catch (e) { alert("卡密:" + data.key); } }); // 关闭按钮 modal.querySelector("#tm-pay-close-btn").addEventListener("click", closeModal); } } catch (e) { /* 轮询出错忽略,继续 */ } } // 此函数序列化到打印页:三种策略共用同一套 A4 预览,分页规则仍由各自 pag-* CSS 决定。 function setupPagedPreview(pageMargin, initialFontSize, initialLineHeight, paginationMode) { if (!document.body.classList.contains('pag-' + paginationMode)) return; document.body.classList.add('tm-paged-preview'); const ua = navigator.userAgent || ''; const isIOSWebKit = /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); if (isIOSWebKit) document.body.classList.add('tm-ios-print-fix'); const isSmart = paginationMode === 'smart'; const states = Array.from(document.querySelectorAll('.q-wrapper')).map(question => { const options = question.querySelector('.options-grid, .options-flex'); return { question, stem: question.querySelector('.q-stem'), options, items: options ? Array.from(options.children) : [], opening: null }; }); let pending = 0; let preparing = false; let imageAdjusting = false; let imageSliderAdjusting = false; let typographyAdjusting = false; let renderFrame = 0; let renderJob = 0; let renderEndTimer = 0; let rerenderRequested = false; let typographyPreviewFrame = 0; // 预览已经完成分页时,打印直接复用现有页面。只有排版参数或图片确实变化才重建。 let layoutDirty = true; let finalDividerFooterPage = -1; let finalDividerFooterText = ''; const closest = (node, selector) => { if (!node || node.nodeType !== 1) return null; if (node.closest) return node.closest(selector); while (node) { const matches = node.matches || node.msMatchesSelector || node.webkitMatchesSelector; if (matches && matches.call(node, selector)) return node; node = node.parentElement; } return null; }; const replaceChildren = (parent, children) => { while (parent.firstChild) parent.removeChild(parent.firstChild); children.forEach(child => parent.appendChild(child)); }; // 固定高度的 CSS 分栏负责真实断页;预览与打印都展示同一批列切片。 const flow = document.createElement('div'); flow.id = 'tm-smart-source'; flow.className = 'tm-smart-flow'; Array.from(document.body.children).forEach(node => { if (!['SCRIPT', 'STYLE'].includes(node.tagName) && !['tm-toolbar', 'save-complete-mask'].includes(node.id)) flow.appendChild(node); }); document.body.appendChild(flow); const pages = document.createElement('div'); pages.id = 'tm-smart-pages'; document.body.appendChild(pages); const renderMask = document.createElement('div'); renderMask.id = 'tm-render-mask'; renderMask.setAttribute('role', 'status'); renderMask.setAttribute('aria-live', 'polite'); renderMask.setAttribute('aria-hidden', 'true'); renderMask.innerHTML = '
正在重新分页完成后可继续调整
'; document.body.appendChild(renderMask); const previewStyle = document.createElement('style'); previewStyle.textContent = ` #tm-smart-source { position:absolute; left:-100000px; top:0; visibility:hidden; } .tm-smart-flow { column-fill:auto; column-gap:0; overflow:visible; } .tm-smart-flow img { max-height:var(--tm-page-height); object-fit:contain; } .tm-smart-flow .cover-page { page:auto; height:var(--tm-page-height); break-after:column; -webkit-column-break-after:always; } .tm-smart-flow .blank-page { page:auto; height:var(--tm-page-height); break-after:column; -webkit-column-break-after:always; } .tm-smart-flow [data-tm-break-before] { break-before:column !important; -webkit-column-break-before:always !important; } .tm-smart-flow [data-tm-break-after] { break-after:column !important; -webkit-column-break-after:always !important; } .tm-smart-sheet { position:relative; box-sizing:border-box; width:210mm; height:297mm; background:white; } .tm-smart-viewport { position:relative; overflow:hidden; width:var(--tm-page-width); height:var(--tm-page-height); } .tm-smart-slice { position:absolute; top:0; visibility:visible; } .tm-smart-slice, .tm-smart-slice * { color:#000 !important; -webkit-text-fill-color:#000 !important; } .tm-smart-footer { position:absolute; left:0; right:0; bottom:6mm; text-align:center; font:11px SimSun,serif; color:#000; } .tm-smart-final-footer { position:absolute; left:20mm; right:20mm; bottom:12mm; text-align:center; white-space:nowrap; font: normal 17px/1.15 SimHei,"Microsoft YaHei",sans-serif; color:#000; } body.tm-paged-preview { font-size:var(--tm-live-font-size); line-height:var(--tm-live-line-height); } body.tm-paged-preview .chapter-sub-desc { font-size:var(--tm-live-font-size); line-height:var(--tm-live-line-height); } body.tm-paged-preview .material-heading { font-size:calc(var(--tm-live-font-size) + 2px); } body.tm-paged-preview .material-box p { line-height:var(--tm-live-line-height); } body.tm-paged-preview .material-box th, body.tm-paged-preview .material-box td { font-size:calc(var(--tm-live-font-size) - 1px) !important; line-height:var(--tm-live-line-height) !important; } body.tm-paged-preview .q-stem { margin-bottom:var(--tm-live-stem-gap); } body.tm-paged-preview .q-stem p, body.tm-paged-preview .q-stem div { margin-bottom:var(--tm-live-paragraph-gap) !important; line-height:var(--tm-live-line-height); } body.tm-paged-preview .opt-item { line-height:var(--tm-live-line-height); } body.tm-paged-preview .options-grid { row-gap:var(--tm-live-option-gap); } #tm-render-mask { position:fixed; inset:0; z-index:1000002; display:none; align-items:center; justify-content:center; background:rgba(232,237,245,.56); font-family:-apple-system,"Segoe UI","PingFang SC",sans-serif; } body.tm-preview-rendering #tm-render-mask { display:flex; } .tm-render-card { display:flex; align-items:center; gap:13px; padding:15px 20px; border:1px solid rgba(148,163,184,.35); border-radius:15px; color:#0f172a; background:rgba(255,255,255,.96); box-shadow:0 18px 50px rgba(15,23,42,.18); } .tm-render-card > span:last-child { display:flex; flex-direction:column; gap:2px; } .tm-render-card b { font-size:14px; } .tm-render-card small { color:#64748b; font-size:11px; } .tm-render-spinner { width:22px; height:22px; box-sizing:border-box; border:3px solid #dbeafe; border-top-color:#2563eb; border-radius:50%; animation:tm-render-spin .65s linear infinite; } @keyframes tm-render-spin { to { transform:rotate(360deg); } } @media screen { body.tm-paged-preview { width:100% !important; min-width:210mm; margin:0 !important; background:#e8edf5; } #tm-smart-pages { display:flex; flex-direction:column; align-items:center; width:100%; min-width:210mm; } .tm-smart-sheet { flex:0 0 auto; margin:20px 0 38px; box-shadow:0 12px 35px rgba(15,23,42,.13); outline:1px solid rgba(148,163,184,.38); } .tm-smart-sheet::after { content:'第 ' attr(data-page) ' 页分界'; position:absolute; top:100%; left:0; right:0; height:30px; border-top:2px dashed #3b82f6; text-align:center; color:#2563eb; font:600 12px/28px -apple-system,"Segoe UI","PingFang SC",sans-serif; } } @media print { @page { size:A4; margin:0; } body.tm-paged-preview { width:210mm !important; min-width:0 !important; margin:0 !important; padding:0 !important; background:white !important; } #tm-smart-source { display:none !important; } #tm-render-mask { display:none !important; } .tm-smart-sheet { height:296.99mm; margin:0; break-inside:avoid; page-break-inside:avoid; break-after:page; page-break-after:always; } .tm-smart-sheet:last-child { break-after:auto; page-break-after:auto; } /* iOS Safari 会为系统网址/日期预留打印区;完整 297mm 页面会溢出并追加空白页。 仅在 iPhone/iPad 打印时等比收进可打印区,并只保留旧式单一换页指令。 */ body.tm-ios-print-fix .tm-smart-sheet { height:276mm; margin:0 auto; overflow:hidden; break-after:page !important; } body.tm-ios-print-fix .tm-smart-sheet:last-child { break-after:auto !important; } body.tm-ios-print-fix .tm-smart-viewport { transform:scale(.93); transform-origin:top left; } } `; document.head.appendChild(previewStyle); flow.querySelectorAll('*').forEach(node => { const cs = getComputedStyle(node); if (['page', 'always'].includes(cs.breakBefore)) node.dataset.tmBreakBefore = '1'; if (['page', 'always'].includes(cs.breakAfter)) node.dataset.tmBreakAfter = '1'; }); const originals = new Map(); const visibleImageClones = new Map(); flow.querySelectorAll('img, .tm-img-handle').forEach((node, index) => { node.dataset.tmImageRef = String(index); originals.set(String(index), node); if (node.tagName === 'IMG') node.scrollIntoView = options => { const width = parseFloat(flow.style.width); const column = Math.max(0, Math.floor((node.getBoundingClientRect().left - flow.getBoundingClientRect().left) / width)); if (pages.children[column]) pages.children[column].scrollIntoView(options); }; }); function syncPreviewImages(onlyRef) { originals.forEach((original, ref) => { if (original.tagName !== 'IMG') return; if (onlyRef !== undefined && ref !== onlyRef) return; const clone = visibleImageClones.get(ref); if (!clone) return; const style = original.getAttribute('style'); if (style) clone.setAttribute('style', style); else clone.removeAttribute('style'); }); } // 拖动期间只同步图片尺寸,不重建全部页面;松手后再重新分页。 function beginPreviewImageResize(event, clientX) { const target = closest(event.target, '[data-tm-image-ref]'); const original = target && originals.get(target.dataset.tmImageRef); if (!original || !target.classList.contains('tm-img-handle')) return; event.preventDefault(); clearTimeout(pending); pending = 0; imageAdjusting = true; const previewImg = target.parentElement && target.parentElement.querySelector('img[data-tm-image-ref]'); const imageRef = previewImg && previewImg.dataset.tmImageRef; const onResize = () => syncPreviewImages(imageRef); const onEnd = () => { imageAdjusting = false; syncPreviewImages(imageRef); schedule(0); }; if (typeof original._tmStartResize === 'function') original._tmStartResize(clientX, onResize, onEnd); else { original.dispatchEvent(new MouseEvent('mousedown', { bubbles:true, cancelable:true, clientX, clientY:event.clientY || 0, button:0 })); const onMove = () => onResize(); const onUp = () => { document.removeEventListener('mousemove', onMove); onEnd(); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp, { once:true }); } } pages.addEventListener('mousedown', event => beginPreviewImageResize(event, event.clientX)); pages.addEventListener('touchstart', event => { const touch = event.touches && event.touches[0]; if (touch) beginPreviewImageResize(event, touch.clientX); }, { passive:false }); pages.addEventListener('dblclick', event => { const target = closest(event.target, '[data-tm-image-ref]'); const original = target && originals.get(target.dataset.tmImageRef); if (!original) return; event.preventDefault(); original.dispatchEvent(new MouseEvent('dblclick', { bubbles:true, cancelable:true })); syncPreviewImages(target.dataset.tmImageRef); schedule(); }); function readTypography() { const fontControl = document.getElementById('tm-font-slider'); const lineControl = document.getElementById('tm-line-slider'); const font = Math.min(32, Math.max(10, parseFloat(fontControl && fontControl.value) || initialFontSize || 16)); const line = Math.min(2.2, Math.max(1.1, parseFloat(lineControl && lineControl.value) || initialLineHeight || 1.5)); return { font, line }; } function setTypographyVars(target, values) { target.style.setProperty('--tm-live-font-size', values.font + 'px'); target.style.setProperty('--tm-live-line-height', String(values.line)); target.style.setProperty('--tm-live-stem-gap', values.line + 'px'); target.style.setProperty('--tm-live-paragraph-gap', (values.line * 3) + 'px'); target.style.setProperty('--tm-live-option-gap', (values.line * 6) + 'px'); } // 字符数只能给出初始列数;最终要用当前字号、字体和 A4 正文宽度实际测量。 // 四列放不下一整行时降为两列,两列仍放不下时降为一列,避免字号增大后列数反而增加。 function adaptTextOptionColumns(root) { if (!root || !root.querySelectorAll) return; root.querySelectorAll('.options-grid.col-2:not(.img-opt), .options-grid.col-4:not(.img-opt)').forEach(grid => { grid.classList.remove('tm-auto-pair', 'tm-auto-single'); const hasWrappedOption = () => Array.from(grid.children) .filter(item => item.classList && item.classList.contains('opt-item')) .some(item => { const text = item.querySelector('.opt-text'); if (!text || !(text.textContent || '').trim()) return false; const style = getComputedStyle(text); const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5; const range = document.createRange(); range.selectNodeContents(text); const lines = []; Array.from(range.getClientRects()).forEach(rect => { // 块级 p 自身也会返回一个整块矩形,只保留真实的行内文字盒。 if (rect.width < 0.5 || rect.height < 0.5 || rect.height >= lineHeight * 0.98) return; let line = lines.find(entry => Math.abs(entry.top - rect.top) <= 1); if (!line) { line = { top:rect.top, left:rect.left, right:rect.right }; lines.push(line); } else { line.left = Math.min(line.left, rect.left); line.right = Math.max(line.right, rect.right); } }); if (lines.length) return lines.length > 1; // 少数浏览器不会为 Range 返回逐行矩形,用高度作为保底判断。 return text.getBoundingClientRect().height > lineHeight * 1.35; }); if (!hasWrappedOption()) return; if (grid.classList.contains('col-4')) { // 四列先降为两列;两列能完整容纳时就保持两列。 grid.classList.add('tm-auto-pair'); void grid.offsetWidth; if (!hasWrappedOption()) return; grid.classList.remove('tm-auto-pair'); } grid.classList.add('tm-auto-single'); }); } function updateTypographyValues(values) { const fontValue = document.getElementById('tm-font-val'); const lineValue = document.getElementById('tm-line-val'); if (fontValue) fontValue.textContent = values.font.toFixed(1).replace(/\.0$/, '') + 'px'; if (lineValue) lineValue.textContent = values.line.toFixed(2).replace(/0$/, ''); } function applyTypography(previewOnly) { const values = readTypography(); updateTypographyValues(values); // 拖动时只更新屏幕内的页片,避免隐藏测量稿和所有 A4 副本一起重排。 if (previewOnly) { Array.from(pages.children).forEach(sheet => { const rect = sheet.getBoundingClientRect(); if (rect.bottom >= -80 && rect.top <= window.innerHeight + 80) { const slice = sheet.querySelector('.tm-smart-slice'); if (slice) setTypographyVars(slice, values); } }); } else { setTypographyVars(flow, values); setTypographyVars(document.body, values); } return values; } function queueTypographyPreview() { updateTypographyValues(readTypography()); if (typographyPreviewFrame) return; const run = () => { typographyPreviewFrame = 0; applyTypography(true); }; if (window.requestAnimationFrame) typographyPreviewFrame = window.requestAnimationFrame(run); else typographyPreviewFrame = setTimeout(run, 16); } applyTypography(false); function setRenderBusy(busy) { const wasBusy = document.body.classList.contains('tm-preview-rendering'); if (busy === wasBusy) return; document.body.classList.toggle('tm-preview-rendering', busy); renderMask.setAttribute('aria-hidden', busy ? 'false' : 'true'); const toolbar = document.getElementById('tm-toolbar'); if (toolbar) toolbar.setAttribute('aria-busy', busy ? 'true' : 'false'); document.querySelectorAll('#tm-toolbar input, #tm-toolbar button').forEach(control => { if (busy) { control.dataset.tmWasDisabled = control.disabled ? '1' : '0'; control.disabled = true; } else { control.disabled = control.dataset.tmWasDisabled === '1'; delete control.dataset.tmWasDisabled; } }); } function queuePrepareWithFeedback() { if (!layoutDirty) return; if (preparing || renderFrame || renderJob) { rerenderRequested = true; return; } setRenderBusy(true); const startedAt = Date.now(); const execute = () => { renderFrame = 0; renderJob = setTimeout(() => { renderJob = 0; try { prepare(); } finally { const remaining = Math.max(0, 220 - (Date.now() - startedAt)); clearTimeout(renderEndTimer); renderEndTimer = setTimeout(() => { renderEndTimer = 0; const release = () => { setRenderBusy(false); if (rerenderRequested) { rerenderRequested = false; schedule(0); } }; if (window.requestAnimationFrame) window.requestAnimationFrame(release); else release(); }, remaining); } }, 0); }; if (window.requestAnimationFrame) renderFrame = window.requestAnimationFrame(execute); else execute(); } function renderPages(width, height, padding) { flow.style.height = height + 'px'; flow.style.columnWidth = width + 'px'; const count = Math.max(1, Math.ceil((flow.scrollWidth - 1) / width)); const fragment = document.createDocumentFragment(); const hasCover = !!flow.querySelector('.cover-page'); for (let i = 0; i < count; i++) { const sheet = document.createElement('div'); sheet.className = 'tm-smart-sheet'; sheet.dataset.page = String(i + 1); sheet.style.padding = padding; const viewport = document.createElement('div'); viewport.className = 'tm-smart-viewport'; const slice = flow.cloneNode(true); slice.removeAttribute('id'); slice.classList.add('tm-smart-slice'); slice.style.left = (-i * width) + 'px'; slice.querySelectorAll('[id]').forEach(node => node.removeAttribute('id')); // 工具栏只操作原图,避免每张切片重复执行缩放。 slice.querySelectorAll('[data-nat-w]').forEach(node => node.removeAttribute('data-nat-w')); viewport.appendChild(slice); sheet.appendChild(viewport); if (!hasCover || i >= 2) { const footer = document.createElement('div'); footer.className = 'tm-smart-footer'; footer.textContent = '第 ' + (hasCover ? i - 1 : i + 1) + ' 页 / 共 ' + (hasCover ? count - 2 : count) + ' 页'; sheet.appendChild(footer); } if (i === finalDividerFooterPage) { const finalLine = document.createElement('div'); finalLine.className = 'tm-smart-final-footer'; finalLine.textContent = finalDividerFooterText; sheet.appendChild(finalLine); } fragment.appendChild(sheet); } replaceChildren(pages, [fragment]); pages.dataset.pageCount = String(count); const pageCount = document.getElementById('tm-page-count'); if (pageCount) pageCount.textContent = count + ' 页'; visibleImageClones.clear(); pages.querySelectorAll('.tm-smart-sheet').forEach(sheet => { const viewport = sheet.querySelector('.tm-smart-viewport').getBoundingClientRect(); sheet.querySelectorAll('img[data-tm-image-ref]').forEach(img => { const rect = img.getBoundingClientRect(); if (rect.left >= viewport.left - 1 && rect.right <= viewport.right + 1 && rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1) { visibleImageClones.set(img.dataset.tmImageRef, img); } }); }); } function protectInlineChapterFirstQuestions(width, height) { const headings = Array.from(flow.querySelectorAll('.chapter-title-box.tm-inline-chapter')); if (!headings.length) return; const flowLeft = flow.getBoundingClientRect().left; const flowTop = flow.getBoundingClientRect().top; const colOf = rect => Math.max(0, Math.floor((rect.left - flowLeft + 1) / width)); const candidates = headings.map(heading => { delete heading.dataset.tmBreakBefore; const firstBlock = heading.nextElementSibling; const isQuestion = !!firstBlock?.classList.contains('q-wrapper'); const isMaterial = !!firstBlock?.classList.contains('material-box'); return { heading, firstBlock: isQuestion || isMaterial ? firstBlock : null, isQuestion, isMaterial, blockHeight: isQuestion || isMaterial ? firstBlock.getBoundingClientRect().height : 0 }; }); flow.style.height = height + 'px'; flow.style.columnWidth = width + 'px'; for (const { heading, firstBlock, isQuestion, isMaterial, blockHeight } of candidates) { if (!firstBlock) continue; const range = document.createRange(); range.selectNodeContents(firstBlock); const columns = new Set(Array.from(range.getClientRects()) .filter(rect => rect.width > 1 && rect.height > 1).map(colOf)); if (!columns.size) columns.add(colOf(firstBlock.getBoundingClientRect())); const headingColumn = colOf(heading.getBoundingClientRect()); // 能容纳在单页的首题不能只露出一部分;标题也不单独留在上一页。 const questionSplit = isQuestion && blockHeight < height - 2 && columns.size > 1; // 材料先行的模块按首份材料判断:能整块放入一页时不截断;超长材料至少留四行起始内容。 const remaining = height - (heading.getBoundingClientRect().bottom - flowTop); const line = parseFloat(getComputedStyle(heading).lineHeight) || parseFloat(getComputedStyle(heading).fontSize) * 1.5; const materialSplit = isMaterial && ((blockHeight < height - 2 && blockHeight + 8 > remaining) || (blockHeight >= height - 2 && remaining < line * 4)); if (questionSplit || materialSplit || !columns.has(headingColumn)) { heading.dataset.tmBreakBefore = '1'; } } } function avoidIsolatedFinalDivider(width) { const divider = flow.querySelector('.tm-module-divider-final'); finalDividerFooterPage = -1; finalDividerFooterText = ''; if (!divider || divider.parentElement !== flow) return; divider.style.removeProperty('display'); const previous = divider.previousElementSibling; // 结束语绝不能成为下一页第一行。若正文放不下,改放在最后一题所在页的页码上方。 if (!previous) return; const left = flow.getBoundingClientRect().left; const column = rect => Math.max(0, Math.floor((rect.left - left + 1) / width)); const range = document.createRange(); range.selectNodeContents(previous); const rects = Array.from(range.getClientRects()).filter(rect => rect.width > 1 && rect.height > 1); const previousColumn = rects.length ? Math.max(...rects.map(column)) : column(previous.getBoundingClientRect()); if (column(divider.getBoundingClientRect()) > previousColumn) { finalDividerFooterPage = previousColumn; finalDividerFooterText = divider.textContent.trim(); divider.style.display = 'none'; } } function prepare() { if (preparing) return; preparing = true; const body = document.body; const oldWidth = body.style.getPropertyValue('width'); const oldPriority = body.style.getPropertyPriority('width'); const probe = document.createElement('div'); try { applyTypography(false); flow.style.setProperty('display', 'block', 'important'); // CSS 自己解析 mm 和 1~4 值页边距,不按屏幕宽度或固定像素估算行数。 probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;box-sizing:border-box;width:210mm;height:297mm;'; probe.style.padding = pageMargin; body.appendChild(probe); const paper = probe.getBoundingClientRect(); const margins = getComputedStyle(probe); const width = paper.width - parseFloat(margins.paddingLeft) - parseFloat(margins.paddingRight); const height = paper.height - parseFloat(margins.paddingTop) - parseFloat(margins.paddingBottom); if (!(width > 0 && height > 0)) return; document.body.style.setProperty('--tm-page-width', width + 'px'); document.body.style.setProperty('--tm-page-height', height + 'px'); flow.style.width = width + 'px'; flow.style.height = 'auto'; flow.style.columnWidth = 'auto'; body.style.setProperty('--tm-smart-width', width + 'px'); body.style.setProperty('width', width + 'px', 'important'); // 每次从原节点重建,保留图片拖拽事件;反复打印不会嵌套包装或复制选项。 if (isSmart) states.forEach(({ question, stem, options, items, opening }) => { question.classList.remove('keep'); if (stem) { stem.classList.remove('tm-smart-link-next'); if (opening) question.insertBefore(stem, opening); } if (options) { options.classList.remove('tm-smart-options', 'tm-smart-continuation'); items.forEach(item => item.classList.remove('tm-smart-long-option')); replaceChildren(options, items); if (opening) question.insertBefore(options, opening); } if (opening) opening.remove(); }); adaptTextOptionColumns(flow); if (isSmart) states.forEach(state => { const { question, stem, options, items } = state; state.opening = null; if (!stem) return; const style = getComputedStyle(stem); const line = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5; const stemHeight = stem.getBoundingClientRect().height; const rows = []; if (options && items.length) { const optionStyle = getComputedStyle(options); const gap = optionStyle.rowGap === 'normal' ? '0px' : optionStyle.rowGap; const columnGap = optionStyle.columnGap === 'normal' ? '0px' : optionStyle.columnGap; // 按实际排版后的纵坐标分行,兼容一列、两列、四列及图片选项。 items.forEach(item => { const rect = item.getBoundingClientRect(); let row = rows[rows.length - 1]; if (!row || Math.abs(row.top - rect.top) > 1) { row = { top: rect.top, items: [], widths: [], height: 0 }; rows.push(row); } row.items.push(item); row.widths.push(rect.width + 'px'); row.height = Math.max(row.height, rect.height); }); options.classList.add('tm-smart-options'); options.style.setProperty('--tm-smart-row-gap', gap); rows.forEach(row => { const wrapper = document.createElement('div'); wrapper.className = 'tm-smart-row'; wrapper.style.setProperty('--tm-smart-columns', row.widths.join(' ')); wrapper.style.setProperty('--tm-smart-column-gap', columnGap); if (row.height > height - 1) { wrapper.classList.add('tm-smart-long-row'); row.items.forEach(item => { if (item.getBoundingClientRect().height > height - 1) item.classList.add('tm-smart-long-option'); }); } row.items.forEach(item => wrapper.appendChild(item)); options.appendChild(wrapper); row.node = wrapper; }); } // 只有整题确实很短才整题保留:最多六行高度,且不超过正文页的 18%。 if (question.getBoundingClientRect().height <= Math.min(line * 6, height * 0.18)) { question.classList.add('keep'); return; } // 长题干交给原生 orphans:4 / widows:2;不把整道长题锁在一起。 if (stemHeight >= line * 4 - 1 || !rows.length) return; // 短题干只绑定足够的前几行选项,余下选项独立流动。 // 不把题距/选项间距计入四行,避免两行文字加空隙被误认为足够。 let contentHeight = stemHeight; const headRows = []; for (const row of rows) { headRows.push(row); contentHeight += row.height; if (contentHeight >= line * 4 - 1) break; } const opening = document.createElement('div'); opening.className = 'tm-smart-opening'; question.insertBefore(opening, stem); opening.appendChild(stem); const headOptions = options.cloneNode(false); headOptions.removeAttribute('id'); headRows.forEach(row => headOptions.appendChild(row.node)); opening.appendChild(headOptions); state.opening = opening; if (options.children.length) options.classList.add('tm-smart-continuation'); // 极大字号/超长单选项:开头本身超页时释放约束,保证内容能继续分页。 if (opening.getBoundingClientRect().height > height - 1) { question.insertBefore(stem, opening); headRows.slice().reverse().forEach(row => options.insertBefore(row.node, options.firstChild)); options.classList.remove('tm-smart-continuation'); opening.remove(); state.opening = null; stem.classList.add('tm-smart-link-next'); } }); protectInlineChapterFirstQuestions(width, height); avoidIsolatedFinalDivider(width); renderPages(width, height, pageMargin); layoutDirty = false; } finally { flow.style.removeProperty('display'); probe.remove(); if (oldWidth) body.style.setProperty('width', oldWidth, oldPriority); else body.style.removeProperty('width'); preparing = false; } } function schedule(delay) { layoutDirty = true; if (imageAdjusting || typographyAdjusting) return; clearTimeout(pending); pending = setTimeout(() => { pending = 0; if (!imageAdjusting && !typographyAdjusting) queuePrepareWithFeedback(); }, typeof delay === 'number' ? delay : 120); } function flush() { clearTimeout(pending); pending = 0; if (renderFrame && window.cancelAnimationFrame) window.cancelAnimationFrame(renderFrame); clearTimeout(renderJob); clearTimeout(renderEndTimer); renderFrame = 0; renderJob = 0; renderEndTimer = 0; rerenderRequested = false; setRenderBusy(false); if (layoutDirty) prepare(); } prepare(); // setup 在 load 后执行;字体若已经就绪,不再无条件进行第二次整份分页。 if (document.fonts && document.fonts.ready && document.fonts.status !== 'loaded') document.fonts.ready.then(() => schedule(0)); // 覆盖保存 PDF、Ctrl+P、重复打印及调整图片后重新打印。 // 打印使用最后一次可见预览;有尚未完成的图片更新时同步刷新一次。 window.tmPreparePagedPreview = flush; window.addEventListener('beforeprint', flush); window.addEventListener('resize', () => schedule(180)); document.addEventListener('load', event => { if (event.target.tagName === 'IMG' && flow.contains(event.target)) schedule(80); }, true); const imageSlider = document.getElementById('tm-img-slider'); const fontSlider = document.getElementById('tm-font-slider'); const lineSlider = document.getElementById('tm-line-slider'); function startImageSlider() { clearTimeout(pending); pending = 0; imageSliderAdjusting = true; imageAdjusting = true; } function finishImageSlider() { if (!imageSliderAdjusting) return; imageSliderAdjusting = false; imageAdjusting = false; syncPreviewImages(); schedule(0); } function startTypography() { typographyAdjusting = true; clearTimeout(pending); pending = 0; } function finishTypography() { if (!typographyAdjusting) return; typographyAdjusting = false; if (typographyPreviewFrame) { if (window.cancelAnimationFrame) window.cancelAnimationFrame(typographyPreviewFrame); else clearTimeout(typographyPreviewFrame); typographyPreviewFrame = 0; applyTypography(true); } schedule(0); } const supportsPointer = 'PointerEvent' in window; if (imageSlider) { imageSlider.addEventListener(supportsPointer ? 'pointerdown' : 'mousedown', startImageSlider); if (!supportsPointer) imageSlider.addEventListener('touchstart', startImageSlider, { passive:true }); } [fontSlider, lineSlider].forEach(control => { if (!control) return; control.addEventListener(supportsPointer ? 'pointerdown' : 'mousedown', startTypography); if (!supportsPointer) control.addEventListener('touchstart', startTypography, { passive:true }); control.addEventListener('input', () => { layoutDirty = true; queueTypographyPreview(); if (!typographyAdjusting) schedule(90); }); }); if (supportsPointer) { document.addEventListener('pointerup', () => { finishImageSlider(); finishTypography(); }); document.addEventListener('pointercancel', () => { finishImageSlider(); finishTypography(); }); } else { document.addEventListener('mouseup', () => { finishImageSlider(); finishTypography(); }); document.addEventListener('touchend', () => { finishImageSlider(); finishTypography(); }); document.addEventListener('touchcancel', () => { finishImageSlider(); finishTypography(); }); } document.addEventListener('input', event => { if (event.target.id === 'tm-img-slider') { layoutDirty = true; syncPreviewImages(); if (!imageAdjusting) schedule(90); } }); document.addEventListener('click', event => { const stepButton = closest(event.target, '[data-tm-step]'); if (stepButton) { const control = document.getElementById(stepButton.getAttribute('data-tm-target')); if (control) { const direction = parseFloat(stepButton.getAttribute('data-tm-step')) || 0; const step = parseFloat(control.step) || 1; const min = parseFloat(control.min); const max = parseFloat(control.max); let value = (parseFloat(control.value) || 0) + direction * step; if (!Number.isNaN(min)) value = Math.max(min, value); if (!Number.isNaN(max)) value = Math.min(max, value); control.value = String(Math.round(value * 100) / 100); control.dispatchEvent(new Event('input', { bubbles:true })); schedule(40); } } if (event.target.id === 'tm-img-reset') schedule(40); if (event.target.id === 'tm-layout-reset') { if (fontSlider) fontSlider.value = String(initialFontSize || 16); if (lineSlider) lineSlider.value = String(initialLineHeight || 1.5); applyTypography(true); schedule(0); } }); if (window.MutationObserver) { const imageObserver = new MutationObserver(() => { if (!imageAdjusting) schedule(100); }); flow.querySelectorAll('img').forEach(img => imageObserver.observe(img, { attributes:true, attributeFilter:['style', 'src'] })); } const hint = document.querySelector('.tm-tb-hint'); if (hint) hint.textContent = '拖动时流畅预览,松手后锁定片刻并重新分页;打印请用 A4、100% 缩放、无边距并关闭页眉页脚。'; } // ============ 自选题目导出:勾选界面 + 板块识别 + 过滤计划 ============ // 与「提取试卷并打印」共用同一条提取/排版链路,只在拼装 newPaperHtml 前插入一次勾选过滤。 // 原则:最小侵入,不改写原有 items / buildHtml 架构,也不改动原有按钮与参数。 const TM_PICK_BOARDS = ['言语理解与表达', '判断推理', '资料分析']; // 「只导出三大块」按板块名过滤,禁止按题号硬编码 // 常见板块别名 → 标准名(顺序即优先级) const TM_BOARD_ALIASES = [ { name: '政治理论', keys: ['政治理论', '政治常识', '时政', '政治'] }, { name: '常识判断', keys: ['常识判断', '常识'] }, { name: '言语理解与表达', keys: ['言语理解与表达', '言语理解', '言语'] }, { name: '数量关系', keys: ['数量关系', '数量'] }, { name: '判断推理', keys: ['判断推理', '判断'] }, { name: '资料分析', keys: ['资料分析', '资料'] } ]; function tmNormalizeBoardName(raw) { const s = String(raw == null ? '' : raw).replace(/[\s\u3000]+/g, ''); if (!s) return ''; for (const item of TM_BOARD_ALIASES) { for (const key of item.keys) if (s.includes(key)) return item.name; } return ''; } function tmEsc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // 元素 → 题目纯文本摘要(图片占位[图]),只用于勾选列表展示 function tmStemSummary(container, max) { if (!container) return '(未取到题干)'; const holder = document.createElement('div'); holder.innerHTML = container.innerHTML || ''; holder.querySelectorAll('img').forEach((img) => { if (img.parentNode) img.parentNode.replaceChild(document.createTextNode('[图]'), img); }); let text = (holder.textContent || '').replace(/[\s\u00a0\u3000]+/g, ' ').trim(); if (!text) text = '[图]'; if (max > 0 && text.length > max) text = text.slice(0, max) + '…'; return text; } // 题号范围解析:支持「1-10,15,20-25」,分隔符与区间符宽容 function tmParseRangeSpec(str) { const out = new Set(); String(str == null ? '' : str).split(/[,,;;、\s]+/).forEach((seg) => { if (!seg) return; const m = seg.match(/^(\d+)\s*[-~—~至到]\s*(\d+)$/); if (m) { let a = parseInt(m[1], 10), b = parseInt(m[2], 10); if (a > b) { const t = a; a = b; b = t; } if (b - a > 500) b = a + 500; for (let i = a; i <= b; i++) out.add(i); return; } const n = parseInt(seg, 10); if (isFinite(n) && n > 0) out.add(n); }); return out; } // 扫描 elements,生成「题目清单 + 元素类型 + 板块」计划(供勾选界面与过滤共用) function tmBuildPickPlan(elements, isEssayLike, isLocalPractice, stemOf) { const nodes = Array.from(elements); const items = nodes.map(() => ({ kind: 'other', name: '' })); const questions = []; nodes.forEach((el, i) => { if (!el || !el.classList) return; const tag = (el.tagName || '').toLowerCase(); if (el.classList.contains('chapter-container')) { items[i].kind = 'chapter'; const nameNode = el.querySelector('.chapter-name'); items[i].name = nameNode ? (nameNode.innerText || '').trim() : ''; return; } if (tag === 'app-materials' || el.classList.contains('material') || el.classList.contains('material-content')) { // 与主流程保持一致:嵌套在 app-materials / .material 内部的子材料会被主流程直接跳过,这里同样不计入材料区间 const nested = (el.classList.contains('material-content') && (el.closest('app-materials') || el.closest('.material'))) || (el.classList.contains('material') && el.closest('app-materials')); items[i].kind = nested ? 'other' : 'material'; return; } if (tag === 'app-ti' || tag === 'fb-local-question') { items[i].kind = 'question'; const isLocal = tag === 'fb-local-question'; const numNode = isLocal ? el.querySelector('.options-title') : el.querySelector('.title-index'); const num = numNode ? (numNode.innerText || '').replace(/\s+/g, ' ').trim() : ''; const cat = el.getAttribute('data-category') || el.getAttribute('data-cat') || el.getAttribute('category') || ''; let stem = ''; try { stem = tmStemSummary(stemOf ? stemOf(el) : null, 110); } catch (_) { stem = '(未取到题干)'; } questions.push({ i, seq: 0, num, cat: String(cat || '').trim(), text: stem }); } }); questions.forEach((q, idx) => { q.seq = idx + 1; }); const plan = { items, questions, boards: [], isEssayLike: !!isEssayLike, isLocalPractice: !!isLocalPractice }; plan.boards = tmDetectBoards(plan); return plan; } // 板块识别:优先按章节名归一,无章节时按题目自带的 cat 归一分组;返回 [{name, idxs, lo, hi}] function tmDetectBoards(plan) { if (plan.isEssayLike) return []; const items = plan.items; const seqOf = new Map(); plan.questions.forEach(q => seqOf.set(q.i, q.seq)); const boards = []; const chapterIdx = []; items.forEach((it, i) => { if (it.kind === 'chapter') chapterIdx.push(i); }); if (chapterIdx.length) { chapterIdx.forEach((ci, k) => { const end = k + 1 < chapterIdx.length ? chapterIdx[k + 1] : items.length; const idxs = []; for (let j = ci + 1; j < end; j++) if (items[j].kind === 'question') idxs.push(j); if (!idxs.length) return; const raw = items[ci].name || ''; const name = tmNormalizeBoardName(raw) || raw || ('板块 ' + (boards.length + 1)); const seqs = idxs.map(i => seqOf.get(i)).filter(n => typeof n === 'number'); boards.push({ name, idxs, lo: seqs.length ? Math.min(...seqs) : 0, hi: seqs.length ? Math.max(...seqs) : 0 }); }); return boards; } // 无章节兜底:仅按题目自带的板块属性归并;识别不出名称时不造板块(不报错,交由范围框勾选) const buckets = new Map(); plan.questions.forEach((q) => { const name = tmNormalizeBoardName(q.cat); if (!name) return; if (!buckets.has(name)) buckets.set(name, []); buckets.get(name).push(q); }); buckets.forEach((list, name) => { const seqs = list.map(q => q.seq); boards.push({ name, idxs: list.map(q => q.i), lo: Math.min(...seqs), hi: Math.max(...seqs) }); }); return boards; } // 勾选界面:返回 { sel:Set<元素下标>, cover, material, count },取消返回 null function openTmPickUI(plan, defaults) { return new Promise((resolve) => { const total = plan.questions.length; const sel = new Set(plan.questions.map(q => q.i)); const old = document.getElementById('tm-pickui'); if (old) old.remove(); const overlay = document.createElement('div'); overlay.id = 'tm-pickui'; overlay.className = 'tm-pickui'; overlay.innerHTML = `
自选题目导出共扫描到 ${total} 题(默认全选)
×
排版功能由行测小助理提供
自选题目导出功能由粉笔脚本君提供
「只导出三大块」= 先全不选,再勾选 言语理解与表达 / 判断推理 / 资料分析(按板块名过滤,不依赖题号);未识别到板块时请用范围框勾选。范围框按列表顺序序号匹配。源脚本无独立「参考答案」开关,故此处不提供该项。
`; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('show')); const listEl = overlay.querySelector('#tm-pick-list'); const boardsEl = overlay.querySelector('#tm-pick-boards'); const cntEl = overlay.querySelector('#tm-pick-cnt'); const rangeEl = overlay.querySelector('#tm-pick-range'); const coverCb = overlay.querySelector('#tm-pick-cover'); const materialCb = overlay.querySelector('#tm-pick-material'); const okBtn = overlay.querySelector('#tm-pick-ok'); coverCb.checked = !!(defaults && defaults.cover); materialCb.checked = !(defaults && defaults.material === false); // 单题列表 const cbAt = new Map(); // 元素下标 -> 单题复选框 plan.questions.forEach((q) => { const row = document.createElement('label'); row.className = 'tm-pick-item'; row.innerHTML = '' + '' + q.seq + '.' + (q.num ? '原题号 ' + tmEsc(q.num) + '' : '') + '' + tmEsc(q.text) + ''; const cb = row.querySelector('input'); cb.addEventListener('change', syncAll); cbAt.set(q.i, cb); listEl.appendChild(row); }); if (!total) { const empty = document.createElement('div'); empty.className = 'tm-pick-empty'; empty.textContent = '未扫描到题目,请确认当前页面是否为试卷作答页。'; listEl.appendChild(empty); } // 板块区(批量按钮行下方,与单题勾选双向同步) const boardCbs = []; if (plan.boards.length) { boardsEl.innerHTML = '板块快捷勾选'; plan.boards.forEach((b) => { const wrap = document.createElement('label'); wrap.className = 'tm-pick-board'; const rangeTxt = (b.lo && b.hi) ? (b.lo === b.hi ? String(b.lo) : b.lo + '-' + b.hi) : ''; wrap.innerHTML = '' + tmEsc(b.name) + '' + (rangeTxt ? '' + rangeTxt + '' : ''); const cb = wrap.querySelector('input'); const bs = { board: b, cb, on: 0, total: b.idxs.length }; // 注意:不可依赖 cb.checked / cb.indeterminate 判断点击后意图—— // 浏览器对 checkbox 的预激活行为会先翻转 checked 并清空 indeterminate。 // 这里只依据 syncAll 维护的受选计数 bs.on 决定:整块已全选→取消,否则→全选。 cb.addEventListener('click', () => { const to = bs.on >= bs.total ? false : true; b.idxs.forEach((i) => { const qcb = cbAt.get(i); if (qcb) qcb.checked = to; }); syncAll(); }); boardCbs.push(bs); boardsEl.appendChild(wrap); }); } else { boardsEl.innerHTML = '未识别到板块,请用范围框勾选'; } function syncAll() { boardCbs.forEach((bs) => { const board = bs.board, cb = bs.cb; let on = 0; board.idxs.forEach((i) => { const qcb = cbAt.get(i); if (qcb && qcb.checked) on++; }); bs.on = on; cb.checked = on > 0 && on === board.idxs.length; cb.indeterminate = on > 0 && on < board.idxs.length; }); sel.clear(); cbAt.forEach((cb, i) => { if (cb.checked) sel.add(i); }); cntEl.textContent = '已选 ' + sel.size + ' / ' + total + ' 题'; const none = sel.size === 0; okBtn.disabled = none; okBtn.style.opacity = none ? '0.55' : '1'; okBtn.style.cursor = none ? 'not-allowed' : 'pointer'; } function setAll(val) { cbAt.forEach((cb) => { cb.checked = val; }); syncAll(); } overlay.querySelectorAll('[data-act]').forEach((btn) => { btn.addEventListener('click', () => { const act = btn.dataset.act; if (act === 'all') { setAll(true); return; } if (act === 'none') { setAll(false); return; } if (act === 'invert') { cbAt.forEach((cb) => { cb.checked = !cb.checked; }); syncAll(); return; } if (act === 'three') { cbAt.forEach((cb) => { cb.checked = false; }); let hit = 0; plan.boards.forEach((b) => { if (!TM_PICK_BOARDS.includes(b.name)) return; hit++; b.idxs.forEach((i) => { const qcb = cbAt.get(i); if (qcb) qcb.checked = true; }); }); syncAll(); if (!hit) alert('未识别到「言语理解与表达 / 判断推理 / 资料分析」板块,请改用范围框勾选。'); return; } if (act === 'range') { const spec = tmParseRangeSpec(rangeEl.value); if (!spec.size) { alert('请先填写题号范围,例如 1-10,15,20-25'); return; } let hit = 0; plan.questions.forEach((q) => { if (!spec.has(q.seq)) return; const qcb = cbAt.get(q.i); if (qcb) { qcb.checked = true; hit++; } }); syncAll(); if (!hit) alert('范围框内的题号没有匹配到题目(范围按列表顺序序号计算)。'); } }); }); let done = false; function close(result) { if (done) return; done = true; overlay.classList.remove('show'); setTimeout(() => { try { overlay.remove(); } catch (_) {} }, 150); document.removeEventListener('keydown', onKey, true); resolve(result); } function onKey(e) { if (e.key !== 'Escape') return; e.preventDefault(); e.stopPropagation(); close(null); } document.addEventListener('keydown', onKey, true); okBtn.addEventListener('click', () => { if (!sel.size) { alert('请至少勾选一道题目。'); return; } // 关键:在这一刻(真实的用户点击手势内)同步打开预览标签页。 // 勾选期间全程不预开窗口,所以用户不会被切走;此处开窗仍处于用户手势中,不会被弹窗拦截。 let previewWin = null; try { if (defaults && typeof defaults.openPreview === 'function') previewWin = defaults.openPreview(); } catch (_) { previewWin = null; } close({ sel: new Set(sel), count: sel.size, cover: coverCb.checked, material: materialCb.checked, win: previewWin }); }); overlay.querySelector('#tm-pick-cancel').addEventListener('click', () => close(null)); overlay.querySelector('#tm-pick-x').addEventListener('click', () => close(null)); syncAll(); }); } // 依据勾选结果计算「需要跳过」的 elements 下标集合 // · 题目:未勾选 → 跳过 // · 材料:行测按「该材料之后、下一份材料/章节之前」的区间归属,区间内有被选题才保留; // 申论/综应材料为整卷共用、无法归属到单题,有被选题时整包保留 // · 章节:其区间内已无任何保留的题目/材料时跳过 function tmComputePickSkip(plan, sel, includeMaterial) { const items = plan.items; const skip = new Set(); let picked = 0; items.forEach((it, i) => { if (it.kind !== 'question') return; if (sel.has(i)) picked++; else skip.add(i); }); items.forEach((it, i) => { if (it.kind !== 'material') return; if (!includeMaterial) { skip.add(i); return; } if (plan.isEssayLike) { if (!picked) skip.add(i); return; } let keep = false; for (let j = i + 1; j < items.length; j++) { const kind = items[j].kind; if (kind === 'material' || kind === 'chapter') break; if (kind === 'question' && sel.has(j)) { keep = true; break; } } if (!keep) skip.add(i); }); const chapterIdx = []; items.forEach((it, i) => { if (it.kind === 'chapter') chapterIdx.push(i); }); chapterIdx.forEach((ci, k) => { const end = k + 1 < chapterIdx.length ? chapterIdx[k + 1] : items.length; let keep = false; for (let j = ci + 1; j < end; j++) { const kind = items[j].kind; if ((kind === 'question' || kind === 'material') && !skip.has(j)) { keep = true; break; } } if (!keep) skip.add(ci); }); return skip; } async function extractAndBuild(hasCover, coverTitle, pageMargin, fontSize, spacing, reservedWin, pickMode) { // 计时器优先挂到「当前处于前台」的窗口:自选导出时原题页在前台、预览窗口在后台, // 用前台窗口的计时器可避开 Edge 对后台页面的定时器节流;选完题目后前台切到预览页,再改用预览页。 const sleep = (ms) => new Promise(resolve => { const sourceFocused = typeof document.hasFocus === 'function' && document.hasFocus(); let activeTimerHost; if (sourceFocused) activeTimerHost = window; else if (reservedWin && !reservedWin.closed && typeof reservedWin.setTimeout === 'function') activeTimerHost = reservedWin; else activeTimerHost = window; try { activeTimerHost.setTimeout(resolve, ms); } catch (_) { setTimeout(resolve, ms); } }); // 分页策略/图片缩放/悬挂缩进/题目间距:Pro 可调;免费版固定参数 let paginationMode = (document.getElementById('tm-pagination')?.value || 'smart'); const moduleNewPage = !!document.getElementById('tm-module-new-page')?.checked; const hideGuangdongJudgement = isGuangdongSourcePaper() && !!document.getElementById('tm-guangdong-hide-judgement')?.checked; let figScale = Math.min(100, Math.max(20, parseInt(document.getElementById('tm-figScale')?.value, 10) || 60)); let hangOn = !!(document.getElementById('tm-indent')?.checked); let lineHeight = Math.min(2.2, Math.max(1.1, parseFloat(document.getElementById('tm-lineHeight')?.value) || 1.5)); if (!currentPro) { paginationMode = 'ultra'; // 免费版固定:紧密排列 figScale = 70; // 免费版固定:图片缩放 70% hangOn = false; // 免费版固定:非悬挂缩进 } const pagClass = 'pag-' + paginationMode; // 正文字体:按面板选择映射完整回退链(仿宋/仿宋_GB2312 等,缺失自动回退宋体) const FONT_MAP = { simsun: '"SimSun","宋体","STSong","Songti SC","Noto Serif CJK SC",serif', fangsong: '"FangSong","仿宋","STFangsong","华文仿宋","FangSong_GB2312","仿宋_GB2312",serif', simhei: '"SimHei","黑体","Microsoft YaHei","微软雅黑","Noto Sans CJK SC",sans-serif', kaiti: '"KaiTi","楷体","STKaiti","Kaiti SC",serif', // 思源宋体:macOS 上 7 个字重各自是独立字体族,必须用对应族名才能体现字重差异,不能统一回退 'shserif-regular': '"Source Han Serif SC","思源宋体",serif', 'shserif-medium': '"Source Han Serif SC Medium","思源宋体 Medium",serif', }; // 思源宋体 Bold 不再出现在面板中,无需额外 font-weight 映射 const FONT_WEIGHT_MAP = {}; const selectedFont = document.getElementById('tm-fontFamily')?.value; const fontFamily = FONT_MAP[selectedFont === 'fangsong2' ? 'fangsong' : selectedFont] || FONT_MAP.simsun; const bodyFontWeight = FONT_WEIGHT_MAP[selectedFont] || 'normal'; const useFangsongStroke = selectedFont === 'fangsong2'; const printFileName = getPrintFileName(coverTitle); const printTitleHtml = printFileName.replace(/&/g, '&').replace(//g, '>'); // 主观题分流严格按 routecs 判断:综应独立兼容,现有申论与行测分支保持不变。 let routeCourse = ''; try { routeCourse = new URL(window.location.href).searchParams.get('routecs') || ''; } catch (_) {} const isShenlun = routeCourse === 'shenlun' || window.location.href.includes('shenlun'); const isZhyynl = routeCourse === 'zhyynl'; const isEssayLike = isShenlun || isZhyynl; const localPracticeRoot = document.querySelector('#app-local-practice') || document.querySelector('app-local-practice'); const isLocalPractice = !!localPracticeRoot; let localOrderedQuestionIds = []; const localSolutionById = new Map(); const localQuestionMetaById = new Map(); let localAnswerLoadError = ''; let elements; const fetchLocalPracticeJson = async (url) => { const controller = typeof AbortController === 'function' ? new AbortController() : null; const timer = controller ? setTimeout(() => controller.abort(), 10000) : 0; try { const response = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' }, signal: controller ? controller.signal : undefined }); if (!response.ok) throw new Error('HTTP ' + response.status); const contentType = response.headers.get('content-type') || ''; if (!/json/i.test(contentType)) throw new Error('答案接口返回了网页而不是数据'); return await response.json(); } finally { if (timer) clearTimeout(timer); } }; const findLocalCategoryQuestionIds = (nodes, categoryId) => { for (const node of Array.isArray(nodes) ? nodes : []) { if (String(node && node.id) === String(categoryId)) return Array.isArray(node.questionIds) ? node.questionIds : []; const nested = findLocalCategoryQuestionIds(node && node.children, categoryId); if (nested) return nested; } return null; }; const loadLocalPracticeSolutions = async () => { const route = window.location.pathname.match(/\/error\/practice\/([^/]+)\/([^/]+)\/([^/]+)\/([^/?#]+)/); if (!route) throw new Error('无法识别错题/收藏网址'); const prefix = route[2]; const categoryId = route[3]; const profileType = parseInt(route[4], 10); // 粉笔 ProfileTab:2=错题、3=笔记、4=收藏;对应 solutions type:1/3/2。 const source = profileType === 2 ? { path: 'errors', solutionType: 1 } : profileType === 3 ? { path: 'notes', solutionType: 3 } : profileType === 4 ? { path: 'collects', solutionType: 2 } : null; if (!source) throw new Error('暂不支持当前列表类型'); // Angular 会把 /tikuApi 改写到 tiku.fenbi.com/api;直接请求网页域名会返回 SPA 的 HTML。 const tikuApiBase = window.location.hostname.includes('fenbilantian.cn') ? 'https://tiku.fenbilantian.cn/api' : 'https://tiku.fenbi.com/api'; const buildTikuApiUrl = (path, params) => { const url = new URL(tikuApiBase + '/' + encodeURIComponent(prefix) + path); if (params) params.forEach((value, key) => url.searchParams.set(key, value)); url.searchParams.set('app', 'web'); url.searchParams.set('kav', '131'); url.searchParams.set('av', '134'); url.searchParams.set('hav', '128'); url.searchParams.set('version', '3.0.0.0'); url.searchParams.set('gav', '2'); url.searchParams.set('apcId', '0'); try { const deviceSid = localStorage.getItem('deviceSid'); if (deviceSid) url.searchParams.set('deviceId', deviceSid); } catch (_) {} return url.toString(); }; let questionIds = []; try { if (Array.isArray(history.state && history.state.questionIds)) questionIds = history.state.questionIds.slice(); } catch (_) {} if (!questionIds.length) { const params = new URLSearchParams(window.location.search); const timeRange = params.get('timeRange') || '0'; const order = params.get('order') || 'desc'; const treeParams = new URLSearchParams({ timeRange, order }); if (timeRange === '-1') { if (params.get('startDate')) treeParams.set('startDate', params.get('startDate')); if (params.get('endDate')) treeParams.set('endDate', params.get('endDate')); } const treePayload = await fetchLocalPracticeJson(buildTikuApiUrl('/' + source.path + '/keypoint-tree', treeParams)); const tree = Array.isArray(treePayload) ? treePayload : (treePayload && (treePayload.results || treePayload.data)) || []; questionIds = findLocalCategoryQuestionIds(tree, categoryId) || []; } const total = Math.max(0, parseInt(new URL(window.location.href).searchParams.get('total'), 10) || questionIds.length); if (total) questionIds = questionIds.slice(0, total); if (!questionIds.length) throw new Error('未取得题目ID'); localOrderedQuestionIds = questionIds.map(id => String(id)); const chunks = []; for (let i = 0; i < questionIds.length; i += 80) chunks.push(questionIds.slice(i, i + 80)); const payloads = await Promise.all(chunks.map(ids => { const solutionParams = new URLSearchParams({ type: String(source.solutionType), questionIds: ids.join(',') }); return fetchLocalPracticeJson(buildTikuApiUrl('/universal/auth/solutions', solutionParams)); })); payloads.forEach(payload => { const solutions = Array.isArray(payload) ? payload : (payload && (payload.solutions || payload.questions)) || []; solutions.forEach(solution => { if (solution && solution.id != null) localSolutionById.set(String(solution.id), solution); }); }); if (!localSolutionById.size) throw new Error('答案接口未返回题目'); // 粉笔把正确答案和全站正确率拆成两个接口:solutions 只给答案,question/meta 才给 correctRatio。 // 正确率属于附加信息,读取失败时不能影响已经取得的答案和题目排版。 const metaResults = await Promise.allSettled(chunks.map(ids => { const metaParams = new URLSearchParams({ ids: ids.join(',') }); return fetchLocalPracticeJson(buildTikuApiUrl('/question/meta', metaParams)); })); const recordLocalMeta = (id, meta) => { if (id == null || !meta || typeof meta !== 'object') return; localQuestionMetaById.set(String(id), meta); }; const consumeLocalMetaPayload = payload => { const root = payload && payload.data != null ? payload.data : payload; const list = Array.isArray(root) ? root : root && Array.isArray(root.metas) ? root.metas : root && Array.isArray(root.results) ? root.results : null; if (list) { list.forEach(meta => recordLocalMeta( meta && (meta.id != null ? meta.id : (meta.questionId != null ? meta.questionId : meta.globalId)), meta )); return; } if (!root || typeof root !== 'object') return; Object.keys(root).forEach(id => { const meta = root[id]; if (meta && typeof meta === 'object') recordLocalMeta(id, meta); }); }; metaResults.forEach(result => { if (result.status === 'fulfilled') consumeLocalMetaPayload(result.value); else console.warn('[粉笔打印] 正确率接口读取失败,答案仍可正常打印', result.reason); }); }; if (isZhyynl) { // ===== 综应独立提取 ===== // 综应可能把主观题拆在多个 .ti 题组中;逐组抓材料与题目,再统一排版。 // 不复用也不修改申论分支,避免影响已经稳定的申论与行测页面。 const log = (...args) => console.log('[粉笔打印][综应]', ...args); const materialMap = new Map(); const questionMap = new Map(); const click = (node) => { try { node.click(); return; } catch (_) {} try { node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); } catch (_) {} }; const nodeSignature = (node, limit) => { if (!node) return ''; const text = (node.innerText || node.textContent || '').replace(/\s+/g, '').slice(0, limit || 1200); const images = Array.from(node.querySelectorAll('img')).map(img => img.currentSrc || img.src || img.getAttribute('src') || '').join('|'); return text + '|' + images; }; const questionKey = (question) => { if (!question) return ''; const key = question.getAttribute('data-question-key'); const number = question.querySelector('.title-index')?.textContent.trim() || ''; return key ? 'k:' + key : 'q:' + number + '|' + nodeSignature(question, 500); }; const isVisibleQuestion = (question) => { if (!question || question.closest('[hidden]')) return false; const style = getComputedStyle(question); return style.display !== 'none' && style.visibility !== 'hidden'; }; const waitStableChange = async (getter, previous, requireChange) => { let last = '', stable = 0; for (let waitIndex = 0; waitIndex < 40; waitIndex++) { await sleep(50); const current = getter(); const signature = nodeSignature(current, 1200); if (!signature || (requireChange && signature === previous)) continue; if (signature === last) { stable++; if (stable >= 1) return current; } else { last = signature; stable = 0; } } return getter(); }; const getBlocks = () => { const blocks = Array.from(document.querySelectorAll('.tis-container .ti')); return blocks.length ? blocks : [document.querySelector('.tis-container') || document.body]; }; // Angular 可能先挂出第一组,随后才补齐其余题组/标签;等待结构安静后再锁定题组数量。 const waitZhyStructureStable = async () => { const started = Date.now(); let last = '', quietSince = Date.now(); for (let waitIndex = 0; waitIndex < 60; waitIndex++) { await sleep(50); const blocks = getBlocks(); const signature = blocks.map(block => { const questions = block.querySelectorAll('.questions-anchors .tabs-content .tab, .questions-anchors .tab').length; const materials = block.querySelectorAll('app-materials .tabs-content .tab, app-materials .label-tab .tab, app-materials [role="tab"]').length; return questions + ':' + materials; }).join('|'); if (signature !== last) { last = signature; quietSince = Date.now(); } const ready = document.querySelector('app-materials') && document.querySelector('app-ti'); if (ready && Date.now() - started >= 600 && Date.now() - quietSince >= 250) return; } }; await waitZhyStructureStable(); const initialBlocks = getBlocks(); for (let blockIndex = 0; blockIndex < initialBlocks.length; blockIndex++) { updatePrintProgress(reservedWin, '正在读取综应材料', '题组 ' + (blockIndex + 1) + ' / ' + initialBlocks.length); const getBlock = () => getBlocks()[blockIndex] || initialBlocks[blockIndex]; const getQuestionTabs = () => Array.from(getBlock().querySelectorAll('.questions-anchors .tabs-content .tab, .questions-anchors .tab')) .filter((tab, index, list) => list.indexOf(tab) === index); const getMaterialRoot = () => getBlock().querySelector('app-materials'); const getMaterialTabs = () => { const root = getMaterialRoot(); if (!root) return []; return Array.from(root.querySelectorAll('.tabs-content .tab, .label-tab .tab, .tab')) .filter((tab, index, list) => list.indexOf(tab) === index && /(?:材料|资料)\s*[一二三四五六七八九十百零〇\d]+/.test(tab.textContent || '')); }; const getMaterial = () => getMaterialRoot()?.querySelector('.material-body .material-content, .material-content') || null; const getQuestions = () => { const all = Array.from(getBlock().querySelectorAll('.questions-objective-container app-ti, .questions-container app-ti, app-ti')); const unique = all.filter((question, index, list) => list.indexOf(question) === index); const visible = unique.filter(isVisibleQuestion); return visible.length ? visible : unique; }; const getCurrentQuestion = () => getQuestions()[0] || null; const questionTabs = getQuestionTabs(); if (questionTabs.length > 1) { const lastTab = getQuestionTabs()[getQuestionTabs().length - 1]; if (lastTab && !lastTab.classList.contains('active')) { const oldSignature = nodeSignature(getCurrentQuestion(), 600); click(lastTab); await waitStableChange(getCurrentQuestion, oldSignature, true); } } let previousMaterialSignature = ''; const materialTabs = getMaterialTabs(); if (materialTabs.length) { for (let materialTabIndex = 0; materialTabIndex < materialTabs.length; materialTabIndex++) { const tab = getMaterialTabs()[materialTabIndex]; if (!tab) continue; click(tab); const material = await waitStableChange(getMaterial, previousMaterialSignature, materialTabIndex > 0); const signature = nodeSignature(material, 2000); if (material && signature) { previousMaterialSignature = signature; if (!materialMap.has(signature)) materialMap.set(signature, material.cloneNode(true)); } } } else { const material = getMaterial(); const signature = nodeSignature(material, 2000); if (material && signature && !materialMap.has(signature)) materialMap.set(signature, material.cloneNode(true)); } if (questionTabs.length) { for (let questionTabIndex = 0; questionTabIndex < getQuestionTabs().length; questionTabIndex++) { updatePrintProgress(reservedWin, '正在整理综应作答要求', '题目 ' + (questionMap.size + 1)); const tab = getQuestionTabs()[questionTabIndex]; if (!tab) continue; const oldSignature = nodeSignature(getCurrentQuestion(), 600); const wasActive = tab.classList.contains('active'); click(tab); if (!wasActive) await waitStableChange(getCurrentQuestion, oldSignature, true); else await waitStableChange(getCurrentQuestion, '', false); getQuestions().forEach(question => { const key = questionKey(question); if (key && !questionMap.has(key)) questionMap.set(key, question.cloneNode(true)); }); } const firstTab = getQuestionTabs()[0]; if (firstTab && !firstTab.classList.contains('active')) click(firstTab); } else { getQuestions().forEach(question => { const key = questionKey(question); if (key && !questionMap.has(key)) questionMap.set(key, question.cloneNode(true)); }); } } elements = [...materialMap.values(), ...questionMap.values()]; log('最终材料数:', materialMap.size, '最终题目数:', questionMap.size); } else if (isShenlun) { // ===== 申论两阶段提取 ===== // 阶段1:点最后一个题目tab(左边会显示全部材料)→ 逐个点材料tab抓取,按tab编号去重 // 阶段2:逐个点题目tab → 抓题干,按 data-question-key 去重 // 输出用数组直接装,绝不用 querySelectorAll(内外层同名 class 会重复匹配) const log = (...a) => console.log('[粉笔打印]', ...a); const materialMap = new Map(); // 材料编号N -> 材料clone const questions = []; // 按题号顺序的题目clone const seenQKeys = new Set(); // 题目去重(data-question-key) updatePrintProgress(reservedWin, '正在读取申论材料', '正在识别材料列表与作答要求…'); // Angular 兼容点击:原生 click 成功后不再额外 dispatch,避免同一标签被处理两次。 const click = (el) => { try { el.click(); return; } catch (e) {} try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); } catch (e) {} }; // 每次都重新查询(Angular 会销毁重建节点,不能缓存引用) const getQTabs = () => { let t = document.querySelectorAll('.questions-anchors .tabs-content .tab'); if (t.length === 0) t = document.querySelectorAll('.questions-anchors .tab'); return Array.from(t); }; const materialLabelPattern = /材料\s*[一二三四五六七八九十百零〇\d]+/; const getMatTabs = () => Array.from(document.querySelectorAll('app-materials .tabs-content .tab, app-materials [role="tab"]')) .filter(t => materialLabelPattern.test(t.innerText || t.textContent || '')); const getMat = () => document.querySelector('app-materials .material-body .material-content') || document.querySelector('app-materials .material-content'); const getTi = () => document.querySelector('.questions-objective-container app-ti') || document.querySelector('app-ti'); // 题目唯一键:粉笔官方 data-question-key 优先,题号兜底 const qKeyOf = (ti) => { if (!ti) return null; const k = ti.getAttribute('data-question-key'); if (k) return 'k:' + k; const idx = ti.querySelector('.title-index'); return idx ? 'i:' + idx.innerText.trim() : null; }; const qSignatureOf = (ti) => { if (!ti) return ''; const text = (ti.innerText || ti.textContent || '').replace(/\s+/g, '').slice(0, 500); if (!text) return ''; return (qKeyOf(ti) || 'content') + '|' + text; }; const materialSignatureOf = (material) => { if (!material) return ''; const text = (material.innerText || material.textContent || '').replace(/\s+/g, '').slice(0, 1200); const images = Array.from(material.querySelectorAll('img')).map(img => img.currentSrc || img.src || img.getAttribute('src') || '').join('|'); return text + '|' + images; }; // 50ms 检查一次并要求连续两次稳定,兼顾异步渲染和读取速度,最长约2秒。 const waitQReady = async (oldSignature, requireChange = true) => { let last = '', stable = 0; for (let w = 0; w < 40; w++) { await sleep(50); const signature = qSignatureOf(getTi()); if (!signature || (requireChange && signature === oldSignature)) continue; if (signature === last) { stable++; if (stable >= 1) return true; } else { last = signature; stable = 0; } } return false; }; // 等待材料内容变化并连续两次稳定,最长约2秒。 const waitMatChange = async (previousSignature) => { let last = '', stable = 0; for (let w = 0; w < 40; w++) { await sleep(50); const m = getMat(); const signature = materialSignatureOf(m); if (!signature || signature === previousSignature) continue; if (signature === last) { stable++; if (stable >= 1) return m; } else { last = signature; stable = 0; } } return getMat(); }; // Angular 有时会把“材料一/题目一”保持数秒,随后才补齐其余标签。 // 单个标签属于可疑的半成品状态,要延长观察;多个标签也要等结构真正安静后再读取。 const waitTabsStable = async (getter, minimumWait = 600, trustSingle = false) => { const started = Date.now(); let last = '', quietSince = Date.now(); for (let w = 0; w < 160; w++) { await sleep(50); const tabs = getter(); const signature = tabs.map(tab => (tab.innerText || tab.textContent || '').replace(/\s+/g, '')).join('|'); if (signature !== last) { last = signature; quietSince = Date.now(); } const suspiciousSingle = tabs.length === 1 && !trustSingle; const requiredWait = Math.max(minimumWait, suspiciousSingle ? 3200 : 0); const requiredQuiet = suspiciousSingle ? 1200 : 250; if (tabs.length && Date.now() - started >= requiredWait && Date.now() - quietSince >= requiredQuiet) return tabs.length; } return getter().length; }; const chineseOrdinal = (text) => { const digits = { '零':0, '〇':0, '一':1, '二':2, '三':3, '四':4, '五':5, '六':6, '七':7, '八':8, '九':9 }; const value = String(text || '').replace(/[^零〇一二三四五六七八九十百]/g, ''); if (!value) return 0; let total = 0, current = 0; for (const ch of value) { if (ch === '百') { total += (current || 1) * 100; current = 0; } else if (ch === '十') { total += (current || 1) * 10; current = 0; } else current = digits[ch] ?? current; } return total + current; }; const materialNumberOf = (tab, index) => { const label = (tab && (tab.innerText || tab.textContent) || '').trim(); const arabic = label.match(/材料\s*(\d+)/); if (arabic) return parseInt(arabic[1], 10); return chineseOrdinal(label) || (index + 1); }; let previousMaterialSignature = ''; const collectVisibleMaterials = async (minimumWait = 0) => { await waitTabsStable(getMatTabs, minimumWait, true); const tabs = getMatTabs(); for (let j = 0; j < tabs.length; j++) { const num = materialNumberOf(tabs[j], j); if (materialMap.has(num)) continue; updatePrintProgress(reservedWin, '正在读取申论材料', '材料 ' + (j + 1) + ' / ' + tabs.length); const tab = getMatTabs()[j]; if (!tab) continue; click(tab); const material = await waitMatChange(previousMaterialSignature); if (!material) continue; previousMaterialSignature = materialSignatureOf(material); materialMap.set(num, material.cloneNode(true)); log('材料', num, '抓取成功,开头:', (material.innerText || '').trim().slice(0, 30)); } return tabs.length; }; const collectVisibleQuestions = async (minimumWait = 0) => { await waitTabsStable(getQTabs, minimumWait, true); const tabs = getQTabs(); for (let i = 0; i < tabs.length; i++) { updatePrintProgress(reservedWin, '正在整理作答要求', '题目 ' + (i + 1) + ' / ' + tabs.length); const tab = getQTabs()[i]; if (!tab) continue; const wasActive = tab.classList.contains('active'); const oldSignature = qSignatureOf(getTi()); if (wasActive) { await waitQReady('', false); } else { click(tab); let switched = await waitQReady(oldSignature); if (!switched) { const retryTab = getQTabs()[i]; if (retryTab) { click(retryTab); await waitQReady(oldSignature); } } } const ti = getTi(); const qk = qKeyOf(ti) || ('s:' + qSignatureOf(ti)); if (ti && qk && !seenQKeys.has(qk)) { seenQKeys.add(qk); questions.push(ti.cloneNode(true)); log('题目抓取成功:', qk); } // 有些页面切到其他题目后才补出对应材料,发现新标签就立即并入材料集合。 if (getMatTabs().length > materialMap.size) await collectVisibleMaterials(200); } return tabs.length; }; await waitTabsStable(getQTabs); const qTabs = getQTabs(); log('题目tab数量:', qTabs.length); if (qTabs.length > 0) { // ===== 阶段1:抓材料 ===== // 点最后一个题目tab(该题材料最全,通常包含全部材料) const lastQTab = getQTabs()[getQTabs().length - 1]; if (!lastQTab.classList.contains('active')) { const oldQSignature = qSignatureOf(getTi()); click(lastQTab); await waitQReady(oldQSignature); } else { await waitQReady('', false); } await waitTabsStable(getMatTabs); // 逐个点材料tab抓取,按tab编号去重 const matTabs = getMatTabs(); log('材料tab数量:', matTabs.length, matTabs.map(t => t.innerText.trim())); for (let pass = 0; pass < 2; pass++) { await collectVisibleMaterials(pass ? 400 : 0); if (materialMap.size >= getMatTabs().length) break; } // ===== 阶段2:抓题目 ===== for (let pass = 0; pass < 2; pass++) { await collectVisibleQuestions(pass ? 400 : 0); if (questions.length >= getQTabs().length) break; } // 排版前做完整性核对。若标签后来继续增长,就自动补抓,不把半成品送入预览。 updatePrintProgress(reservedWin, '正在核对申论内容', '确认材料与作答要求已经读取完整…'); const needsDeepVerification = getQTabs().length <= 1 || getMatTabs().length <= 1 || questions.length < getQTabs().length || materialMap.size < getMatTabs().length; if (needsDeepVerification) { for (let verifyPass = 0; verifyPass < 3; verifyPass++) { await waitTabsStable(getQTabs, verifyPass ? 200 : 0, true); await collectVisibleQuestions(0); const latestQTabs = getQTabs(); const latestLastTab = latestQTabs[latestQTabs.length - 1]; if (latestLastTab && !latestLastTab.classList.contains('active')) { const oldSignature = qSignatureOf(getTi()); click(latestLastTab); await waitQReady(oldSignature); } await collectVisibleMaterials(verifyPass ? 200 : 0); const expectedQuestions = getQTabs().length; const expectedMaterials = getMatTabs().length; if (questions.length >= expectedQuestions && materialMap.size >= expectedMaterials) break; } } const expectedQuestions = getQTabs().length; const expectedMaterials = getMatTabs().length; if (questions.length < expectedQuestions || materialMap.size < expectedMaterials) { const incompleteError = new Error('申论内容尚未完整加载'); incompleteError.userMessage = '申论材料或作答要求尚未加载完整,插件已停止生成不完整预览。请等待页面内容显示完整后再试。'; throw incompleteError; } // 恢复到第1题 const finalTab = getQTabs()[0]; if (finalTab && !finalTab.classList.contains('active')) click(finalTab); } else { // 无题目tab的兜底:抓当前可见内容 const m = getMat(); if (m) materialMap.set(1, m.cloneNode(true)); const ti = getTi(); if (ti) questions.push(ti.cloneNode(true)); } // 直接组装数组(不用 querySelectorAll,避免内外层同名class重复匹配) const sortedNums = Array.from(materialMap.keys()).sort((a, b) => a - b); log('最终材料数:', sortedNums.length, '最终题目数:', questions.length); const arr = []; sortedNums.forEach(n => arr.push(materialMap.get(n))); questions.forEach(q => arr.push(q)); elements = arr; } else if (isLocalPractice) { // ===== 错题/收藏练习:等待异步列表并读取每个独立题目组件 ===== // 这类页面没有 .tis-container/app-ti,题目可能在路由完成后才挂载。 updatePrintProgress(reservedWin, '正在读取错题 / 收藏', '正在批量读取正确答案,无需逐题点击…'); try { await loadLocalPracticeSolutions(); } catch (error) { localAnswerLoadError = error && error.message ? error.message : '答案接口读取失败'; console.warn('[粉笔打印] 批量读取答案失败,将使用当前页面已显示的答案', error); } let localQuestions = []; for (let i = 0; i < 30; i++) { localQuestions = Array.from(localPracticeRoot.querySelectorAll('.exam-detail fb-local-question, fb-local-question')); if (localQuestions.length > 0) break; await sleep(100); } let lastHeight = 0; for (let i = 0; i < 10; i++) { window.scrollTo(0, document.body.scrollHeight); await sleep(150); const curHeight = document.body.scrollHeight; localQuestions = Array.from(localPracticeRoot.querySelectorAll('.exam-detail fb-local-question, fb-local-question')); if (curHeight === lastHeight) break; lastHeight = curHeight; } window.scrollTo(0, 0); // 错题/收藏默认每页只渲染 10 题。按“下一页”依次克隆当前 DOM,最后回到第 1 页。 // querySelectorAll 对逗号选择器会去重;题号加题干作为键,避免页面切换时重复收集。 const collectedQuestions = []; const seenLocalQuestions = new Set(); const localQuestionKey = (question) => { const number = question.querySelector('.options-title')?.textContent.trim() || ''; const stem = question.querySelector('.question-content')?.textContent.replace(/\s+/g, '').slice(0, 120) || ''; return number + '|' + stem; }; const collectCurrentLocalPage = () => { localQuestions = Array.from(localPracticeRoot.querySelectorAll('.exam-detail fb-local-question, fb-local-question')); localQuestions.forEach(question => { const key = localQuestionKey(question); if (!seenLocalQuestions.has(key)) { seenLocalQuestions.add(key); const clone = question.cloneNode(true); const printedNumber = parseInt(question.querySelector('.options-title')?.textContent || '', 10); const orderedIndex = Number.isFinite(printedNumber) && printedNumber > 0 ? printedNumber - 1 : collectedQuestions.length; const questionId = localOrderedQuestionIds[orderedIndex]; if (questionId) clone.setAttribute('data-tm-question-id', questionId); collectedQuestions.push(clone); } }); }; collectCurrentLocalPage(); const firstPageKey = localQuestions[0] ? localQuestionKey(localQuestions[0]) : ''; let totalQuestions = 0; try { totalQuestions = parseInt(new URL(window.location.href).searchParams.get('total'), 10) || 0; } catch (_) {} const expectedPages = totalQuestions > localQuestions.length && localQuestions.length > 0 ? Math.min(100, Math.ceil(totalQuestions / localQuestions.length)) : 1; updatePrintProgress(reservedWin, '正在读取错题 / 收藏', '正在读取第 1 / ' + expectedPages + ' 页,已收集 ' + collectedQuestions.length + ' 题'); for (let pageIndex = 1; pageIndex < expectedPages; pageIndex++) { const nextButton = localPracticeRoot.querySelector('fb-pager .houpage')?.parentElement; if (!nextButton) break; const beforeKey = localQuestions[0] ? localQuestionKey(localQuestions[0]) : ''; try { nextButton.click(); } catch (_) { break; } let changed = false; for (let waitIndex = 0; waitIndex < 30; waitIndex++) { await sleep(50); const nextFirst = localPracticeRoot.querySelector('.exam-detail fb-local-question, fb-local-question'); if (nextFirst && localQuestionKey(nextFirst) !== beforeKey) { changed = true; break; } } if (!changed) break; // 首题变化说明框架已经提交新一页;再留两个绘制帧让同页其余题目挂载。 await sleep(40); collectCurrentLocalPage(); updatePrintProgress(reservedWin, '正在读取错题 / 收藏', '正在读取第 ' + (pageIndex + 1) + ' / ' + expectedPages + ' 页,已收集 ' + collectedQuestions.length + ' 题'); } const firstPageButton = Array.from(localPracticeRoot.querySelectorAll('fb-pager .item')) .find(button => button.textContent.trim() === '1'); if (firstPageButton && localQuestions[0] && localQuestionKey(localQuestions[0]) !== firstPageKey) { try { firstPageButton.click(); } catch (_) {} } elements = collectedQuestions; } else { // ===== 行测:滚动加载全部,直接查询 ===== const scrollContainer = document.querySelector('.tis-container') || document.querySelector('[class*="scroll"]') || window; let lastHeight = 0; for (let i = 0; i < 10; i++) { if (scrollContainer === window) { window.scrollTo(0, document.body.scrollHeight); } else { scrollContainer.scrollTop = scrollContainer.scrollHeight; } await sleep(150); const curHeight = scrollContainer === window ? document.body.scrollHeight : scrollContainer.scrollHeight; if (curHeight === lastHeight) break; lastHeight = curHeight; } if (scrollContainer === window) window.scrollTo(0, 0); else scrollContainer.scrollTop = 0; const tisContainer = document.querySelector('.tis-container') || document.body; elements = tisContainer.querySelectorAll('.chapter-container, app-materials, .material, .material-content, app-ti'); } if (!elements || elements.length === 0) { mask.style.display = 'none'; try { if (reservedWin && !reservedWin.closed) reservedWin.close(); } catch (_) {} alert("未能在当前页面找到题目,请确保页面已完全加载!"); return; } updatePrintProgress(reservedWin, '正在生成 A4 分页预览', '题目与材料已读取完成,正在计算页面位置…'); let newPaperHtml = ` ${printTitleHtml}
P 粉笔打印预览 ${paginationMode === 'smart' ? '智能分页' : paginationMode === 'whole' ? '整题不拆' : '紧密排列'}计算页数…
字号 ${fontSize}px
行距 ${lineHeight}
图片 60%
拖动时即时预览,停顿或松手后自动校准分页;图片也可拖右下角手柄,双击恢复。
✓
正在完成 PDF 保存
浏览器仍在写入文件,请暂时不要打开 PDF。
`; if (hasCover) { let titleLine1 = coverTitle; let titleLine2 = ""; if(coverTitle.includes("《")) { let parts = coverTitle.split("《"); titleLine1 = parts[0]; titleLine2 = "《" + parts[1]; } newPaperHtml += `
内部资料
考生专用
${titleLine1}
${titleLine2 ? '
' + titleLine2 + '
' : ''}
重要提示

为维护您的个人权益,确保考试的公平公正,请您协助我们监督考试实施工作。

本场考试规定:监考老师要向本考场全体考生展示题本密封情况,并邀请两名考生代表验封签字后,方能开启试卷袋。

名字:
分数:
行测小助理提供
 
`; } let isFirstChapter = true; let chapterCount = 0; let materialIndex = 0; // 当前章节内的材料序号 let lastLocalMaterialSignature = ''; // 错题/收藏中连续共用材料只输出一次 let shenlunQHeadingAdded = false; // 申论"作答要求"标题是否已输出 let currentChapter = ''; // 当前章节名,用于识别"判断推理"(图形推理)这类图形题 const localAnswerRows = []; // 与错题/收藏题目在同一次遍历中生成,避免跨页或排序造成错配 let localQuestionOrder = 0; const escapePaperText = (value) => String(value == null ? '' : value) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); const sectionName = index => { const names = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; return '第' + (names[index] || String(index)) + '部分'; }; const moduleDividerHtml = (label, isFinal = false) => '
※※※' + label + '※※※
'; const answerTextFromHtml = (value) => { const container = document.createElement('div'); container.innerHTML = String(value == null ? '' : value); return (container.textContent || '').replace(/\s+/g, ' ').trim(); }; const answerFromLocalSolution = (solution) => { const correctAnswer = solution && solution.correctAnswer; if (!correctAnswer) return ''; if (correctAnswer.choice != null && String(correctAnswer.choice).trim()) { const rawChoices = Array.isArray(correctAnswer.choice) ? correctAnswer.choice : String(correctAnswer.choice).split(','); const letters = rawChoices.map(value => { const normalized = String(value).trim().toUpperCase(); if (/^\d+$/.test(normalized)) { const index = parseInt(normalized, 10); return index >= 0 && index < 26 ? String.fromCharCode(65 + index) : ''; } return /^[A-Z]$/.test(normalized) ? normalized : ''; }).filter(Boolean); if (letters.length) return Array.from(new Set(letters)).sort().join(''); } if (Array.isArray(correctAnswer.blanks) && correctAnswer.blanks.length) { const blanks = correctAnswer.blanks.map(blank => { const alternatives = Array.isArray(blank) ? blank : [blank]; return alternatives.map(answerTextFromHtml).filter(Boolean).join(' / '); }).filter(Boolean); if (blanks.length) return blanks.join(';'); } if (correctAnswer.answer != null) return answerTextFromHtml(correctAnswer.answer); return ''; }; const extractLocalCorrectAnswer = (question, solution) => { const remoteAnswer = answerFromLocalSolution(solution); if (remoteAnswer) return remoteAnswer; // 粉笔 fb-ng-solution-choice 会把每个正确选项标为 right-options;多选题会有多个。 const answers = []; const addAnswer = (value) => { const normalized = String(value || '').trim().toUpperCase(); if (normalized && !answers.includes(normalized)) answers.push(normalized); }; const rightMarkers = Array.from(question.querySelectorAll('.right-options')); rightMarkers.forEach(marker => { const option = marker.closest('li') || marker; const letterText = option.querySelector('.fb-radioInput span')?.textContent || marker.querySelector('span')?.textContent || ''; const letter = letterText.replace(/[.、::\s]/g, '').toUpperCase(); if (/^[A-H]$/.test(letter)) { addAnswer(letter); return; } const optionText = (option.textContent || '').replace(/\s+/g, ''); if (/^正确$/.test(optionText) || optionText.includes('正确')) addAnswer('正确'); else if (/^错误$/.test(optionText) || optionText.includes('错误')) addAnswer('错误'); }); if (answers.length) { const lettersOnly = answers.every(value => /^[A-H]$/.test(value)); return lettersOnly ? answers.sort().join('') : answers.join('、'); } // 兼容粉笔可能采用的显式“正确答案”字段;只接受明确答案节点,绝不从解析正文猜答案。 const explicitNodes = question.querySelectorAll( '.correct-answer, .right-answer, [class*="correct-answer"], [class*="right-answer"], [data-role*="correct-answer"]' ); for (const node of explicitNodes) { const text = (node.textContent || '').replace(/\s+/g, ''); const choiceMatch = text.match(/(?:正确答案|参考答案|答案)?[::]?([A-H](?:[,,、/]*[A-H])*)/i); if (choiceMatch) return choiceMatch[1].toUpperCase().replace(/[,,、/]/g, ''); const trueFalseMatch = text.match(/(?:正确答案|参考答案|答案)?[::]?(正确|错误)/); if (trueFalseMatch) return trueFalseMatch[1]; } return ''; }; const extractLocalCorrectRate = (question, solution, questionMeta) => { // 粉笔答案详情组件读取 question.meta_.correctRatio,并用整数百分比展示。 const candidates = [ solution && solution.meta && solution.meta.correctRatio, solution && solution.meta_ && solution.meta_.correctRatio, solution && solution.correctRatio, questionMeta && questionMeta.correctRatio, questionMeta && questionMeta.meta && questionMeta.meta.correctRatio ]; for (const value of candidates) { if (value == null || value === '') continue; const number = Number(String(value).replace('%', '').trim()); if (Number.isFinite(number) && number >= 0 && number <= 100) return Math.round(number) + '%'; } // 接口缺少统计字段时,兼容页面已经渲染出的“正确率 75%”文本。 const text = question ? (question.innerText || question.textContent || '') : ''; const match = text.match(/(?:全站|本题|平均)?\s*正确率\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/); return match ? Math.round(Number(match[1])) + '%' : ''; }; const toChineseMaterialNumber = (number) => { const digits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; if (number < 10) return digits[number]; if (number === 10) return '十'; if (number < 20) return '十' + digits[number - 10]; if (number < 100) return digits[Math.floor(number / 10)] + '十' + (number % 10 ? digits[number % 10] : ''); return String(number); }; const removeEmbeddedShenlunHeading = (container) => { if (!isEssayLike) return; container.querySelectorAll('*').forEach(node => { const text = (node.innerText || node.textContent || '').trim(); if (node.children.length === 0 && /^材料\s*[一二三四五六七八九十百零〇\d]+[::]?$/.test(text)) node.remove(); }); }; const materialHeadingHtml = (html, index) => { if (isEssayLike) return '
材料' + toChineseMaterialNumber(index) + '
'; return /材料\s*[一二三四五六七八九十\d]+/.test(html) ? '' : '
材料' + index + '
'; }; const markInlineMaterialFormulas = (root) => { // 只有前后都有正文的公式图才随文字排版;独立公式和图表仍保持居中块状排版。 root.querySelectorAll('img[flag="tex"]').forEach(img => { const parent = img.parentElement; if (!parent || parent.tagName !== 'P') return; const siblings = Array.from(parent.childNodes); const index = siblings.indexOf(img); const hasText = nodes => nodes.some(node => (node.textContent || '').trim()); if (hasText(siblings.slice(0, index)) && hasText(siblings.slice(index + 1))) { img.classList.add('tm-inline-formula'); } }); }; const shenlunSolutionSelector = (isShenlun ? [ 'fb-ng-solution-choice', 'fb-ng-solution-blank', 'fb-ng-solution-essay', 'app-solution', 'app-result-common', 'app-solution-overall', '.answer', '.analysis', '.solution', '[class*="answer-analysis"]', '[class*="answer-content"]', '[class*="reference-answer"]', '[class*="analysis-content"]', '[data-role*="answer"]', '[data-role*="solution"]' ] : [ 'fb-ng-solution-choice', 'fb-ng-solution-blank', 'fb-ng-solution-essay', 'app-solution', 'app-result-common', 'app-solution-overall', '.answer', '.analysis', '.solution', '[class*="solution"]', '[class*="answer-analysis"]', '[class*="answer-content"]', '[class*="reference-answer"]', '[class*="analysis-content"]', '[data-role*="answer"]', '[data-role*="solution"]' ]).join(','); const findShenlunStemContainer = (question) => { const selectors = [ ...[ 'app-solution-smart article.content', 'app-solution-blank article.content', 'app-solution-view-only article.content', 'app-solution-special-filling-blank article.content', 'app-solution-sketch article.content', 'app-solution-keyword article.content', 'app-solution-draw-line article.content', 'app-solution-choice app-question-choice app-format-html' ], 'fb-ng-question-essay app-format-html', 'fb-ng-question-blank app-format-html', 'fb-ng-question-choice app-format-html', '.question-content app-format-html', 'article.content app-format-html', 'app-format-html', 'article.content' ]; for (const selector of selectors) { const match = Array.from(question.querySelectorAll(selector)).find(node => !node.closest(shenlunSolutionSelector)); if (match) return match; } return null; }; const stripShenlunAnswerTail = (root) => { if (!isEssayLike || !root) return; root.querySelectorAll(shenlunSolutionSelector).forEach(node => node.remove()); const marker = /(?:参考答案(?:说明)?|答案解析|参考解析|标准答案|作答参考)\s*[::]/; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); while (walker.nextNode()) { const textNode = walker.currentNode; const match = (textNode.nodeValue || '').match(marker); if (!match) continue; const range = document.createRange(); range.setStart(textNode, match.index); range.setEnd(root, root.childNodes.length); range.deleteContents(); root.normalize(); break; } }; // ===== 自选题目导出:拼装 newPaperHtml 之前先弹出勾选界面,拿到过滤集合后再继续 ===== let tmPickSkip = null; if (pickMode) { mask.style.display = 'none'; // 勾选界面展示在原页面:确保预览窗口退到后台,避免遮挡勾选界面 try { if (reservedWin && !reservedWin.closed) reservedWin.blur(); } catch (_) {} try { window.focus(); } catch (_) {} const tmPlan = tmBuildPickPlan(elements, isEssayLike, isLocalPractice, (el) => { let container = isEssayLike ? findShenlunStemContainer(el) : el.querySelector('app-format-html'); if (!container && !isEssayLike) container = el.querySelector('article.content'); if (!container) container = el.querySelector('.question-content'); return container; }); const tmPicked = await openTmPickUI(tmPlan, { cover: document.getElementById('tm-hasCover') ? !!document.getElementById('tm-hasCover').checked : false, material: true, // 勾选期间不预开任何窗口,用户全程留在原页面;点「确定,生成预览」时才在这里 // 同步创建预览标签页(仍处于用户点击手势内,不会被弹窗拦截)。 openPreview: () => { try { reservedWin = window.open('', '_blank'); showReservedLoading(reservedWin); } catch (_) { reservedWin = null; } return reservedWin; } }); if (!tmPicked) { // 用户取消:关闭预留预览窗口,直接结束本次导出(原有按钮与流程不受影响) try { if (reservedWin && !reservedWin.closed) reservedWin.close(); } catch (_) {} return; } if (!reservedWin || reservedWin.closed) { try { reservedWin = (tmPicked.win && !tmPicked.win.closed) ? tmPicked.win : null; } catch (_) {} } if (typeof tmPicked.cover === 'boolean') hasCover = tmPicked.cover; tmPickSkip = tmComputePickSkip(tmPlan, tmPicked.sel, tmPicked.material); mask.style.display = 'flex'; updatePrintProgress(reservedWin, '正在生成 A4 分页预览', '已选定 ' + tmPicked.count + ' 题,正在计算页面位置…'); // 用户已确认:把之前压到后台的预览窗口唤回前台 try { if (reservedWin && !reservedWin.closed) reservedWin.focus(); } catch (_) {} } elements.forEach((el, elIdx) => { if (tmPickSkip && tmPickSkip.has(elIdx)) return; if (el.classList.contains('chapter-container')) { materialIndex = 0; let nameNode = el.querySelector('.chapter-name'); let descNode = el.querySelector('.chapter-desc'); let chapterName = nameNode ? nameNode.innerText.trim() : ''; let chapterDesc = descNode ? descNode.innerText.trim() : ''; currentChapter = chapterName; // 记录当前章节名(用于判断推理->图形题识别) if (chapterName) { if (chapterCount) { newPaperHtml += moduleDividerHtml(sectionName(chapterCount) + '结束,请继续作答' + sectionName(chapterCount + 1)); } chapterCount++; let pageBreakStyle = isFirstChapter ? 'padding-top: 10px;' : moduleNewPage ? 'page-break-before: always; break-before: page;' : ''; const inlineChapterClass = !isFirstChapter && !moduleNewPage ? ' tm-inline-chapter' : ''; isFirstChapter = false; newPaperHtml += `
${chapterName}
${chapterDesc ? '
' + chapterDesc + '
' : ''}
`; } } else if (el.tagName.toLowerCase() === 'app-materials' || el.classList.contains('material') || el.classList.contains('material-content')) { if (el.classList.contains('material-content') && el.closest('app-materials')) return; if (el.classList.contains('material-content') && el.closest('.material')) return; if (el.classList.contains('material') && el.closest('app-materials')) return; let materialClone = el.cloneNode(true); materialClone.querySelectorAll('.tooltip-container, .label-tab, app-ti, .material-tabs, .material-tab, .tabs, .tabs-content, .tab-list, .material-nav, .material-select, .select-material').forEach(e => e.remove()); materialClone.querySelectorAll('*').forEach(node => { const txt = (node.innerText || '').trim(); if (txt && /材料\s*[一二三四五六七八九十\d]+.*材料\s*[一二三四五六七八九十]+/.test(txt)) node.remove(); }); if (el.tagName.toLowerCase() === 'app-materials') { let innerMaterials = materialClone.querySelectorAll('.material'); if (innerMaterials.length > 1) { innerMaterials.forEach(m => { let mClone = m.cloneNode(true); mClone.querySelectorAll('.tooltip-container, .label-tab, app-ti, .material-tabs, .material-tab, .tabs, .tabs-content, .tab-list, .material-nav, .material-select, .select-material').forEach(e => e.remove()); mClone.querySelectorAll('*').forEach(node => { const txt = (node.innerText || '').trim(); if (node.children.length === 0 && /请选择材料|选择材料/.test(txt)) node.remove(); if (txt && /材料\s*[一二三四五六七八九十\d]+.*材料\s*[一二三四五六七八九十]+/.test(txt)) node.remove(); }); removeEmbeddedShenlunHeading(mClone); markInlineMaterialFormulas(mClone); let mHtml = mClone.innerHTML; materialIndex++; let heading = materialHeadingHtml(mHtml, materialIndex); let matClass = isShenlun ? 'material-box shenlun-mat' : 'material-box'; newPaperHtml += '
' + heading + mHtml + '
'; }); return; } } materialClone.querySelectorAll('*').forEach(node => { const txt = (node.innerText || '').trim(); if (node.children.length === 0 && /请选择材料|选择材料/.test(txt)) node.remove(); if (txt && /材料\s*[一二三四五六七八九十\d]+.*材料\s*[一二三四五六七八九十]+/.test(txt)) node.remove(); }); removeEmbeddedShenlunHeading(materialClone); markInlineMaterialFormulas(materialClone); let mHtml = materialClone.innerHTML; materialIndex++; let heading = materialHeadingHtml(mHtml, materialIndex); let matClass = isShenlun ? 'material-box shenlun-mat' : 'material-box'; newPaperHtml += '
' + heading + mHtml + '
'; } else if (el.tagName.toLowerCase() === 'app-ti' || el.tagName.toLowerCase() === 'fb-local-question') { const isLocalQuestion = el.tagName.toLowerCase() === 'fb-local-question'; if (isLocalQuestion) localQuestionOrder++; if (isEssayLike && !shenlunQHeadingAdded) { newPaperHtml += '
作答要求
'; shenlunQHeadingAdded = true; } let q = el; let qNumObj = isLocalQuestion ? q.querySelector('.options-title') : q.querySelector('.title-index'); let qNumStr = qNumObj ? qNumObj.innerText.trim() : ''; if (isLocalQuestion) { const answerNumber = qNumStr || (localQuestionOrder + '.'); const questionId = q.getAttribute('data-tm-question-id') || ''; localAnswerRows.push({ number: answerNumber, questionId, answer: extractLocalCorrectAnswer(q, localSolutionById.get(questionId)), correctRate: extractLocalCorrectRate(q, localSolutionById.get(questionId), localQuestionMetaById.get(questionId)) }); } let localMaterialHtml = ''; if (isLocalQuestion) { const localMaterial = q.querySelector('fb-ng-question-material .material-content, .fb-question-material .material-content'); if (localMaterial) { const materialClone = localMaterial.cloneNode(true); materialClone.querySelectorAll('button, input, .material-nav').forEach(node => node.remove()); markInlineMaterialFormulas(materialClone); const imageSignature = Array.from(materialClone.querySelectorAll('img')).map(img => img.currentSrc || img.src || img.getAttribute('src') || '').join('|'); const signature = (materialClone.innerText || materialClone.textContent || '').replace(/\s+/g, '') + '|' + imageSignature; if (signature && signature !== lastLocalMaterialSignature) { lastLocalMaterialSignature = signature; localMaterialHtml = '
材料
' + materialClone.innerHTML + '
'; } } } let stemHtml = ''; let stemContainer = isEssayLike ? findShenlunStemContainer(q) : q.querySelector('app-format-html'); if (!stemContainer && !isEssayLike) stemContainer = q.querySelector('article.content'); if (!stemContainer && isLocalQuestion) { stemContainer = q.querySelector('fb-ng-choice-question .question-content, fb-ng-question-blank .question-content, fb-ng-question-essay .question-content, fb-ng-solution-choice .question-content, fb-ng-solution-blank .question-content, fb-ng-solution-essay .question-content, .fb-question-options .question-content, .fb-solution-sub .question-content, .question-content'); } if (stemContainer) { let stemClone = stemContainer.cloneNode(true); if (isEssayLike) stemClone.querySelectorAll(shenlunSolutionSelector).forEach(node => node.remove()); let htmlContent = stemClone.innerHTML; htmlContent = htmlContent.replace(/_{6,}/g, '        '); htmlContent = htmlContent.replace(/( |\s){6,}/g, '          '); let tempDiv = document.createElement('div'); tempDiv.innerHTML = htmlContent; stripShenlunAnswerTail(tempDiv); if (qNumStr) { let firstP = tempDiv.querySelector('p'); if (firstP) { firstP.classList.add('qnum-first'); firstP.innerHTML = '' + qNumStr + '' + firstP.innerHTML; } else { tempDiv.innerHTML = '

' + qNumStr + '' + tempDiv.innerHTML + '

'; } } stemHtml = tempDiv.innerHTML; } // 统计题干中的图片数量:用于识别"六个图形分两类"这类(选项是序号、题干是多图)的图形题 let stemImgCount = 0; if (stemHtml) { let tmpDiv = document.createElement('div'); tmpDiv.innerHTML = stemHtml; stemImgCount = tmpDiv.querySelectorAll('img').length; } let optElements = isLocalQuestion ? q.querySelectorAll('fb-ng-choice-question .options > li, fb-ng-solution-choice .options > li, .fb-question-options .options > li, .fb-solution-sub .options > li') : q.querySelectorAll('li[class*="choice"], .option-item'); let optsHtml = ''; let isFigureQuestion = false; const optionContentOf = (opt) => { if (!isLocalQuestion) return opt.querySelector('.choice-radio-label') || opt; const directChildren = Array.from(opt.children || []); return directChildren.find(node => node.classList && node.classList.contains('options-material')) || directChildren.find(node => node.tagName === 'P' && !(node.classList && node.classList.contains('fb-radioInput'))) || opt.querySelector('.options-material') || opt; }; // 广东专用且用户主动勾选:严格限定两个纯文字选项,分别为正确、错误。 const hideThisJudgement = hideGuangdongJudgement && optElements.length === 2 && (() => { const labels = Array.from(optElements, opt => { const clone = optionContentOf(opt).cloneNode(true); clone.querySelectorAll('.input-radio, .fb-radioInput, .fb-label, input, button, i').forEach(node => node.remove()); if (clone.querySelector('img, svg')) return ''; return (clone.textContent || '').replace(/[\s.、::]/g, ''); }); return labels.includes('正确') && labels.includes('错误'); })(); if (optElements.length > 0 && !hideThisJudgement) { let maxTextLen = 0; let isAllPureImages = true; let hasBigImg = false; optElements.forEach(opt => { let labelNode = optionContentOf(opt); let cloneLabel = labelNode.cloneNode(true); cloneLabel.querySelectorAll('.input-radio, .fb-radioInput, .fb-label, input, button, i').forEach(e => e.remove()); let pureText = cloneLabel.innerText.replace(/\s+/g, ''); // 选项里可能夹着箭头、公式等小图;只要仍有实际文字,就不能当作纯图片选项。 if (pureText.length > 0 && !/^[A-D\.、]$/.test(pureText)) { isAllPureImages = false; } if (pureText.length > maxTextLen) { maxTextLen = pureText.length; } labelNode.querySelectorAll('img').forEach(img => { let w1 = img.getAttribute('width'); let w2 = img.style.width; let renderedWidth = img.getBoundingClientRect ? img.getBoundingClientRect().width : 0; if (w1 && parseInt(w1) > 120) hasBigImg = true; if (w2 && parseInt(w2) > 120) hasBigImg = true; if (renderedWidth > 120) hasBigImg = true; }); }); // 图形推理(图片推理):四个选项全是图片,或当前在"判断推理"章节且题干含图形 // (图可能是一整张合成图=1个img,也可能是多张;不再按图片张数判断)→ 定向缩放题干图形 isFigureQuestion = isAllPureImages || stemImgCount >= 1; let containerClass = 'options-grid '; if (isAllPureImages) { if (hasBigImg) { containerClass += 'col-2 img-opt'; } else { containerClass += 'col-4 img-opt'; if (optElements.length === 4) containerClass += ' small-img-opt'; } } else { if (hasBigImg) { containerClass += (maxTextLen > 15) ? 'col-1' : 'col-2'; } else { if (maxTextLen <= 6) { containerClass += 'col-4'; } else if (maxTextLen <= 15) { containerClass += 'col-2'; } else { containerClass += 'col-1'; } } } optsHtml += '
'; optElements.forEach((opt, optIndex) => { let letterNode = isLocalQuestion ? opt.querySelector('.fb-radioInput span') : opt.querySelector('.input-radio'); let letter = letterNode ? letterNode.innerText.trim() : String.fromCharCode(65 + optIndex); let labelNode = optionContentOf(opt); let cloneLabel = labelNode.cloneNode(true); cloneLabel.querySelectorAll('.input-radio, .fb-radioInput, .fb-label, input, button, i').forEach(e => e.remove()); let textHtml = cloneLabel.innerHTML.trim(); let pt = cloneLabel.innerText.replace(/\s+/g, ''); if (pt === letter && !cloneLabel.querySelector('img')) { textHtml = ''; } // 去掉选项 label 里自带的单个字母(A/B/C/D,无论图形是 还是 div/svg),避免与下方 .opt-letter 重复 { let dt = document.createElement('div'); dt.innerHTML = textHtml; const walker = document.createTreeWalker(dt, NodeFilter.SHOW_TEXT); const kills = []; while (walker.nextNode()) { const n = walker.currentNode; if (/^[A-Da-d]$/.test((n.nodeValue || '').replace(/[\s.、:]/g, ''))) kills.push(n); } kills.forEach(n => n.parentNode.removeChild(n)); textHtml = dt.innerHTML; } if (isAllPureImages) { optsHtml += `
${textHtml}
${letter}.
`; } else { optsHtml += `
${letter}.
${textHtml}
`; } }); optsHtml += '
'; } newPaperHtml += localMaterialHtml + `
${stemHtml}
${optsHtml}
`; } }); if (chapterCount) newPaperHtml += moduleDividerHtml('全部测验到此结束', true); // 错题/收藏由多个来源题目组成,没有可用的单一 answercard ID;逐题附上与打印顺序绑定的答案。 if (isLocalPractice && localAnswerRows.length) { const missingAnswerCount = localAnswerRows.filter(row => !row.answer).length; const missingCorrectRateCount = localAnswerRows.filter(row => !row.correctRate).length; const answerItemsHtml = localAnswerRows.map(row => { const answer = row.answer ? '' + escapePaperText(row.answer) + '' : '未读取'; const correctRate = row.correctRate ? '' + escapePaperText(row.correctRate) + '' : ''; const longClass = row.answer && row.answer.length > 12 ? ' tm-answer-long' : ''; return '
' + escapePaperText(row.number) + '' + answer + correctRate + '
'; }).join(''); let answerNote = '答案和全站正确率已从粉笔当前账号的题目数据中批量读取,并按题目ID与打印题号逐题对应。'; if (missingAnswerCount) { answerNote += ' 其中 ' + missingAnswerCount + ' 题未读取到答案'; answerNote += localAnswerLoadError ? '(' + escapePaperText(localAnswerLoadError) + '),请刷新或重新登录后再试。' : ',请确认这些题目在粉笔中可查看解析。'; } if (missingCorrectRateCount) { answerNote += ' ' + missingCorrectRateCount + ' 题暂未取得全站正确率。'; } newPaperHtml += `

答案速查

${answerNote}

${answerItemsHtml}
`; } // 添加答案二维码区域(粉笔扫一扫对答案) const answerQrUrl = getAnswerQrCodeUrl(); if (answerQrUrl) { newPaperHtml += `
扫一扫,对答案
打开粉笔APP,扫描下方二维码,提交答案后即可评分并查看解析
答案二维码
本二维码由粉笔题库整理生成
`; } // 原生“打印为 PDF”不提供文件写完事件;打印窗口关闭后给出明确的安全等待提示。 newPaperHtml += `