// ==UserScript== // @name 粉笔一页成卷 // @namespace marvis/fenbi-inpage-typeset // @version 1.1.3 // @description 在粉笔网页版题目页就地套用《粉笔试卷排版打印》的版式:宋体(默认字号 24px / 行高 1.6,均在面板上用 −/+ 加减调整)(本机宋体优先,安卓平板/iPad 缺宋体时自动联网补思源宋体,可关)、题干首行缩进 2em 与段间距 0.5em、题号悬挂缩进(固定 2em)、选项 flex 排布(列数默认自动,可切 1/2/4 列;列距 18px、行距 0.5em、上距 0.3em)、图片限幅(题干 max-width 100%、选项 140x100)、表格规则、题间距(默认 18px,面板用 −/+ 调整)。只调样式:不打印、不生成 A4、不浮层、不改动题目文本与原有交互。 // @author Marvis File Agent // @match *://spa.fenbi.com/* // @match *://www.fenbi.com/spa/* // @run-at document-idle // @grant none // ==/UserScript== /* * 设计说明:与《粉笔试卷排版打印》的对应关系 * --------------------------------------------------------------------------- * 原脚本的链路是「提取 -> buildHtml 重排 -> paginate 分页 -> 打印」,本质是 * 另造一份文档再印出来。本脚本只借用它的版式数值与构造思想,在粉笔原生页面上 * 就地覆盖 CSS 视觉表现(不生成页面、不打印、不改 DOM 结构)。 * * 原脚本 本脚本 * ------------------------------------------------------ ------------------------------------------------------ * body{font-size:15px;line-height:1.6} :root 上的 --fp-ts-fs / --fp-ts-lh * .fp-stem{padding-left:var(--hang)} [data-fp-ts] .fp-ts-stem 同款悬挂列 * .fp-num{float:left;margin-left:-hang;width:hang} [data-fp-ts] .fp-ts-num 同款负边距顶出 * .fp-stem p{margin:0 0 .5em;justify;text-align-last:left} 题干/材料段落同款 * .fp-opts{flex-wrap;column-gap:18px;row-gap:.5em;margin-top:.3em} * [data-fp-ts] .fp-ts-opts 同款 flex 换行 * .fp-opts.grid-N>.fp-opt{flex:0 0 calc((100% - gap*(N-1))/N)} * 用 --fp-ts-optw 表达同一套列宽公式 * layoutFor():18px 列距 / 140px 图片上限 / 70px 小图估算 pickCols() 按真实可用宽算 4/2/1 * .fp-opt img{max-width:140px;max-height:100px;object-fit:contain} * 选项图同款限幅;题干图 max-width:100% * .fp-mat table{width:100%;border-collapse} 表格同款规则(字号比正文小 1px) * * 与打印版的刻意差异(详见交付说明的「已知限制」): * 1. 不做 tex 行内公式图等比缩放(原脚本按 fontSize/18 缩放,本次明确不在范围内)。 * 2. 不重写题目文本(blankify 填空线、flattenOpt 拆盒都不做);选项内部块级外壳用 * CSS :has() 守卫做行内化,从而不移动任何节点。 * 3. 选项字母悬挂放在每个选项自身(列内缩进),而不是整组统一 padding —— 4 列时 * 字母不会越出自己的列、压到左列文字上。 * 4. JS 只做两件事:探测卡片并打标记 + 按固定悬挂量写 --fp-ts-hang,其余全部由 CSS 变量驱动。 * * 跨端宋体(v1.9.0): * 字体链写全各端真实字体名(Windows SimSun / macOS·iPad Songti SC / 安卓 Noto Serif CJK), * 并按「本机优先、缺了才下载」的顺序,把思源宋体(Noto Serif SC)的 webfont 排在链尾: * 本机出得来宋体就绝不联网;安卓平板、无宋体的 iPad 浏览器则由 webfont 接力,实现跨端显示一致。 * 字体来源与命中情况在面板底部单独一行实时显示,便于一眼确认到底有没有生效。 * * 选项字母(v1.9.1): * 字母盒取消上下居中(vertical-align:middle -> top,并显式 margin-top:0),字母靠行顶; * 每个字母后用样式层 :after 补一个英文句号,呈现为 A. B. C. D.(不改动页面 DOM 文本)。 * * 选项字母定位(v1.9.2): * 真相:原生把字母盒的父级做成了 flex 居中,父级的 align-items 会盖掉子项的 vertical-align, * 所以前几版只改 vertical-align 一律「没变化」。改为从子项侧下手: * · 竖直:align-self:flex-start 顶开父级居中,再用上边距 = 行盒半行距 (行高-1)/2 em, * 让字母盒与选项正文第一行落在同一条基线上(既不居中、也不过高)。 * · 水平:text-align:left 让字母靠盒左缘;字母后那个空格用负 word-spacing 回收, * 句号与正文之间自然留出呼吸位(回收量见 v1.9.3)。 * 同一组声明在 JS 里再写一份内联 !important 兜底(原生的选择器权重/内联样式都压不过它), * 并在停用时按名单逐个摘除。 * * 选项字母定位微调(v1.9.3): * 只调「字母后那个空格」的回收量:word-spacing 由 -.5em 放宽到 -.25em, * 句号相对字母右移约 0.25em(15px 字号下约 3~4px),起步离开字母、不再贴脸; * 字母仍 text-align:left 靠盒左缘,竖直基线与 v1.9.2 完全一致,未动其余任何属性。 * * 题干左边缘对齐(v1.9.4): * 问题:题干吃的是「悬挂列」内缩(--fp-ts-hang,固定 2em),而选项字母贴的是选项行左边界 * (选项行 padding-left 里只有 --fp-ts-optindent 这一小段),两者起点天生不同, * 表现就是题干整体比 ABCD 偏右十几像素。 * 做法:不猜数值,直接量。用 Range 取题干首个文字块的左边缘、第一个选项字母的左边缘, * 把像素残差写进本卡的 --fp-ts-stemfix(叠加在题干 padding-left 里),迭代收敛; * 同时把题干容器原生的水平外边距清零,避免「缩进 + 外边距」两份偏移叠加。 * 字体晚到、窗口缩放等引起的位移,由既有的 remeasure 流程重排卡片后自动重新量正。 * * 题干对齐「打开不生效、点一下面板才生效」修复(v1.9.5): * 现象:打开题目页题干仍比 ABCD 偏右,手动点一下面板里的「选项列数」才对齐。 * 真因:首帧(React 水合中)题干还是零宽,Range 量出的两侧左缘都是 0,残差 0 被当成 * 「已对齐」直接返回;此后卡片签名没变、标记仍在,handleCard 一路早退,错位就此固化, * 只有 force 重排(点面板改设置)才会重新量。 * 修法:① 宽度不合法(≤1px)时绝不判定对齐,一律挂 remeasure 等布局稳定后复量; * ② 卡片首次上妆后强制挂一次延迟复量,避免首帧巧合量到 0 后被永久早退跳过。 * 关键补丁(否则前面两条都会空转):加 data-fp-ts-stemfix-pending 记号,「待重量」的卡片 * 在 handleCard 里不参与「签名相同 → 早退」的短路,remeasure 重排时才真能重算 stemfix; * 量准即摘记号,恢复正常早退,不会自旋。 * * 刷新偶发错位修复(v1.1.3 / v1.1.4): * 现象:刷新页面后偶尔仍错位,且手动点面板「重新排版」也常常修不好。 * 真因(五条叠加,前四条都在 v1.1.1 的缓存上): * ① 缓存没有作废通道:字符键只编了「字号|行高|列数」,且 applyAll(true) 不清缓存。 * 联网补字到位 / 字体回退链换档后,onWebFontReady 里的 applyAll(true) 白排一轮—— * 键没变,直接吃下用回退字体量出来的旧值;用户点「重新排版」同样吃到旧值。 * ② 收敛一次即固化:首帧或字体未稳时两侧可能巧合量到同一过渡位置,残差 0 被当量准值 * 缓存并摘掉 pending,错位被永久钉死,只能靠改字号(键变了)才解得开。 * ③ 已固化的卡片永不复核:字体度量变化、外层容器改动引起的位移不会触发重新量测; * 而 remeasure 预算上限只有 7 次且不重置,早期事件耗尽后卡片彻底失去自愈能力。 * ④ 首帧量测没有下限保护:字母盒那侧量不到时会退回盒左缘,尚未布局的 0 宽盒算出天文残差。 * ⑤ 反馈态起手现量:刷新一张「此刻已显示答案」的题,卡片一上来就是反馈态(选项区多出 * 状态节点、字母盒被整体推右),此时照常量残差就会把题干一起推右——刷新后错位的最后一环。 * 修法:① 复用键加入「字体度量指纹」,并引入「强制重排世代号」——force 一次作废全部缓存; * ② 连续两次独立量测都收敛、且值一致才算量准,只收敛一次则继续 pending 复量; * ③ 未作答的卡片走廉价残差自检(误差 ≥0.75px 才介入),发现漂移当场重排; * 已作答(反馈态)一律只复用不重量,保住 v1.1.1「不再把题干推右」的成果; * ④ remeasure 改为「20 次 / 8 秒窗口」,且一次全量排版干净收尾即重置预算; * ⑤ 首个选项字母量不到文字时不再拿 0 顶替;反馈态且自身无缓存的卡片, * 直接借「同键已量准值」(同页卡片 / 上次访问落盘,见 __fpTsStemFix 共享表), * 绝不拿反馈态那副被推歪的基准去现量。 * 自检口子:面板底部状态行会显示「对齐待复量 N」,N>0 表示还有卡片没量准(正常应为 0); * 需要更细的现场时,控制台执行 __fpTsDebug() 可打印每张卡的补偿值 / 复用键 / 当前残差 / 是否反馈态。 * * 刷新后错位不再自愈的真因与修法(v1.1.6): * 实机证据:卡上 pending=false、hits=0(即「已量准」),fixStyle = cached = 16.79, * 但此刻残差 = -21px;一改字号/行高(键变化)立刻全页对齐。也就是说卡上挂着一个 * 「键没变、值已失效」的固化值,而反馈态又免自检 → 错位永久钉死。 * 值为什么会失效:补偿量本身有两个基准——作答前选项行带圆圈/占位节点,显示答案后它们被 * 替换、字母盒整体挪位(实测同页差 ~21px)。而 stemFixKey() 里没有「反馈态」这一维, * 于是「加载瞬间还判成未作答」时量出的值被当成对整张卡都成立;等答案渲染出来、字母盒挪位后, * 键仍是同一个、值仍算「已量准」→ 复用分支早退,错位再也不会被复核。 * 修法:① stemFixKey() 编入反馈态(|a0 / |a1)——状态一变键即变,旧值自动作废、必须重量; * ② 反馈态不再免检:残差自检对反馈态同样生效(门槛放宽到 1.5px),发现漂移一律重量; * ③ 漂移/审计修复时打「禁止借值」标(__fpTsNoBorrow),必须现量,不许拿同键旧值回填; * ④ 收尾审计从「只检查」升级为「检查 + 自愈」:干净收尾却仍有卡残差 ≥1.5px, * 就作废其值、挂待复量重扫,每卡最多自愈 4 次(真正改动布局会重置额度); * ⑤ 反馈态量准的值现在也允许写入共享表(键已含状态,不会再污染其它状态),减少刷新后首帧跳动。 * 自检口子:__fpTsDebug() 每行新增「键 / 自愈次数」,共享表键名末尾会出现 |a0 / |a1。 * * 刷新后错位不再自愈的三处缺口(v1.1.5): * 本轮实机排查(Chrome / 解析页,5 张卡全反馈态)拿到两条硬事实:一次刷新后 5 张卡残差都是 0 * (脚本自量与独立像素扫描互证),说明这个 bug 是状态相关的偶发、不是每次必错;同时 * localStorage 里的补偿值共享表是空的(解析页全反馈态,按设计永不写表)——也就是说 * 解析页刷新时每张卡都只能靠反馈态现场量,此前那套「借值」通道在解析页是彻底空的。 * 顺着这条线查出三处会让错位永久固化的缺口: * ① 补偿值被框架抹掉却没有补写通道:框架可能只重置卡片根节点的 style(类名还在), * markersIntact 会把这种卡判成「自己人」→ handleCard 早退(反馈态更甚,连漂移自检都跳过) * → 缓存里有值、--fp-ts-stemfix 已不在,题干永远回到未补偿位置,刷新也不一定好。 * ② 布局没真正排出来时的量测:后台标签页 / 窗口最小化 / 恢复显示的瞬间 / 首帧零宽, * 这时量到的左缘没有意义;旧逻辑照样会量、还会被「两次收敛」错当成量准值钉死。 * ③ 预算用尽后没有重新给机会的通道:失败若发生在 remeasure 预算耗尽之后(或卡片一直停在 * pending),此后页面不再产生任何事件,卡片就永久停在未补偿状态。 * 修法:① 每次早退前核对补偿值是否真在 style 里,不在场就补写,键已过时则清缓存重量; * ② 新增 layoutSane 门槛:视口或卡片没排出来时拒绝量测、挂待复量,绝不固化; * ③ 回前台 / 获焦 / 系统字体就绪 / 加载后 60 秒内每 2 秒兜底扫描,重开预算重扫未量准的卡; * 自检口子:状态行显示「错位 N / 已修 N / 自愈 N」;__fpTsDebug() 展示视口、可见性与逐卡量测现场。 */ (function () { 'use strict'; /* ================================================================== * 0. 常量:排版基准值取自《粉笔试卷排版打印》,不另创一套 * ================================================================ */ var VERSION = '1.1.8'; var STYLE_ID = 'fp-ts-style'; // 排版样式(停用时整体失效) var PANEL_STYLE_ID = 'fp-ts-panel-style'; // 面板样式(不受停用影响) var PANEL_ID = 'fp-ts-panel'; var STORE_KEY = 'fenbi_inpage_typeset_v1'; /* 正文宋体链(按平台逐个命中,写全各端真实字体名): * Windows SimSun / 宋体 * macOS STSong / Songti SC * iOS/iPad Songti SC(STSongti-SC-Regular) * Android 多数 ROM 不带中文衬线 → 交给下面的联网补字(思源宋体 / Noto Serif SC) * 浏览器是按字形逐个回退的:本机能出宋体就绝不会去下载网络字体。 */ var FONT_CHAIN = '"SimSun","宋体","STSong","Songti SC","STSongti-SC-Regular","Noto Serif CJK SC",' + '"Noto Serif SC","Source Han Serif SC","Source Han Serif CN","Source Han Serif SC VF","思源宋体",serif'; // 题号字体:与原脚本 .fp-num 一致,Times New Roman 打头 var NUM_FONT = '"Times New Roman",' + FONT_CHAIN; // 已知的黑体链:只用于「到底命中宋体没有」的自检对照 var SANS_CHAIN = '"PingFang SC","Microsoft YaHei","Noto Sans CJK SC","Heiti SC",sans-serif'; /* 联网补字:本机没有宋体时按顺序试这些源,前一个失败(被墙 / 404 / 超时)自动换下一个。 * 前三个是同一份 Noto Serif SC(思源宋体简中版)的 Google Fonts 及其国内可用镜像, * 后两个是「中文网字计划」打包的思源宋体可变字体,走 jsDelivr。 * 这些 CSS 都切成上千个 unicode-range 分片,浏览器只下载页面真正用到的字,不是整包几 MB。 */ var WEBFONT_PROVIDERS = [ { family: 'Noto Serif SC', url: 'https://fonts.googleapis.cn/css2?family=Noto+Serif+SC:wght@400;700&display=swap' }, { family: 'Noto Serif SC', url: 'https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700&display=swap' }, { family: 'Noto Serif SC', url: 'https://fonts.loli.net/css2?family=Noto+Serif+SC:wght@400;700&display=swap' }, { family: 'Source Han Serif SC VF', url: 'https://cdn.jsdelivr.net/npm/cn-fontsource-source-han-serif-sc-vf/font.css' }, { family: 'Source Han Serif SC VF', url: 'https://fastly.jsdelivr.net/npm/cn-fontsource-source-han-serif-sc-vf/font.css' } ]; var WEBFONT_TIMEOUT = 5000; // 单个源的最长等待(ms),超时即换源 var HANG_FALLBACK = 2.0; // 题号悬挂宽度(em),固定值,容纳三位数题号 var O_HANG_FALLBACK = 1.0; // 选项字母列宽(em):字母与内容间距的唯一定量,调小=更近 var COL_GAP = 18; // 选项列间距(px),与 CSS column-gap 一致 var OPT_IMG_W = 140; // 选项图限幅宽(px) var OPT_IMG_H = 100; // 选项图限幅高(px) var TEX_SMALL_W = 70; // 无 width 属性的小图(行内公式图)估算宽(px) var NUM_SLACK = 6; // 列宽余量(px),避免卡边界时刚好换行 var LIMITS = { fontSize: [10, 30], lineHeight: [1.2, 2.4], qSpacing: [0, 120] }; var DEFAULTS = { enabled: true, fontSize: 24, lineHeight: 1.6, cols: 'auto', // 恢复默认时列数自动 qSpacing: 18, webFont: true // 本机缺宋体时联网补一份思源宋体(安卓平板 / 部分 iPad 浏览器需要) }; /* ================================================================== * 1. 小工具 * ================================================================ */ function $$(sel, root) { var scope = root || document; return Array.prototype.slice.call(scope.querySelectorAll(sel)); } function num(v, def, key) { var n = parseFloat(v); if (!isFinite(n)) return def; var range = LIMITS[key]; if (!range) return n; return Math.min(range[1], Math.max(range[0], n)); } function addClass(el, cls) { if (el && !el.classList.contains(cls)) el.classList.add(cls); } // 只写不同值:避免无谓的 DOM 写入触发自身的 MutationObserver 循环 function setVar(el, name, value) { if (!el) return; if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value); } function setAttr(el, name, value) { if (el && el.getAttribute(name) !== value) el.setAttribute(name, value); } // 文本宽度(px):canvas 实测(Times New Roman 打头) // 环境无 canvas(少数内嵌 webview / 无头环境)时返回 0,选项列宽估算随之保守 var _ctx = null; function textWidthPx(s, fs, family) { if (!s || _ctx === false) return 0; try { if (!_ctx) _ctx = document.createElement('canvas').getContext('2d'); if (!_ctx || typeof _ctx.measureText !== 'function') { _ctx = false; return 0; } _ctx.font = fs + 'px ' + (family || NUM_FONT); return _ctx.measureText(s).width || 0; } catch (e) { _ctx = false; return 0; } } /* ================================================================== * 2. 设置持久化 * ================================================================ */ var cfg = {}; var cfgStamp = 0; // 设置每变一次 +1,参与卡片处理签名 function readSettings() { var s = {}, raw = {}; try { raw = JSON.parse(localStorage.getItem(STORE_KEY) || '{}') || {}; } catch (e) { raw = {}; } Object.keys(DEFAULTS).forEach(function (k) { if (!(k in raw)) raw[k] = DEFAULTS[k]; }); s.enabled = !!raw.enabled; s.fontSize = num(raw.fontSize, DEFAULTS.fontSize, 'fontSize'); s.lineHeight = num(raw.lineHeight, DEFAULTS.lineHeight, 'lineHeight'); s.qSpacing = num(raw.qSpacing, DEFAULTS.qSpacing, 'qSpacing'); s.cols = ['auto', '1', '2', '4'].indexOf(String(raw.cols)) >= 0 ? String(raw.cols) : DEFAULTS.cols; s.webFont = ('webFont' in raw) ? !!raw.webFont : DEFAULTS.webFont; return s; } function saveSettings() { try { localStorage.setItem(STORE_KEY, JSON.stringify(cfg)); } catch (e) { /* 隐私模式等忽略 */ } } /* ================================================================== * 3. 排版样式:CSS 变量驱动(停用时整体失效,不改页面原生内联样式) * ================================================================ */ var CSS = [ ':root{', ' --fp-ts-fs:15px;', ' --fp-ts-lh:1.6;', ' --fp-ts-hang:2em;', ' --fp-ts-ohang:1em;', ' --fp-ts-optindent:1cm;', ' --fp-ts-colgap:18px;', ' --fp-ts-rowgap:.5em;', ' --fp-ts-optgap:.3em;', ' --fp-ts-qgap:18px;', ' --fp-ts-optw:100%;', ' --fp-ts-font:' + FONT_CHAIN + ';', '}', /* -------- 卡片本体:只吃「题与题之间」的间距 -------- */ '[data-fp-ts]{margin-bottom:var(--fp-ts-qgap)!important}', /* -------- 字体:只作用于题干/材料/选项文字,不碰工具条与按钮 -------- */ '[data-fp-ts] .fp-ts-stem,', '[data-fp-ts] .fp-ts-mat,', '[data-fp-ts] .fp-ts-opt,', '[data-fp-ts] .fp-ts-stem :not(img):not(svg):not(canvas):not(button):not(input):not(select):not(textarea):not([class*=icon]):not(.fp-ts-num),', '[data-fp-ts] .fp-ts-mat :not(img):not(svg):not(canvas):not(button):not(input):not([class*=icon]),', '[data-fp-ts] .fp-ts-opt :not(img):not(svg):not(canvas):not(button):not(input):not([class*=icon]):not(.fp-ts-ol){', ' font-family:var(--fp-ts-font)!important;', ' font-size:var(--fp-ts-fs)!important;', ' line-height:var(--fp-ts-lh)!important;', '}', /* -------- 题干容器:悬挂列(题号占道,正文各行统一左对齐) -------- */ '[data-fp-ts] .fp-ts-stem{', ' display:block!important;', ' box-sizing:border-box!important;', ' max-width:100%!important;', /* v1.9.4:悬挂列 = 题号列宽(--fp-ts-hang) + 实测对齐补偿(--fp-ts-stemfix), 补偿量由 JS 量「题干首个文字左边缘 vs 第一个选项字母左边缘」的像素残差写入, 使题干正文左边缘与 ABCD 字母左边缘严格落在同一条竖线上(此前题干整体偏右)。 */ ' padding-left:calc(var(--fp-ts-hang) + var(--fp-ts-stemfix,0px))!important;', ' margin-left:0!important;', ' margin-right:0!important;', ' margin-bottom:.4em!important;', '}', /* -------- 题干/材料段落:首行缩进 2em、justify、段间距 .5em -------- 只给「叶子块」加缩进(:not(:has(块级子元素))),块级容器一律去缩进, 避免原生页面里 p/div 套娃时父子缩进叠加成 4em、6em。 */ '[data-fp-ts] .fp-ts-stem :is(p,div,li):not(.fp-ts-np):not(.fp-ts-opt):not(.fp-ts-opts):not([style*=center]):not([style*=right]):not(:has(p,div,li)),', '[data-fp-ts] .fp-ts-mat :is(p,div,li):not(.fp-ts-np):not(.fp-ts-opt):not(.fp-ts-opts):not([style*=center]):not([style*=right]):not(:has(p,div,li)){', ' margin:0 0 .5em!important;', ' padding:0!important;', ' text-indent:2em!important;', ' text-align:justify!important;', ' text-align-last:left!important;', ' line-height:var(--fp-ts-lh)!important;', '}', /* 容器块:保留行高,但绝不吃缩进,也不额外加段间距 */ '[data-fp-ts] .fp-ts-stem :is(p,div,li):has(p,div,li):not(.fp-ts-opt):not(.fp-ts-opts),', '[data-fp-ts] .fp-ts-mat :is(p,div,li):has(p,div,li):not(.fp-ts-opt):not(.fp-ts-opts){', ' text-indent:0!important;', ' line-height:var(--fp-ts-lh)!important;', '}', /* 页面原本就居中/居右的段落:不吃首行缩进 */ '[data-fp-ts] .fp-ts-stem :is(p,div,li)[style*=center],', '[data-fp-ts] .fp-ts-stem :is(p,div,li)[style*=right],', '[data-fp-ts] .fp-ts-mat :is(p,div,li)[style*=center],', '[data-fp-ts] .fp-ts-mat :is(p,div,li)[style*=right]{text-indent:0!important}', /* 题号所在的那一段:题号本身就是行首标记,不叠首行缩进(对应原脚本 .fp-first) */ '[data-fp-ts] .fp-ts-stem .fp-ts-np{', ' margin:0 0 .5em!important;', ' padding:0!important;', ' text-indent:0!important;', ' text-align:left!important;', ' line-height:var(--fp-ts-lh)!important;', '}', /* -------- 题号悬挂:float + 负边距顶到正文左边界外 -------- */ '[data-fp-ts] .fp-ts-num{', ' float:left!important;', ' width:var(--fp-ts-hang)!important;', ' margin:0 0 0 calc(-1 * var(--fp-ts-hang))!important;', ' padding:0!important;', ' text-indent:0!important;', ' text-align:left!important;', ' font-family:' + NUM_FONT + '!important;', ' font-size:var(--fp-ts-fs)!important;', ' line-height:var(--fp-ts-lh)!important;', ' font-weight:400!important;', ' color:inherit!important;', ' background:none!important;', ' border:0!important;', ' box-shadow:none!important;', ' white-space:nowrap!important;', '}', /* 题号与题干不在同一个盒子(粉笔常见的「标题行 + 正文块」结构)时的兜底: 标题行塌成 0 高、溢出可见,题号自然落到题干第一行的左侧栏里。 只在 JS 判定「该行只有题号、没有别的文字」时才加这个标记。 */ '[data-fp-ts][data-fp-ts-num=row] .fp-ts-numrow{', ' height:0!important;min-height:0!important;max-height:0!important;', ' margin:0!important;padding:0!important;border:0!important;background:none!important;', ' overflow:visible!important;', '}', '[data-fp-ts][data-fp-ts-num=row] .fp-ts-numrow .fp-ts-num{', ' float:none!important;display:block!important;', ' width:var(--fp-ts-hang)!important;margin:0!important;', '}', /* 标题行里除了题号还有别的内容(题型标签等)→ 不动行高,题号只做成定宽块 */ '[data-fp-ts][data-fp-ts-num=keep] .fp-ts-num{', ' float:none!important;display:inline-block!important;', ' width:var(--fp-ts-hang)!important;margin:0!important;', '}', /* -------- 选项组:flex 换行,列距 18px / 行距 .5em / 上距 .3em -------- 原生日志样式(display:block / 带 !important 的覆盖)在这里一律用 !important 压回 row+wrap;JS 另外还会把同一组声明写成元素内联 !important(内联 + !important 是层叠里的最高优先级,任何页面样式表都压不掉)。 */ '[data-fp-ts] .fp-ts-opts{', ' display:flex!important;', ' flex-direction:row!important;', ' flex-wrap:wrap!important;', ' align-items:flex-start!important;', ' justify-content:flex-start!important;', ' column-gap:var(--fp-ts-colgap)!important;', ' row-gap:var(--fp-ts-rowgap)!important;', ' margin:var(--fp-ts-optgap) 0 0 0!important;', ' padding:0!important;', ' list-style:none!important;', ' width:100%!important;', ' max-width:100%!important;', ' box-sizing:border-box!important;', ' background:none!important;', ' border:0!important;', ' float:none!important;', ' text-indent:0!important;', '}', /* 容器原生是 ul/ol 时:清掉列表符号与默认缩进(含 -webkit-padding-start) */ '[data-fp-ts] ul.fp-ts-opts,', '[data-fp-ts] ol.fp-ts-opts{', ' list-style:none!important;', ' padding-left:0!important;', ' margin-left:0!important;', '}', /* 选项行(容器直接子节点):不给内生宽度套路留任何机会 */ '[data-fp-ts] .fp-ts-opts>*{min-width:0!important;box-sizing:border-box!important}', /* 同一道题列数一致:JS 在卡片上写 data-fp-ts-cols,整套列宽公式与原脚本 grid-N 等价 */ '[data-fp-ts][data-fp-ts-cols="4"] .fp-ts-opts{--fp-ts-optw:calc((100% - var(--fp-ts-colgap) * 3) / 4)}', '[data-fp-ts][data-fp-ts-cols="2"] .fp-ts-opts{--fp-ts-optw:calc((100% - var(--fp-ts-colgap)) / 2)}', '[data-fp-ts][data-fp-ts-cols="1"] .fp-ts-opts{--fp-ts-optw:100%}', '[data-fp-ts] .fp-ts-opts .fp-ts-opt{', ' display:block!important;', ' flex:0 0 var(--fp-ts-optw,100%)!important;', ' flex-grow:0!important;', ' flex-shrink:0!important;', ' flex-basis:var(--fp-ts-optw,100%)!important;', ' width:var(--fp-ts-optw,100%)!important;', ' max-width:var(--fp-ts-optw,100%)!important;', ' min-width:0!important;', ' box-sizing:border-box!important;', ' float:none!important;', ' clear:none!important;', ' margin:0!important;', ' padding-left:calc(var(--fp-ts-ohang) + var(--fp-ts-optindent))!important;', ' list-style:none!important;', ' text-indent:0!important;', ' line-height:var(--fp-ts-lh)!important;', ' word-break:normal!important;', ' overflow-wrap:break-word!important;', ' break-inside:avoid!important;page-break-inside:avoid!important;', '}', /* 选项字母:定宽列(宽 = 父级 padding-left)+ 负 margin 自身悬挂: 字母贴 li 左边界、正文紧接其后;字体/字号/行高与内容完全一致,基线自然对齐。 位置两条硬约束(v1.9.2): ① 竖直:原生把字母盒的父级做成了 flex 居中,vertical-align 会被父级的 align-items 吃掉 (所以前几版改 vertical-align 都「没变化」)。这里用 align-self:flex-start 从子项侧 强制顶对齐,再用上边距 = 行盒半行距 (行高-1)/2 em 把字母盒压到与正文首行同一基线。 ② 水平:text-align:left 让字母靠盒左缘;DOM 里字母后常带一个空格,用负 word-spacing 回收它——-.25em 只吃掉大半格,句号停在字母右侧一点点,后面仍留呼吸位(v1.9.3)。 */ '[data-fp-ts] .fp-ts-opt .fp-ts-ol{', ' float:none!important;', ' display:inline-block!important;', ' align-self:flex-start!important;', ' width:var(--fp-ts-ohang)!important;', ' min-width:0!important;', ' max-width:100%!important;', ' margin:calc((var(--fp-ts-lh) - 1) * .5em) 1px 0 calc(-1 * var(--fp-ts-ohang))!important;', ' padding:0!important;', ' box-sizing:border-box!important;', ' height:var(--fp-ts-lh)!important;', ' font-family:var(--fp-ts-font)!important;', ' font-size:var(--fp-ts-fs)!important;', ' font-weight:400!important;', ' line-height:1!important;', ' color:inherit!important;', ' text-align:left!important;', ' text-indent:0!important;', ' white-space:nowrap!important;', ' word-spacing:-.25em!important;', ' vertical-align:top!important;', ' position:static!important;', ' top:auto!important;', ' left:auto!important;', ' transform:none!important;', ' overflow:visible!important;', '}', /* 小圆圈:app-choice-radio 是伪元素空圈(display:none),.input-radio/.input-check 是字母盒只去外观 */ '[data-fp-ts] .fp-ts-opts app-choice-radio{', ' display:none!important;', '}', /* 字母盒:去边框圆角底色阴影,保留字母本体 */ '[data-fp-ts] .fp-ts-opts .input-radio,', '[data-fp-ts] .fp-ts-opts .input-check{', ' border:0!important;', ' border-radius:0!important;', ' background:transparent!important;', ' background-image:none!important;', ' box-shadow:none!important;', ' outline:none!important;', '}', /* 圈的另一来源:原生 radio/checkbox 控件本体,直接抹掉 */ '[data-fp-ts] .fp-ts-opts input[type="radio"],', '[data-fp-ts] .fp-ts-opts input[type="checkbox"]{', ' -webkit-appearance:none!important;', ' appearance:none!important;', ' display:none!important;', ' width:0!important;', ' height:0!important;', ' margin:0!important;', ' padding:0!important;', '}', /* 圈的另一来源:选项行/字母盒上的伪元素装饰圈 */ '[data-fp-ts] .fp-ts-opt::before,', '[data-fp-ts] .fp-ts-opt::after,', '[data-fp-ts] .fp-ts-opt .input-radio::before,', '[data-fp-ts] .fp-ts-opt .input-radio::after,', '[data-fp-ts] .fp-ts-opt .input-check::before,', '[data-fp-ts] .fp-ts-opt .input-check::after,', '[data-fp-ts] .fp-ts-opt label::before,', '[data-fp-ts] .fp-ts-opt label::after,', '[data-fp-ts] .fp-ts-opt li::before,', '[data-fp-ts] .fp-ts-opt li::after,', '[data-fp-ts] .fp-ts-opt .choice-radio-label::before,', '[data-fp-ts] .fp-ts-opt .choice-radio-label::after{', ' content:none!important;', ' display:none!important;', ' border:0!important;', ' background:none!important;', ' width:0!important;', ' height:0!important;', '}', /* 去底后字母改随正文色;对错态按粉笔语义还原: .correct=选对、.correctLost=正确答案但未选(绿);.wrong=选错(红) */ '[data-fp-ts] .fp-ts-opt .fp-ts-ol{ color:inherit!important; }', '[data-fp-ts] .fp-ts-opt .fp-ts-ol.correct,', '[data-fp-ts] .fp-ts-opt .fp-ts-ol.correctLost{ color:#16a34a!important; }', '[data-fp-ts] .fp-ts-opt .fp-ts-ol.wrong{ color:#dc2626!important; }', /* 状态类落到选项行(li/wrapper)而不是字母盒时的兼容: 选对/正确未选 → 绿;选错 → 红。字母盒本身没状态时按行上的状态着色 */ '[data-fp-ts] .fp-ts-opt.correct .fp-ts-ol,', '[data-fp-ts] .fp-ts-opt.correctLost .fp-ts-ol{ color:#16a34a!important; }', '[data-fp-ts] .fp-ts-opt.wrong .fp-ts-ol{ color:#dc2626!important; }', /* 字母后补英文句号(样式层注入,不改 DOM 文本):显示为 A. B. C. D.。 放在上面 content:none 兜底之后,源序在后即可覆盖该 !important。 */ '[data-fp-ts] .fp-ts-opt .fp-ts-ol::after{', ' content:"."!important;', ' display:inline!important;', ' border:0!important;', ' background:none!important;', ' width:auto!important;', ' height:auto!important;', ' margin:0!important;', ' padding:0!important;', ' color:inherit!important;', ' font:inherit!important;', '}', /* 选项内部的块级外壳行内化:让「字母 + 内容」待在同一个行内流。 带图/表格的分支不参与(:has 守卫),免得把图片表格压扁。 */ '[data-fp-ts] .fp-ts-opt :is(div,p,span,section,article,label,app-format-html,app-choice-radio)', ':not(.fp-ts-ol):not(:has(img,table,svg,canvas,ul,ol)){', ' display:inline!important;', ' margin:0!important;', ' padding:0!important;', ' text-indent:0!important;', '}', /* -------- 图片:题干图限宽居中,选项图等比限幅 140x100 -------- */ '[data-fp-ts] .fp-ts-stem img,', '[data-fp-ts] .fp-ts-mat img{', ' max-width:100%!important;height:auto!important;', ' vertical-align:middle!important;object-fit:contain!important;', '}', '[data-fp-ts] .fp-ts-opt img{', ' max-width:' + OPT_IMG_W + 'px!important;', ' max-height:' + OPT_IMG_H + 'px!important;', ' height:auto!important;', ' vertical-align:middle!important;', ' object-fit:contain!important;', '}', /* -------- 表格:宽 100%、1px 边框、居中、字号比正文小 1px -------- */ '[data-fp-ts] table{width:100%!important;border-collapse:collapse!important;margin:14px 0!important}', '[data-fp-ts] th,', '[data-fp-ts] td{', ' border:1px solid #333!important;', ' padding:7px 9px!important;', ' text-align:center!important;', ' font-size:calc(var(--fp-ts-fs) - 1px)!important;', ' line-height:1.5!important;', '}', '[data-fp-ts] th{background:#f4f4f4!important;font-weight:700!important}', '[data-fp-ts] .fp-ts-stem img,', '[data-fp-ts] .fp-ts-mat img,', '[data-fp-ts] .fp-ts-opt img{border:0!important;box-shadow:none!important;background:transparent!important}' ].join('\n'); /* ================================================================== * 4. 参数面板样式(与排版样式分离,停用排版后仍可操作面板) * ================================================================ */ var PANEL_CSS = [ '#' + PANEL_ID + '{', ' position:fixed;right:16px;bottom:16px;z-index:2147483000;width:250px;', ' font:12px/1.5 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;', ' color:#0f172a;background:rgba(255,255,255,.97);border:1px solid #d5dae3;', ' border-radius:10px;box-shadow:0 6px 20px rgba(15,23,42,.18);overflow:hidden;', '}', '#' + PANEL_ID + ' .fp-ts-head{display:flex;align-items:center;justify-content:space-between;', ' padding:8px 10px;background:#f1f4f9;border-bottom:1px solid #e2e6ee;', ' font-weight:600;color:#2563eb}', '#' + PANEL_ID + ' .fp-ts-fold{width:22px;height:22px;padding:0;line-height:1;', ' border:1px solid #cbd5e1;border-radius:6px;background:#fff;cursor:pointer;color:#475569;font-size:14px}', '#' + PANEL_ID + ' .fp-ts-body{padding:8px 10px 10px}', '#' + PANEL_ID + '.fp-ts-collapsed .fp-ts-body{display:none}', '#' + PANEL_ID + ' .fp-ts-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 7px}', '#' + PANEL_ID + ' .fp-ts-row>span{color:#475569;white-space:nowrap}', '#' + PANEL_ID + ' .fp-ts-row input[type=number],#' + PANEL_ID + ' .fp-ts-row select{', ' width:100px;padding:3px 6px;border:1px solid #cbd5e1;border-radius:6px;', ' font-size:12px;background:#fff;color:#0f172a;box-sizing:border-box}', '#' + PANEL_ID + ' .fp-ts-row input[type=checkbox]{width:14px;height:14px;margin:0}', '#' + PANEL_ID + ' .fp-ts-stepbox{display:flex;align-items:center;gap:3px;width:100px}', '#' + PANEL_ID + ' .fp-ts-stepbtn{width:24px;height:22px;padding:0;flex:0 0 auto;', ' border:1px solid #cbd5e1;border-radius:6px;background:#fff;cursor:pointer;', ' font-size:13px;line-height:1;color:#334155;box-sizing:border-box}', '#' + PANEL_ID + ' .fp-ts-stepbtn:hover:not(:disabled){background:#eff6ff;border-color:#93c5fd}', '#' + PANEL_ID + ' .fp-ts-stepbtn:active:not(:disabled){background:#dbeafe}', '#' + PANEL_ID + ' .fp-ts-stepbtn:disabled{background:#f1f5f9;color:#cbd5e1;border-color:#e2e8f0;cursor:default}', '#' + PANEL_ID + ' .fp-ts-stepval{flex:1 1 auto;text-align:center;font-size:12px;', ' color:#0f172a;font-variant-numeric:tabular-nums;user-select:none}', '#' + PANEL_ID + ' .fp-ts-stepbox-off .fp-ts-stepval{color:#94a3b8}', '#' + PANEL_ID + ' .fp-ts-sub{padding-left:12px}', '#' + PANEL_ID + ' .fp-ts-row input:disabled{background:#f1f5f9;color:#94a3b8}', '#' + PANEL_ID + ' .fp-ts-btns{display:flex;gap:8px;margin:2px 0 6px}', '#' + PANEL_ID + ' .fp-ts-btns button{flex:1;padding:4px 0;border:1px solid #cbd5e1;', ' border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#334155}', '#' + PANEL_ID + ' .fp-ts-btns button:hover{background:#f1f5f9}', '#' + PANEL_ID + ' .fp-ts-status{color:#64748b;font-size:11px;line-height:1.5;word-break:break-all}' ].join('\n'); function ensureStyle(id, text) { var el = document.getElementById(id); if (!el) { el = document.createElement('style'); el.id = id; el.textContent = text; (document.head || document.documentElement).appendChild(el); } else if (el.textContent !== text) { el.textContent = text; } return el; } var styleEl = null; function injectStyles() { styleEl = ensureStyle(STYLE_ID, CSS); ensureStyle(PANEL_STYLE_ID, PANEL_CSS); } // 把当前设置写到 :root 的 CSS 变量上(变量驱动,不往页面元素上写内联样式) function pushRootVars() { var r = document.documentElement.style; var map = { '--fp-ts-fs': cfg.fontSize + 'px', '--fp-ts-lh': String(cfg.lineHeight), '--fp-ts-hang': HANG_FALLBACK + 'em', '--fp-ts-ohang': O_HANG_FALLBACK + 'em', '--fp-ts-colgap': COL_GAP + 'px', '--fp-ts-rowgap': '.5em', '--fp-ts-optgap': '.3em', '--fp-ts-qgap': cfg.qSpacing + 'px', '--fp-ts-font': FONT_CHAIN }; Object.keys(map).forEach(function (k) { if (r.getPropertyValue(k) !== map[k]) r.setProperty(k, map[k]); }); } var ROOT_VARS = ['--fp-ts-fs', '--fp-ts-lh', '--fp-ts-hang', '--fp-ts-ohang', '--fp-ts-colgap', '--fp-ts-rowgap', '--fp-ts-optgap', '--fp-ts-qgap', '--fp-ts-font']; // 停用时把写在 内联样式上的变量也一并摘掉,做到「完全还原」 function clearRootVars() { var r = document.documentElement.style; ROOT_VARS.forEach(function (k) { if (r.getPropertyValue(k)) r.removeProperty(k); }); } /* ================================================================== * 4b. 联网补字:本机缺宋体时(安卓平板、iPad、部分国产浏览器)自动补一份思源宋体 * 思路是「本机优先,缺了才下载」——字体链里本机宋体名排在网络字体前面, * 本机出得来宋体就不会触发下载;只有整条链都没衬线时,浏览器才会去取网络字体。 * ================================================================ */ var webFontState = 'idle'; // idle | loading | ok | failed | off var webFontFamily = ''; function hasWebFontLink() { return !!document.querySelector('link[data-fp-ts-webfont]'); } function setupWebFont() { if (!cfg.webFont) { webFontState = 'off'; refreshFontStatus(); return; } if (hasWebFontLink() && webFontState === 'ok') { refreshFontStatus(); return; } webFontState = 'loading'; refreshFontStatus(); tryProvider(0); } function tryProvider(i) { if (i >= WEBFONT_PROVIDERS.length) { webFontState = 'failed'; refreshFontStatus(); return; } var p = WEBFONT_PROVIDERS[i]; var link = document.createElement('link'); link.rel = 'stylesheet'; link.setAttribute('data-fp-ts-webfont', '1'); link.href = p.url; var settled = false; var timer = setTimeout(function () { // 被墙 / 一直挂着:不等了,换下一个源 if (settled) return; settled = true; link.remove(); tryProvider(i + 1); }, WEBFONT_TIMEOUT); link.onerror = function () { if (settled) return; settled = true; clearTimeout(timer); link.remove(); tryProvider(i + 1); }; link.onload = function () { if (settled) return; settled = true; clearTimeout(timer); verifyProvider(p, i); // 链接进来还不算数,得真能取到字形 }; (document.head || document.documentElement).appendChild(link); } // 用 Font Loading API 实探:这份字体真的能加载出汉字字形吗?不能就换源 function verifyProvider(p, i) { if (!document.fonts || typeof document.fonts.load !== 'function') { webFontState = 'ok'; // 老浏览器无法探测:样式已进来,按可用计 webFontFamily = p.family; onWebFontReady(); return; } document.fonts.load('15px "' + p.family + '"', '宋体测试题目').then(function (faces) { if (faces && faces.length) { webFontState = 'ok'; webFontFamily = p.family; onWebFontReady(); } else { tryProvider(i + 1); } }, function () { tryProvider(i + 1); }); } function onWebFontReady() { refreshFontStatus(); // 字体换了 → 字宽变了 → 列宽判定与题号悬挂都要按新度量重算一次 if (cfg.enabled) applyAll(true); } function clearWebFontLinks() { $$('link[data-fp-ts-webfont]').forEach(function (n) { n.remove(); }); } function setWebFont(on) { cfg.webFont = !!on; cfgStamp++; saveSettings(); clearWebFontLinks(); if (on) { setupWebFont(); } else { webFontState = 'off'; refreshFontStatus(); if (cfg.enabled) applyAll(true); } } // 自检:同一串汉字分别用「宋体链」和「已知黑体链」量宽度。 // 宽度不同 = 命中了衬线字体;完全相同 = 大概率两串都落到了系统默认黑体(即没宋体)。 function serifHit() { var w1 = textWidthPx('国题', 100, FONT_CHAIN); var w2 = textWidthPx('国题', 100, SANS_CHAIN); if (!w1 || !w2) return null; // canvas 不可用 → 不猜 return Math.abs(w1 - w2) > 0.5; } function fontSourceText() { if (webFontState === 'ok') return '思源宋体(联网补字·' + webFontFamily + ')'; if (webFontState === 'loading') return '本机字体(正在尝试联网补字…)'; if (webFontState === 'failed') return '本机字体(联网补字失败,可换网络后重排)'; if (webFontState === 'off') return '本机字体(已关闭联网补字)'; return '本机字体'; } // 字体状态单独一行显示,不和「已排版 N 道题」互相覆盖 function refreshFontStatus() { if (!fontStatusEl) return; if (!cfg.enabled) { fontStatusEl.textContent = '字体:已停用'; return; } var hit = serifHit(); fontStatusEl.textContent = '字体:' + fontSourceText() + ' · ' + (hit === null ? '无法自检' : (hit ? '已命中宋体' : '未命中宋体(回退黑体)')); } /* ================================================================== * 5. 探测:卡片 / 题干 / 题号 / 选项(辨认不出的卡片一律安全跳过) * ================================================================ */ var CARD_SELECTORS = ['app-ti', '.ti-item', '.question-item']; function findCards() { var out = []; CARD_SELECTORS.forEach(function (sel) { $$(sel).forEach(function (el) { if (out.some(function (c) { return c === el || c.contains(el); })) return; // 只保留最外层匹配 out.push(el); }); }); return out; } // 题干:取第一个「不在答案区里」的 app-format-html(与 pickStem 同一判定), // 取不到再退到 article.content,再退到卡内第一个 app-format-html function findStem(card) { var stem = null; $$('app-format-html', card).forEach(function (n) { if (!stem && !n.closest('app-result-common')) stem = n; }); if (stem) return stem; stem = card.querySelector('article.content'); if (stem) return stem; return card.querySelector('app-format-html'); } function findNumEl(card) { var el = card.querySelector('.title-index'); if (el) return el; // 兜底:纯数字的短文本节点(排除答案区与交互控件) var cands = $$('span,div,i,em,b,strong', card); for (var i = 0; i < cands.length; i++) { var n = cands[i]; if (n.children.length) continue; if (n.closest('app-result-common,button,a,label')) continue; var t = (n.textContent || '').trim(); if (!/^\d{1,3}\s*[.、.]?$/.test(t)) continue; var w = 0; try { w = n.getBoundingClientRect().width; } catch (e) { w = 0; } if (w >= 0 && w < 60) return n; } return null; } function findOptions(card) { var items = $$('li[class*=choice],.option-item', card); if (items.length < 2) { // 背题页常见结构:li > app-choice-radio(字母与内容都在里面) // 优先把 li 外壳当作选项单元,这样选项组容器才能正确定位到外层 ul var radios = $$('app-choice-radio', card); if (radios.length >= 2) { items = radios.map(function (r) { var p = r.parentElement; return (p && p.tagName === 'LI') ? p : r; }); } } if (items.length < 2) { items = $$('.choice-radio-label', card).map(function (l) { return l.parentElement; }) .filter(function (p) { return !!p && !!p.querySelector('.input-radio,.choice-radio-label'); }); } // 去重 + 只留最外层,避免 li 与嵌在里面的 app-choice-radio 被重复计数 return items.filter(function (el, i) { return items.indexOf(el) === i && !items.some(function (o) { return o !== el && o.contains(el); }); }); } // 选项们最深的共同父节点(不含卡片自身):夹了中间层 wrapper 时,它就是真正的选项组容器 function deepestCommonParent(items, card) { if (!items.length) return null; var p = items[0].parentElement; while (p && p !== card) { var holds = true; for (var i = 1; i < items.length; i++) { if (!p.contains(items[i])) { holds = false; break; } } if (holds) return p; p = p.parentElement; } return null; } // 选项组容器:优先选项们最深的共同父节点;取不到再退到「直接子节点里选项最多」的候选 function findOptGroup(card, items) { if (!items.length) return null; var deep = deepestCommonParent(items, card); if (deep) return deep; var best = null, bestDirect = 0; $$('ul,ol,app-question-choice,app-choice-radio,[class*=choice-container],[class*=choices],[class*=options]', card) .forEach(function (c) { var direct = items.filter(function (o) { return o.parentElement === c; }).length; if (direct < 2) return; if (direct > bestDirect || (direct === bestDirect && best && c.contains(best))) { best = c; bestDirect = direct; } }); return best; } // 选项行单元:正常情况就是选项自身;若容器与选项之间还夹着 wrapper(容器的直接子节点里 // 一个选项都没有),向下递归一层,把「只容纳一个选项」的直接子节点认作选项行来打标记。 // 这样既不会把 wrapper 当成选项,也不会让 wrapper 层把 flex 多列压成一列。 function resolveOptUnits(groupEl, items) { var plain = items.map(function (o) { return { el: o, opt: o, wrapped: false }; }); if (!groupEl || !items.length) return plain; var direct = items.filter(function (o) { return o.parentElement === groupEl; }); if (direct.length >= 2) return plain; var units = []; Array.prototype.slice.call(groupEl.children).forEach(function (k) { var inside = items.filter(function (o) { return k.contains(o); }); if (inside.length === 1) units.push({ el: k, opt: inside[0], wrapped: true }); }); return units.length >= 2 ? units : plain; } // 材料块(背题页/申论页可能出现):标题与正文规则同题干,但不吃悬挂 function findMaterials(card) { var mats = $$('.material-content,.material-body', card); if (!mats.length) mats = $$('app-materials .material', card); return mats.filter(function (el, i) { return mats.indexOf(el) === i && !mats.some(function (o) { return o !== el && o.contains(el); }); }); } function probe(card) { var stemEl = findStem(card); var numEl = findNumEl(card); var options = findOptions(card); var groupEl = findOptGroup(card, options); var units = resolveOptUnits(groupEl, options); var matEls = findMaterials(card); return { cardEl: card, stemEl: stemEl, numEl: numEl, options: options, groupEl: groupEl, units: units, matEls: matEls }; } // 题号所在的那一段(直接是 stem 的子块),用于取消该段首行缩进 function numBlock(numEl, stemEl) { if (!numEl || !stemEl) return null; var el = numEl; while (el && el.parentElement && el.parentElement !== stemEl) el = el.parentElement; return (el && el.parentElement === stemEl) ? el : null; } /* ================================================================== * 6. 选项列数:按真实可用宽自适应 4 / 2 / 1(对应原脚本 layoutFor) * ================================================================ */ var needRemeasure = false; // 有卡片首帧量不到可用宽 → 稍后按真实宽度重测一次 // canvas 不可用(少数内嵌 webview / 无头环境)时的兜底估宽:CJK 按 1 字宽,其余按 0.55 function estTextPx(s, fs) { var cjk = 0, other = 0; for (var i = 0; i < s.length; i++) { if (/[\u2e80-\u9fff\uff00-\uffef\u3000-\u303f]/.test(s.charAt(i))) cjk++; else other++; } return cjk * fs + other * fs * 0.55; } // 选项内容自然宽(px):文字实测(口径同原脚本 maxUnits*fontSize + fontSize*2.5), // 图片选项按渲染限幅估算 function optionNeedPx(o, hangPx) { var clone = o.cloneNode(true); $$('.input-radio', clone).forEach(function (e) { e.remove(); }); var txt = (clone.textContent || '').replace(/\s+/g, ' ').trim(); var textW = txt ? textWidthPx(txt, cfg.fontSize, FONT_CHAIN) : 0; if (!textW && txt) textW = estTextPx(txt, cfg.fontSize); var textNeed = txt ? textW + cfg.fontSize * 2.5 : 0; var imgW = 0, hasImg = false; $$('img', o).forEach(function (img) { hasImg = true; var w = parseInt(img.getAttribute('width') || '', 10); var cur = (isFinite(w) && w > 0) ? Math.min(w, OPT_IMG_W) : 0; if (cur > imgW) imgW = cur; }); // 纯图选项且拿不到可读宽度(行内公式小图)→ 按固定值反推,避免被误判成一行一个 if (hasImg && imgW === 0) imgW = TEX_SMALL_W; var imgNeed = hasImg ? (Math.min(imgW, OPT_IMG_W) + hangPx + cfg.fontSize * 0.5) : 0; return Math.max(textNeed, imgNeed, NUM_SLACK); } // 可用宽(px):选项容器 → 题干 → 题目卡片,逐级回退;都量不到返回 0(交给重测) function usableWidthPx(info) { var cands = [info.groupEl, info.stemEl, info.cardEl]; for (var i = 0; i < cands.length; i++) { var el = cands[i]; if (!el) continue; var w = 0; try { w = el.clientWidth || 0; } catch (e) { w = 0; } if (!w) { try { w = Math.round(el.getBoundingClientRect().width) || 0; } catch (e) { w = 0; } } if (w > 0) return w; } return 0; } // 列宽表达式(与原脚本 .fp-opts.grid-N > .fp-opt 的 flex-basis 公式等价) function optWidthExpr(cols) { var k = parseInt(cols, 10) || 1; return k <= 1 ? '100%' : 'calc((100% - ' + (COL_GAP * (k - 1)) + 'px) / ' + k + ')'; } // 列宽实值:量得到可用宽就算成像素(不依赖 calc,量到的就是真实可用宽); // 量不到才退回 calc 表达式,由重测修正 function optWidthCss(info, cols) { var k = parseInt(cols, 10) || 1; var gw = usableWidthPx(info); if (gw) { var avail = gw - 2 * cfg.fontSize; if (avail <= 0) avail = gw; var px = k <= 1 ? avail : (avail - COL_GAP * (k - 1)) / k; if (px > 0) return Math.floor(px) + 'px'; } return optWidthExpr(cols); } function pickCols(info, hangEm) { if (cfg.cols !== 'auto') return String(cfg.cols); // 手动档:用户定死,不再自适应 var n = info.units.length || info.options.length; if (n < 2) return '1'; var maxK = n >= 4 ? 4 : 2; // 选项不足 4 个时不摆 4 列 var hangPx = hangEm * cfg.fontSize; var need = 0; info.units.forEach(function (u) { need = Math.max(need, optionNeedPx(u.opt, hangPx)); }); var gw = usableWidthPx(info); if (!gw) { // 首帧还没布局(clientWidth=0):先按选项数给积极档位并登记重测, // 不要一上来就退回「一行一个」把多列判死 needRemeasure = true; return String(maxK); } var avail = gw - 2 * cfg.fontSize; // 扣掉正文 2em 首行缩进 if (avail <= 0) avail = gw; var list = maxK === 4 ? [4, 2] : [2]; for (var i = 0; i < list.length; i++) { var k = list[i]; var colW = (avail - COL_GAP * (k - 1)) / k; if (need <= colW) return String(k); // 放得下就取更宽的列数(4 → 2) } return '1'; } // 实测校验:标了 N 列但实际仍竖排(原生样式把 flex 压掉、列宽没生效)→ 逐级降档 function countLefts(units) { var seen = {}, measured = 0; units.forEach(function (u) { var r = null; try { r = u.el.getBoundingClientRect(); } catch (e) { r = null; } if (!r || !r.width) return; measured++; seen[Math.round(r.left)] = 1; }); return { lefts: Object.keys(seen).length, measured: measured }; } function verifyCols(info, cols) { if (cfg.cols !== 'auto' || cols === '1') return cols; // 手动档 / 单列:无需校验 if (!info.groupEl) return cols; var gw = 0; try { gw = info.groupEl.clientWidth || 0; } catch (e) { gw = 0; } if (!gw) return cols; // 还没布局,不做判断 var chain = cols === '4' ? ['4', '2'] : ['2']; for (var i = 0; i < chain.length; i++) { var m = countLefts(info.units); if (m.measured < 2) return chain[i]; // 量不到实际尺寸:保持当前档 if (m.lefts >= 2) return chain[i]; // 真的排成了多列 if (i < chain.length - 1) setAttr(info.cardEl, 'data-fp-ts-cols', chain[i + 1]); } setAttr(info.cardEl, 'data-fp-ts-cols', '1'); return '1'; } /* ------------------------------------------------------------------ * 6.1 内联 !important 兜底 * 样式表里的 !important 仍可能被「页面样式表 !important + 更高特异性」改写; * 元素内联样式 + !important 是层叠最高优先级,写上去就没有任何页面样式能压掉。 * 只写列布局相关的属性,还原时按同一属性名单逐个摘除,不碰页面原生内联样式。 * ---------------------------------------------------------------- */ var HARD_PROPS = ['display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis', 'align-items', 'align-self', 'justify-content', 'column-gap', 'row-gap', 'margin', 'margin-top', 'padding', 'padding-left', 'padding-right', 'list-style', 'width', 'max-width', 'min-width', 'box-sizing', 'float', 'clear', 'text-indent', // 字母盒专用:位置/对齐必须从内联层压过原生样式 'text-align', 'word-spacing', 'vertical-align', 'position', 'top', 'left', 'transform']; // 幂等写入:按元素缓存最近写入值,而不是回读 style 来比较。 // 回读会被引擎的规范化/简写展开(padding 拆成 padding-*、var() 值被丢弃)干扰, // 造成同一值反复重写、DOM 抖动;缓存比较可彻底消除这类无谓写入。 function setImp(el, prop, val) { if (!el || !el.style) return; var cache = el.__fpTsImp || (el.__fpTsImp = {}); if (cache[prop] === val) return; cache[prop] = val; el.style.setProperty(prop, val, 'important'); } function unstyledOpts(root) { if (!root) return; $$('.fp-ts-opts,.fp-ts-opt,.fp-ts-ol', root).forEach(function (n) { HARD_PROPS.forEach(function (p) { if (n.style.getPropertyValue(p)) n.style.removeProperty(p); }); n.__fpTsImp = null; // 摘除后清缓存,下次重新排版才会重新写入 }); } function hardenOpts(info, cols) { var g = info.groupEl; if (!g) return; setImp(g, 'display', 'flex'); setImp(g, 'flex-direction', 'row'); setImp(g, 'flex-wrap', 'wrap'); setImp(g, 'align-items', 'flex-start'); setImp(g, 'justify-content', 'flex-start'); setImp(g, 'column-gap', COL_GAP + 'px'); setImp(g, 'row-gap', '.5em'); setImp(g, 'margin', '.3em 0 0 0'); setImp(g, 'padding', '0'); setImp(g, 'list-style', 'none'); setImp(g, 'width', '100%'); setImp(g, 'max-width', '100%'); setImp(g, 'box-sizing', 'border-box'); setImp(g, 'float', 'none'); setImp(g, 'text-indent', '0'); var w = optWidthCss(info, cols); info.units.forEach(function (u) { var el = u.el; setImp(el, 'display', 'block'); setImp(el, 'float', 'none'); setImp(el, 'clear', 'none'); setImp(el, 'flex', '0 0 ' + w); setImp(el, 'flex-grow', '0'); setImp(el, 'flex-shrink', '0'); setImp(el, 'flex-basis', w); setImp(el, 'width', w); setImp(el, 'max-width', w); setImp(el, 'min-width', '0'); setImp(el, 'box-sizing', 'border-box'); setImp(el, 'margin', '0'); setImp(el, 'padding-left', 'calc(var(--fp-ts-ohang) + var(--fp-ts-optindent))'); setImp(el, 'list-style', 'none'); setImp(el, 'text-indent', '0'); // 字母盒:位置与对齐写进内联 !important。原生若把字母盒的父级设成 flex 居中, // 父级 align-items 会盖掉 vertical-align(改了几版都「没变化」就是这个原因), // 所以这里从子项侧用 align-self:flex-start 顶开,再用上边距压到正文首行基线; // text-align:left + 负 word-spacing(-.25em) 负责「字母靠左、句号略离字母、后面留白」。 var ol = el.querySelector('.fp-ts-ol') || (u.opt && u.opt.querySelector('.fp-ts-ol')); if (ol) { setImp(ol, 'margin', 'calc((var(--fp-ts-lh) - 1) * .5em) 1px 0 calc(-1 * var(--fp-ts-ohang))'); setImp(ol, 'align-self', 'flex-start'); setImp(ol, 'vertical-align', 'top'); setImp(ol, 'text-align', 'left'); setImp(ol, 'word-spacing', '-.25em'); setImp(ol, 'position', 'static'); setImp(ol, 'top', 'auto'); setImp(ol, 'transform', 'none'); } }); } /* ---------------- v1.9.4 题干左边缘对齐:题干正文推到与选项字母同一起点 ---------------- * 只量不猜:题干首个文字块的左边缘(Range 取文字本身的墨迹左缘,不是盒左缘)、 * 第一个选项字母的左边缘,两者的像素残差写进本卡 --fp-ts-stemfix。 * 残差是线性的(padding 加多少、文字就移多少),所以迭代两三次即归零; * 量不到文字(纯图题干等)就原样不动,绝不改动页面结构。 */ // 取「第一个非空文字节点」的左边缘(px);skipSel 里的元素(题号等)不参与 function firstTextLeft(root, skipSel) { if (!root || !document.createTreeWalker) return null; var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); var node; while ((node = walker.nextNode())) { if (!/\S/.test(node.nodeValue || '')) continue; var p = node.parentElement; if (!p) continue; if (skipSel && p.closest && p.closest(skipSel)) continue; try { var r = document.createRange(); r.selectNodeContents(node); var rects = r.getClientRects(); for (var i = 0; i < rects.length; i++) { if (rects[i] && rects[i].width > 0.5) return rects[i].left; } var b = r.getBoundingClientRect(); if (b && b.width > 0.5) return b.left; } catch (e) { /* 已卸载节点 / 跨文档:跳过 */ } } return null; } /* v1.1.8:对齐不可达兜底(要的补偿超出 ±48px,硬修只会把题干推出卡片)。 * ① 同键共享表里有值就用共享值(同键的卡验证过的对齐位);② 没有就清零,回到页面自己的排版; * ③ 打「跳过对齐」标(标内存当前键:改字号/行高/列数/反馈态一变,键就不同,这张卡自动重新参与对齐)。 * 这样就不会再出现「值被限幅钉在 ±48、残差永远差一截、每轮都重试还修不好」的僵死态。 */ function dropStemFixUnreachable(card, info, delta) { var key = stemFixKey(card); var shared = stemFixByKey[key]; card.__fpTsStemFixHits = 0; clearStemFixPending(card); card.__fpTsNoBorrow = false; if (typeof shared === 'number' && isFinite(shared)) { applyStemFix(card, shared); card.__fpTsStemFix = shared; card.__fpTsStemFixKey = key; card.__fpTsStemFixEpoch = stemFixEpoch; } else { card.style.removeProperty('--fp-ts-stemfix'); card.__fpTsStemFix = null; card.__fpTsStemFixKey = null; } setAttr(card, 'data-fp-ts-stemfix-noreach', key); } /* 题干正文左边缘(跳过题号与材料图,避免量到悬挂列里那个题号) * v1.1.8 真因修复:量「第一个正文块元素整块的左缘」——取该块所有行盒里最靠左的那条, * 含行首缩进/空白。旧实现逐文本节点找「第一个有墨迹的字」,会跳过纯空白文本节点, * 于是「行首 12 个空格(≈66px)的段落」被量成了第一个字的左缘:需求补偿从 -6.21px * 被算成 -72px,撞 ±48 限幅后被钉死在 -48px,刷新/切题都再也回不来(第25题现场)。 * 块级左缘与「调字号后借用同键共享值」的基准一致——那正是用户认为「已经对齐」的状态。 */ function stemTextLeft(stemEl) { var skipSel = '.fp-ts-num,.fp-ts-numrow,.fp-ts-mat'; var blocks = $$('p,li', stemEl); if (!blocks.length) blocks = $$('div', stemEl); for (var i = 0; i < blocks.length; i++) { var el = blocks[i]; if (skipSel && el.closest && el.closest(skipSel)) continue; if (!/\S/.test(el.textContent || '')) continue; var box = null; try { box = el.getBoundingClientRect(); } catch (e) { box = null; } if (!box || !(box.width > 1)) continue; // 还没排出来:等下一轮 var best = null; try { var r = document.createRange(); r.selectNodeContents(el); var rects = r.getClientRects(); for (var k = 0; k < rects.length; k++) { if (rects[k] && rects[k].width > 0.5 && (best == null || rects[k].left < best)) best = rects[k].left; } } catch (e2) { best = null; } return best == null ? box.left : best; // 量不到行盒就退回块左缘 } return firstTextLeft(stemEl, skipSel); // 兜底:沿用老逻辑 } // 第一个选项字母的左边缘;两侧都量不到时返回 null(宁可等 remeasure,也不拿 0 当基准) function firstOlLeft(info) { for (var i = 0; i < info.units.length; i++) { var u = info.units[i]; var ol = u.el.querySelector('.fp-ts-ol') || (u.opt && u.opt.querySelector('.fp-ts-ol')); if (!ol) continue; var left = firstTextLeft(ol, null); if (left == null) { /* v1.1.3:字母文字量不到时才退回盒左缘,且必须确认这个盒真的排出来了 * (display:none / 尚未布局时盒宽为 0,此时的 0 会把残差算成天文数字)。 */ var box = null; try { box = ol.getBoundingClientRect(); } catch (e) { box = null; } if (!box || !(box.width > 1)) continue; left = box.left; } return left; } return null; } // v1.1.3:单看的残差(px)——题干正文左缘 vs 第一个选项字母左缘;任一侧量不到即 null function stemResidual(info, card) { if (!info.stemEl || !info.units || !info.units.length) return null; var stemLeft = stemTextLeft(info.stemEl); if (stemLeft == null) return null; var olLeft = firstOlLeft(info); if (olLeft == null) return null; /* v1.1.7:两个基准都必须落在卡片自己的横向范围内。恢复显示的瞬间、radio 被临时隐藏 * 或挪到容器左缘时,量到的左缘没有意义;照它修会把题干推到卡片外,而 ±48 限幅又刚好 * 把结论钉死在边界值上(第25题的 -48px 就是这么钉出来的,且此后再也回不来)。 * 这种读数一律当作「量不到」,等下一轮,绝不据此改值。 */ if (card) { var box = null; try { box = card.getBoundingClientRect(); } catch (e) { box = null; } if (!box || !(box.width > 1)) return null; var lo = box.left - 1, hi = box.right + 1; if (stemLeft < lo || stemLeft > hi || olLeft < lo || olLeft > hi) return null; } return olLeft - stemLeft; } /* ---------------- v1.1.5:自愈 ---------------- * 只做三件事:值不在场就补写、布局不合法就拒绝量测、坏掉时留下原始证据。 */ function r2(v) { return v == null ? null : Math.round(v * 100) / 100; } // 补偿值是否真的写在 style 里(框架重置卡片根节点 style 时会被抹掉) function stemFixMissing(card) { if (!card || card.__fpTsStemFix == null) return false; // 本卡还没定值:交给正常流程 var cur = parseFloat(card.style.getPropertyValue('--fp-ts-stemfix')); if (!isFinite(cur)) return true; // 不在场 → 需要补写 return Math.abs(cur - card.__fpTsStemFix) > 0.01; // 被改成别的值 → 需要补写 } // 视口与卡片是否都真正排出来了(隐藏 / 最小化 / 恢复瞬间量到的左缘没有意义) function layoutSane(card) { if (!(window.innerWidth > 0) || !(window.innerHeight > 0)) return false; var de = document.documentElement; if (!de || !(de.clientWidth > 0)) return false; var w = 0; try { w = card.getBoundingClientRect().width; } catch (e) { w = 0; } return w > 1; } // 是否还有「没量准」的卡片(挂着待复量 / 补偿值已不在场) function hasUnsettledCards() { var found = false; $$('[data-fp-ts]').forEach(function (c) { if (inPanel(c)) return; if (c.hasAttribute('data-fp-ts-stemfix-pending') || stemFixMissing(c)) { found = true; return; } /* v1.1.6:已固化的卡也可能「值还在、但已经不对」。只看 pending / 值在不在场会漏掉这种, * 回前台重扫与兜底扫描都会因此空转;这里顺手做一次廉价残差核对。 */ if ((c.__fpTsDriftHeal || 0) >= 4) return; if (stemFixDrifted(c, probe(c), answerRevealed(c) ? 1.5 : 0.75)) found = true; }); return found; } /* v1.9.5:题干预点「待重量」记号。 * 挂着这个记号的卡片,即便签名未变、标记完整,handleCard 也不会早退跳过, * 保证 remeasure 重排时真能重新量一次 --fp-ts-stemfix;量准后立即摘掉,恢复正常早退。 */ function markStemFixPending(card) { setAttr(card, 'data-fp-ts-stemfix-pending', '1'); } function clearStemFixPending(card) { if (card.hasAttribute('data-fp-ts-stemfix-pending')) card.removeAttribute('data-fp-ts-stemfix-pending'); } /* v1.1.1:对齐补偿的复用键。补偿量只与字号/行高/选项列数有关(容器宽度不影响), * 只要这几项没变就复用上次量准的值,避免无关重排触发重测。 * v1.1.3:把「字体度量指纹」也编进键——联网补字到位 / 字体回退链换档后,同一串字的 * 实测宽度会变,旧补偿值必须作废重测。这正是 onWebFontReady 里那记 applyAll(true) * 过去会空转的原因:键没变,直接吃了用回退字体量出来的旧值。 */ function fontMetricStamp() { var w = textWidthPx('国题函', cfg.fontSize, FONT_CHAIN); return w ? (Math.round(w * 2) / 2) : 0; } function stemFixKey(card) { /* v1.1.6:把「是否已显示答案(反馈态)」也编进键。 * 补偿量跟在两个不同基准上:作答前选项行里有圆圈/占位节点,显示答案后它们被替换, * 字母盒整体挪位(实测同页差 ~21px)。过去键里没有这一维,于是「加载瞬间还判成未作答」 * 时量出的值会被当成对整张卡都成立——答案渲染完、字母盒挪位后键仍没变、值也仍算「已量准」, * 复用分支直接早退 → 错位永久钉死,只能靠改字号/行高让键变化才解得开。 * 现在状态一变键就变:旧值自动作废、必须重量,跨状态也绝不会互相借值。 */ var st = 'a?'; if (card) { try { st = answerRevealed(card) ? 'a1' : 'a0'; } catch (e) { st = 'a?'; } } return [cfg.fontSize, cfg.lineHeight, cfg.cols, fontMetricStamp(), st].join('|'); } /* v1.1.3:强制重排「世代号」。applyAll(true)(面板重新排版、字体就绪回调、设置变更) * 的语义就是「旧量测一律不算数」,所以每强制一次 +1,所有卡片已固化的补偿值随之作废。 * 修掉的回归:v1.1.1 的缓存在手动重排时照样命中,于是「错位之后点重新排版也修不好」。 */ var stemFixEpoch = 0; /* v1.1.3:同页 / 跨页「已量准」补偿值共享表(键 = stemFixKey())。 * 同一套排版参数下补偿量必然是同一个数(这正是 v1.1.1 复用键成立的前提),于是: * · 某张卡处在「量不准」的状态(反馈态 / 首帧)时,直接借同键的实测值,不冒险现量; * · 落盘一份:刷新后整页题目本来就是「已作答」时(没有一张可信的卡),也能直接借用, * 不会拿反馈态那副被推歪的基准去量、把题干推右。 * 只在「连续两次量测都收敛」的固化时刻写入,绝不存可疑值。 */ var STEM_FIX_STORE = 'fenbi_inpage_stemfix_v1'; var stemFixByKey = readStemFixStore(); function readStemFixStore() { var o = {}; try { var raw = JSON.parse(localStorage.getItem(STEM_FIX_STORE) || '{}'); if (raw && typeof raw === 'object') { Object.keys(raw).forEach(function (k) { var v = parseFloat(raw[k]); if (isFinite(v) && Math.abs(v) <= 48) o[k] = v; }); } } catch (e) { o = {}; } return o; } function saveStemFixStore() { try { localStorage.setItem(STEM_FIX_STORE, JSON.stringify(stemFixByKey)); } catch (e) { /* 无痕模式 / 配额满:只影响跨页复用,不影响当前页 */ } } function rememberStemFix(key, fix) { if (stemFixByKey[key] === fix) return; stemFixByKey[key] = fix; saveStemFixStore(); } function borrowStemFix(card, key) { var v = stemFixByKey[key]; if (v == null) return false; applyStemFix(card, v); card.__fpTsStemFix = v; card.__fpTsStemFixKey = key; card.__fpTsStemFixEpoch = stemFixEpoch; card.__fpTsStemFixHits = 0; clearStemFixPending(card); return true; } /* v1.1.3:收敛不再「一次算数」。 * 首帧 / 字体未稳时两侧可能巧合量到同一个过渡位置,残差 0 被旧代码当成量准值缓存并摘掉 * pending,错位就此永久钉死(只能靠改字号让键变化才解得开)。 * 现在要求「连续两次独立量测都收敛、且两次值一致」才固化;只收敛一次先按候选值上妆、 * 继续挂 pending 复量,复量再收敛才算数。 */ function confirmStemFix(card, fix, key) { var prev = card.__fpTsStemFix; var same = (prev != null && card.__fpTsStemFixKey === key && Math.abs(prev - fix) < 0.5); var hits = (same ? (card.__fpTsStemFixHits || 0) : 0) + 1; card.__fpTsStemFix = fix; card.__fpTsStemFixKey = key; card.__fpTsStemFixEpoch = stemFixEpoch; if (hits >= 2) { // 两次独立量测都收敛 → 固化,反馈态重排可放心复用 card.__fpTsStemFixHits = 0; clearStemFixPending(card); card.__fpTsNoBorrow = false; // 已按现量值固化:解除「禁止借值」,后续可正常复用/借值 /* v1.1.6:反馈态量准的值也写进共享表。键里已含状态(|a0 / |a1),不会再串到别的状态上; * 写入前要求「连续两次量测收敛」,且任何一次复用都会过残差自检(不合格立刻作废重量), * 因此它只会减少刷新后的首帧跳动,不会把错位固化。 */ rememberStemFix(key, fix); } else { // 只收敛一次:先上妆,再复量确认 card.__fpTsStemFixHits = hits; markStemFixPending(card); needRemeasure = true; } } function applyStemFix(card, fix) { card.style.setProperty('--fp-ts-stemfix', fix.toFixed(2) + 'px'); } /* v1.1.3:漂移自检。已固化的卡片不能「一劳永逸」——字体度量变化、外层容器改动都会让当初 * 量准的值失准,而签名(题号/选项数/宽度/设置版本)未必变化,没有这一步就永远等不到复核。 * 只做一次残差读数,误差 ≥tol 才介入(比收敛阈值 0.5 松,避免亚像素抖动来回抖)。 * v1.1.6:反馈态(已作答)也走这里,门槛放宽到 1.5px。旧版「反馈态免检」本意是不把题干推右, * 但它连「值已失效」也不许复核——作答前 → 显示答案的基准切换会让字母盒挪位 ~21px,键里没这一维 * 就永远发现不了,错位被永久钉死。现在状态已编进键,反馈态重量只是「换一个基准重算」,不会累积推右。 */ function stemFixDrifted(card, info, tol) { if (card.__fpTsStemFix == null) return false; // 还没量准过,交给正常流程 var delta = stemResidual(info, card); if (delta == null) return false; // 量不到就不动 return Math.abs(delta) >= (tol || 0.75); } function alignStemToOptions(card, info, force) { if (!info.stemEl || !info.groupEl || !info.units || !info.units.length) return; // v1.1.8:标了「跳过对齐」的卡(且键没变)不再参与对齐,免得被够不着的基准反复重算 var noreachMark = card.getAttribute('data-fp-ts-stemfix-noreach'); if (noreachMark && noreachMark === stemFixKey(card)) return; /* v1.1.1:量准过一次就缓存复用。 * 点击选项后粉笔会重渲染卡片(脚本标记被抹 → clearCard 整卡重排), * 而答案反馈态下选项区多出状态节点/样式,此时的量测基准并不可靠, * 重测出的残差会把题干往右推(表现:显示正确答案后题干又往后移)。 * 所以只要「字号/行高/列数/字体度量」没变、世代号没动、且不是「待复量」状态, * 就直接复用上次量准的补偿值(force = 用户/字体明确要求重排时不在此列)。 */ var reuseKey = stemFixKey(card); var pending = card.hasAttribute('data-fp-ts-stemfix-pending'); if (!force && !pending && card.__fpTsStemFix != null && card.__fpTsStemFixKey === reuseKey && card.__fpTsStemFixEpoch === stemFixEpoch) { applyStemFix(card, card.__fpTsStemFix); return; } /* v1.1.3:反馈态且本卡没有「当前键的已固化值」→ 借同键的已量准值,绝不在这种状态下现量。 * 命中的典型场景就是「刷新一张此刻已经显示答案的题」:卡片一上来就是反馈态, * 选项区多出的状态节点把字母盒整体推右,此时若照常量残差,就会把题干一起推右 * ——这正是「刷新页面偶尔错位」的最后一环。同页卡片 / 上次访问落盘的值都能借。 * (上面那条复用分支已经处理了「本卡已固化且键一致」的情况,走到这里就说明本卡 * 要么没值、要么值没确认、要么键已变,借用共享值都比现量可靠。) */ var settled = (card.__fpTsStemFix != null && card.__fpTsStemFixKey === reuseKey && card.__fpTsStemFixEpoch === stemFixEpoch); if (!force && !settled && !card.__fpTsNoBorrow && answerRevealed(card) && borrowStemFix(card, reuseKey)) { // v1.1.6:被打过「禁止借值」标的卡必须现量 return; } /* v1.1.5:布局没真正出来(后台标签页 / 最小化 / 恢复显示的瞬间 / 首帧零宽)时不量、更不固化。 * 旧逻辑在这种状态下量出的值会被「两次收敛」错当成已量准钉死,此后不再复核 → 永久错位。 */ if (!layoutSane(card)) { // 已固化的好值原样留在 style 里(隐藏期间视觉不变),只把「已量准」的记账撤掉,等布局合法时复量 if (!(card.__fpTsStemFix != null && card.__fpTsStemFixKey === reuseKey && card.__fpTsStemFixEpoch === stemFixEpoch)) { card.__fpTsStemFixHits = 0; } markStemFixPending(card); needRemeasure = true; return; } /* v1.9.5:首帧量测不可信。 * 打开页面时粉笔的卡片还在 React 水合/布局中,题干可能还是零宽(或被隐藏), * 这时 Range 量出来的两侧左缘都是 0 —— 残差 0,被当成「已对齐」直接 return。 * 而此后卡片签名没变、标记还在,handleCard 一律早退,错位就永久固化, * 直到手动点一下面板(force 重排)才量正。这就是「打开页面错位、点一下才对齐」的真因。 * 修法:宽度不合法时绝不判定对齐,挂上 pending 记号 + remeasure,等布局稳定后复量。 * v1.1.3:选项字母那一侧同样要过门槛(firstOlLeft 量不到就返回 null,不再拿 0 顶替)。 */ var stemW = 0; try { stemW = info.stemEl.getBoundingClientRect().width; } catch (e) { stemW = 0; } if (!(stemW > 1)) { card.__fpTsStemFixHits = 0; markStemFixPending(card); needRemeasure = true; return; } var fix = parseFloat(card.style.getPropertyValue('--fp-ts-stemfix')) || 0; for (var k = 0; k < 3; k++) { var delta = stemResidual(info, card); if (delta == null) { // 首帧还没排出来(文字量不到 / 字母盒还没成型) card.__fpTsStemFixHits = 0; markStemFixPending(card); // 挂个记号,等 remeasure 重排后再量 needRemeasure = true; return; } if (Math.abs(delta) < 0.5) { // 到像素级对齐 → 走「两次确认」再固化 applyStemFix(card, fix); confirmStemFix(card, fix, reuseKey); return; } var next = fix + delta; if (next > 48) next = 48; // 防异常量测把题干推出卡片 if (next < -48) next = -48; if (Math.abs(next - fix) < 0.25) { // 顶到限幅调不动了:这张卡的基准不在这套几何能修到的范围内 if (Math.abs(delta) >= 1.5) { // 不是亚像素抖动,是真的够不着 card.__fpTsNoReach = (card.__fpTsNoReach || 0) + 1; if (card.__fpTsNoReach >= 2) { // v1.1.8:连着两次都够不着 → 复位并标记跳过对齐 dropStemFixUnreachable(card, info, delta); return; } } break; // 先交给 remeasure 复量一轮,下一轮再判 } fix = next; applyStemFix(card, fix); } // 三轮还没归零 / 已顶到限幅:挂 pending 交给 remeasure 再走一轮,不自旋 card.__fpTsStemFixHits = 0; markStemFixPending(card); needRemeasure = true; } /* ================================================================== * 7. 应用 / 还原 * ================================================================ */ var sigMap = new WeakMap(); // 卡片 -> 上次处理签名(DOM 与设置都没变就跳过重算) // 本脚本打标过的卡片根节点集合。框架重建卡片内部时会抹掉标记类与属性, // 但卡片根这个 DOM 节点往往还在——靠节点身份才能认出「这是自己排版过的卡」, // 从而在标记被抹的第一时间就地补回排版(而不是等去抖计时器)。 var appliedRoots = new WeakSet(); var applying = false; // 自身写入引发 observer 时的重入保护 var remeasureTimer = null, remeasureTries = 0, remeasureStart = 0; function clearCard(card) { unstyledOpts(card); // 先摘内联兜底样式,再摘标记类/属性 var stem = card.querySelector('.fp-ts-stem'); if (stem) { // 题干首/末 p 的对齐、缩进内联样式一并摘除(只摘本脚本写过的) var clPs = $$('p', stem); var clTg = clPs.length === 1 ? [clPs[0]] : [clPs[0], clPs[clPs.length - 1]]; clTg.forEach(function (p) { var cache = p.__fpTsImp; if (cache && cache['text-align']) p.style.removeProperty('text-align'); if (cache && cache['text-indent']) p.style.removeProperty('text-indent'); p.__fpTsImp = null; }); } ['data-fp-ts', 'data-fp-ts-cols', 'data-fp-ts-num', 'data-fp-ts-stemfix-pending', 'data-fp-ts-stemfix-noreach'].forEach(function (a) { if (card.hasAttribute(a)) card.removeAttribute(a); }); if (card.style.getPropertyValue('--fp-ts-hang')) card.style.removeProperty('--fp-ts-hang'); if (card.style.getPropertyValue('--fp-ts-stemfix')) card.style.removeProperty('--fp-ts-stemfix'); card.__fpTsDriftHeal = 0; // v1.1.6:布局真的变了 → 自愈额度重置 card.__fpTsNoReach = 0; // v1.1.8:不可达判定一并重置,重新给机会 card.__fpTsNoBorrow = false; appliedRoots.delete(card); } // 只拆本脚本加的、以 fp-ts- 开头的类(页面原生类名一律不碰) // 面板自身也用 fp-ts- 前缀类名,但它不属于「页面标记」:清残留必须跳过,否则停用时面板控件会掉样式 function inPanel(node) { return !!(node && node.closest && node.closest('#' + PANEL_ID)); } function stripMarkers(root) { $$('[class*=fp-ts-]', root || document).forEach(function (el) { if (inPanel(el)) return; var cls = []; Array.prototype.slice.call(el.classList).forEach(function (c) { if (c.indexOf('fp-ts-') === 0) cls.push(c); }); cls.forEach(function (c) { el.classList.remove(c); }); }); } function restoreAll() { // 无痕迹:零改动直接返回(面板自身的类不计入,否则永远走全量清) var traces = $$('[data-fp-ts],[class*=fp-ts-]').filter(function (el) { return !inPanel(el); }); if (!traces.length) return; $$('[data-fp-ts]').forEach(clearCard); stripMarkers(document); // 兜底:SPA 切页后属性已被框架重建时,按类名清残留 } /* ---------------- 排版自检:框架重建后自动复原 ---------------- * 粉笔点击选项会重渲染卡片内部,把本脚本写入的标记类/内联样式清掉; * 而卡片签名(题号·选项数·宽度)没变,早退逻辑会让排版一直停在默认样式上 * (表现:选完选项,排版「变回默认」)。所以每次处理前先自检标记在不在, * 不在就摘掉残留、整卡重排一次。 */ function hasFpClass(el) { if (!el || !el.classList) return false; for (var i = 0; i < el.classList.length; i++) { if (el.classList[i].indexOf('fp-ts-') === 0) return true; } return false; } // 标记是否完好:本脚本写过的关键标记只要丢一个,就认定卡片被重建过 function markersIntact(card, info) { if (!card || !card.hasAttribute('data-fp-ts')) return false; info = info || probe(card); if (info.stemEl && !hasFpClass(info.stemEl)) return false; var i; for (i = 0; i < info.matEls.length; i++) { if (!hasFpClass(info.matEls[i])) return false; } if (info.numEl && !hasFpClass(info.numEl)) return false; if (info.groupEl && info.units.length >= 2) { if (!hasFpClass(info.groupEl)) return false; if (!card.hasAttribute('data-fp-ts-cols')) return false; var cache = info.groupEl.__fpTsImp; if (!cache || cache['display'] !== 'flex') return false; // 内联兜底样式也被清掉了 for (i = 0; i < info.units.length; i++) { if (!hasFpClass(info.units[i].el)) return false; var ol = info.units[i].el.querySelector('.input-radio') || info.units[i].opt.querySelector('.input-radio'); if (ol && !hasFpClass(ol)) return false; } } return true; } /* v1.1.3:反馈态判定(答案/解析已经出来)。 * 反馈态下选项区会多出状态节点与样式,量测基准不可靠——正是 v1.1.1 那条踩坑的根源。 * 三条线索(任一命中即算): * ① 用户在本卡点过 —— 点击捕获阶段就打标,早于框架重渲染,最可靠; * ② 选项带了对错态类(.correct/.correctLost/.wrong 是粉笔反馈态才加的状态类); * ③ 答案/解析区真的排出了高度(display:none 的占位高度为 0,不算)。 */ function answerRevealed(card) { if (card.__fpTsAnswered) return true; try { if (card.querySelector('.correct, .correctLost, .wrong')) return true; } catch (e) { /* 选择器异常:不算命中 */ } var a = card.querySelector('app-result-common'); if (!a) return false; var h = 0; try { h = a.getBoundingClientRect().height; } catch (e) { h = 0; } return h > 1; } // 同一张卡片在短时间内被反复抹掉时不再硬顶,避免和页面互相刷(限流窗口 4s / 10 次) var repairLog = new WeakMap(); var REPAIR_WINDOW = 4000, REPAIR_MAX = 10; function repairAllowed(card) { var now = Date.now(); var rec = repairLog.get(card); if (!rec || now - rec.t0 > REPAIR_WINDOW) rec = { t0: now, n: 0 }; rec.n++; repairLog.set(card, rec); return rec.n <= REPAIR_MAX; } function handleCard(card, force) { var info = probe(card); // 认不出题目结构的卡片:安全跳过,绝不做任何改动 if (!info.stemEl && !info.options.length && !info.matEls.length) return false; var numEl = info.numEl; var numText = numEl ? (numEl.textContent || '').replace(/\s+/g, '').trim() : ''; // 签名 = 题号文字 + 选项数(原始/拆分后) + 可用宽 + 设置版本,都没变就不重复算 var gw = 0; try { gw = info.groupEl ? info.groupEl.clientWidth : (info.stemEl ? info.stemEl.clientWidth : 0); } catch (e) { gw = 0; } var sig = [numText, info.options.length, info.units.length, Math.round(gw), cfgStamp].join('|'); // 签名相同 + 标记仍在 → 真的没变化,跳过; // 签名相同但标记被框架抹掉 → 必须重排(这正是「选完选项排版回退」的根因) var intact = markersIntact(card, info); var sigSame = (sigMap.get(card) === sig); // v1.1.3:框架把整张卡换掉了(换题)→ 上一题的「用户已作答」标作废, // 别让旧题的状态拖累新题的漂移自检(同一题重渲染时签名不变,不会误清)。 if (!sigSame && !intact) card.__fpTsAnswered = false; // v1.9.5:挂着「题干预点待重量」记号的卡片,即便签名没变也不能早退—— // 否则 remeasure 重排后会因签名相同被跳过,--fp-ts-stemfix 永远得不到重算, // 打开页面时的错位就此固化,只有手动 force 重排(点面板)才解得开。 var pendFix = card.hasAttribute('data-fp-ts-stemfix-pending'); if (sigSame && intact && !pendFix) { /* v1.1.5:先核对补偿值是否还真的写在 style 里。 * 框架可能只重置了卡片根节点的 style(类名还在 → 上面刚判成「自己人」), * 这时缓存里有值、--fp-ts-stemfix 却已不在,题干回到未补偿位置;而旧逻辑在这里 * 直接早退(反馈态更甚,连漂移自检都跳过)→ 错位再也修不回来。 */ if (stemFixMissing(card)) { if (card.__fpTsStemFixKey === stemFixKey(card) && card.__fpTsStemFixEpoch === stemFixEpoch) { applyStemFix(card, card.__fpTsStemFix); // 值仍是当前键的:补写即可 return true; } card.__fpTsStemFix = null; // 键已过时:清缓存,走待复量重量 markStemFixPending(card); needRemeasure = true; pendFix = true; } if (!pendFix) { /* v1.1.3:签名没变也要复核一次对齐。 * 签名只编了「题号·选项数·宽度·设置版本」,字体度量换档、外层容器改动引起的位移 * 都不会体现在签名里;已固化的补偿值如果永远不复核,错位就只能等用户手动点面板。 * v1.1.6:反馈态不再享有「免复核」特权。旧规则「已作答只复用不重量」本意是不把题干推右, * 但它把「值已失效」也一起挡在门外:作答前 → 显示答案的基准切换会让字母盒挪位 ~21px, * 键里没这一维就永远发现不了。现在状态已编进键,反馈态也必须过残差自检——门槛放宽到 * 1.5px(比作答前的 0.75px 松,免得在反馈态追亚像素抖动)。介入同样受 repairAllowed 限流。 */ if (!stemFixDrifted(card, info, answerRevealed(card) ? 1.5 : 0.75)) return true; if ((card.__fpTsDriftHeal || 0) >= 4) { // 自愈额度用尽:不再和页面互相刷 return true; } card.__fpTsDriftHeal = (card.__fpTsDriftHeal || 0) + 1; card.__fpTsNoBorrow = true; // 这次必须现量,不许借同键旧值回填 markStemFixPending(card); needRemeasure = true; pendFix = true; // 本帧就往下走,立刻重量 } } /* v1.1.8:撤销 v1.1.7 的「待复量卡也放行」。放行等于让同一张卡每帧绕过修复闸门重排, * 一旦它同时满足「签名没变 + 量测每轮反复」,脚本写 style → observer 又触发一轮, * 页面会被拖进自旋(实机 reload 后 renderer 卡死的嫌疑路径)。第25题的真因是量测基准, * 不在闸门,因此闸门恢复 v1.1.6 原样;坏值由审计就地修正 + 不可达兜底接手。 */ if (sigSame && !repairAllowed(card)) return true; // 与页面互相覆盖时先放手,不自旋 sigMap.set(card, sig); var wasApplied = appliedRoots.has(card); // v1.9.5:判断是不是「首次上妆」 if (!intact) clearCard(card); // 先摘干净残留,再整卡重排,避免两套样式叠加 setAttr(card, 'data-fp-ts', '1'); appliedRoots.add(card); // 悬挂宽度:固定 2em var hangEm = HANG_FALLBACK; setVar(card, '--fp-ts-hang', hangEm.toFixed(3) + 'em'); // 题干 / 材料 if (info.stemEl) addClass(info.stemEl, 'fp-ts-stem'); // 题干盒内:仅首个 p 与末个 p 左对齐,中间段落不动 if (info.stemEl) { var stemPs = $$('p', info.stemEl); var stemTg = stemPs.length === 1 ? [stemPs[0]] : [stemPs[0], stemPs[stemPs.length - 1]]; stemTg.forEach(function (p) { setImp(p, 'text-indent', '0'); setImp(p, 'text-align', 'left'); }); } info.matEls.forEach(function (m) { addClass(m, 'fp-ts-mat'); }); // 题号定位方式三态:inline(与题干同盒)/ row(独占标题行)/ keep(标题行还有别的内容) if (numEl) { addClass(numEl, 'fp-ts-num'); if (info.stemEl && info.stemEl.contains(numEl)) { card.setAttribute('data-fp-ts-num', 'inline'); var blk = numBlock(numEl, info.stemEl); if (blk) addClass(blk, 'fp-ts-np'); // 题号所在段落不再叠首行缩进 } else { var row = numEl.parentElement; var rowText = row ? (row.textContent || '').replace(/\s+/g, '').trim() : ''; if (row && row !== card && rowText === numText && row.children.length <= 1) { card.setAttribute('data-fp-ts-num', 'row'); addClass(row, 'fp-ts-numrow'); } else { card.setAttribute('data-fp-ts-num', 'keep'); } } } else if (card.hasAttribute('data-fp-ts-num')) { card.removeAttribute('data-fp-ts-num'); } // 选项组 + 列数(同一道题列数一致) if (info.groupEl && info.units.length >= 2) { addClass(info.groupEl, 'fp-ts-opts'); info.units.forEach(function (u) { addClass(u.el, 'fp-ts-opt'); // 选项行(夹 wrapper 时标在 wrapper 上) var ol = u.el.querySelector('.input-radio') || u.opt.querySelector('.input-radio'); if (ol) addClass(ol, 'fp-ts-ol'); }); var cols = pickCols(info, hangEm); hardenOpts(info, cols); // 内联 !important 兜底,防被原生样式压掉 setAttr(card, 'data-fp-ts-cols', cols); // 实测校验:标了多列却仍被排成一列时逐级降档,避免「设置写了、视觉没变」 var verified = verifyCols(info, cols); if (verified !== cols) { hardenOpts(info, verified); setAttr(card, 'data-fp-ts-cols', verified); } } else if (card.hasAttribute('data-fp-ts-cols')) { card.removeAttribute('data-fp-ts-cols'); } // v1.9.4:把题干正文左边缘实测对齐到第一个选项字母的左边缘(写 --fp-ts-stemfix) // v1.1.3:force 一路上传——强制重排时不吃缓存,必须重量 if (info.stemEl && info.groupEl && info.units.length >= 1) { alignStemToOptions(card, info, force); } /* v1.9.5:首次排版时字体/布局还没稳定,首帧那次量测得到的残差可能是错的 * (甚至巧合为 0,被当成已对齐)。而「签名没变 + 标记仍在」会让后续回调直接早退, * 于是错位固化到用户手动点面板为止。所以首次上妆后强制挂一次延迟复量, * 等布局稳定后再校一遍(remeasure 上限 7 次、120ms 起,不会自旋)。 */ if (!wasApplied) { markStemFixPending(card); needRemeasure = true; } return true; } // 首帧量不到可用宽时的补救:隔几拍按真实布局重算 // v1.1.3:上限从「7 次」放宽为「20 次 / 8 秒窗口」,并且一次全量排版干净收尾就重置预算—— // 旧写法只减不增、且不重置,早开页面时被首帧事件吃掉几次之后,卡片就再也等不到复量了。 var REMEASURE_MAX = 20, REMEASURE_SPAN = 8000; function clearRemeasure() { if (remeasureTimer) { clearTimeout(remeasureTimer); remeasureTimer = null; } remeasureTries = 0; remeasureStart = 0; } function resetRemeasureBudget() { remeasureTries = 0; remeasureStart = 0; } function scheduleRemeasure() { if (remeasureTimer || !cfg.enabled) return; var now = Date.now(); if (!remeasureStart) remeasureStart = now; if (remeasureTries >= REMEASURE_MAX || now - remeasureStart > REMEASURE_SPAN) return; remeasureTries++; remeasureTimer = setTimeout(function () { remeasureTimer = null; if (!cfg.enabled) return; needRemeasure = false; sigMapClear(); // 尺寸变了,旧签名一律作废 applyAll(false); }, remeasureTries <= 3 ? 120 : 400); } function applyAll(force) { if (!cfg.enabled) return; if (force) { sigMapClear(); stemFixEpoch++; // v1.1.3:强制重排 = 旧量测一律作废,缓存世代号 +1 } needRemeasure = false; applying = true; var seen = []; var cards = findCards(); try { cards.forEach(function (card) { if (handleCard(card, force)) seen.push(card); }); // 清理已从页面消失/不再识别的卡片残留 $$('[data-fp-ts]').forEach(function (card) { if (seen.indexOf(card) < 0) clearCard(card); }); } finally { applying = false; } // v1.1.3:这一轮已经干净收尾(没有卡片还要复量)→ 预算重置,下次事件重新给足机会 var pendN = 0; $$('[data-fp-ts-stemfix-pending]').forEach(function (c) { if (!inPanel(c) && c.hasAttribute('data-fp-ts')) pendN++; }); /* v1.1.5:只在「本轮干净收尾」时做一次残差审计——已经没有待复量却仍有卡片肉眼可见地没对齐 * (≥1.5px),就是永久错位现场。 * v1.1.6:顺手把它变成最后一道自愈网——发现就作废该卡的值、挂待复量重扫(详见下方注释)。 */ var badN = 0, healedN = 0, repairedN = 0; if (!needRemeasure) { seen.forEach(function (card) { var info2 = probe(card); if (!info2.stemEl || !info2.units.length) return; // v1.1.8:已判「跳过对齐」的卡(键一致时)不再进审计,否则每轮都会拿同一个够不着的残差刷自愈额度 if (card.getAttribute('data-fp-ts-stemfix-noreach') === stemFixKey(card)) return; var d = stemResidual(info2, card); // 门槛 1.5px(不是介入修复用的 0.75px):亚像素残差肉眼无感,报出来只会误导 if (d == null || Math.abs(d) < 1.5) return; badN++; /* v1.1.6:审计不再只检查,能修就修。 * 走到这里 = 本轮已干净收尾(没有任何卡片挂着待复量),却仍有卡肉眼可见地没对齐—— * 典型就是卡上那个「键没变、值已失效」的固化值(跨状态 / 字体换档 / 外层位移都会这样)。 * 作废它、挂待复量重扫一轮,并打「禁止借值」标逼它现量。 * 每卡最多自愈 4 次(真正改动布局时 clearCard 会重置额度),绝不和页面互相刷。 */ if ((card.__fpTsDriftHeal || 0) >= 4) return; /* v1.1.7:改成「就地修」而不是「作废后等复量」。 * 走到这里 = 卡片身上的值与几何已经对不上(|残差| ≥1.5px,肉眼可见)。 * 修法很直接:当前 style 里的值 + 这次量到的残差 = 该有的值,写回去即可闭环 * ——和收敛循环用的是同一套几何,一次到位。旧写法(只把缓存作废 + 挂 pending * 等下一轮复量)依赖 pending / 复量预算 / 借值这三条通道,任一环节哑火,坏值就会 * 像第25题那样被限幅钉住、几秒甚至更久都回不来;就地修不再经过这些通道。 * 下一轮审计会复量,残差归零即确认修好;仍有偏差就再来一次(每卡上限 4 次)。 */ var curFix = parseFloat(card.style.getPropertyValue('--fp-ts-stemfix')); if (!isFinite(curFix)) curFix = 0; var wantRaw = curFix + d; // 该有的值(未限幅) if (wantRaw > 48 || wantRaw < -48) { // v1.1.8:需要的补偿超出限幅 → 对齐不可达,别硬修 card.__fpTsNoReach = (card.__fpTsNoReach || 0) + 1; if (card.__fpTsNoReach >= 2) { dropStemFixUnreachable(card, info2, d); return; } card.__fpTsNoBorrow = true; card.__fpTsStemFix = null; card.__fpTsStemFixHits = 0; markStemFixPending(card); needRemeasure = true; healedN++; return; } var wantFix = wantRaw; card.__fpTsDriftHeal = (card.__fpTsDriftHeal || 0) + 1; if (Math.abs(wantFix - curFix) >= 0.25) { // 修得动:直接写正确值并记账为「已量准」 applyStemFix(card, wantFix); card.__fpTsStemFix = wantFix; card.__fpTsStemFixKey = stemFixKey(card); card.__fpTsStemFixEpoch = stemFixEpoch; card.__fpTsStemFixHits = 0; card.__fpTsNoBorrow = false; clearStemFixPending(card); repairedN++; } else { // 修不动(顶到限幅/读数没变):作废等复量 card.__fpTsNoBorrow = true; card.__fpTsStemFix = null; card.__fpTsStemFixHits = 0; markStemFixPending(card); needRemeasure = true; healedN++; } }); } if (!needRemeasure) resetRemeasureBudget(); setStatus('已排版 ' + seen.length + ' 道题 · ' + (cfg.cols === 'auto' ? '列数自动' : cfg.cols + ' 列') + (pendN ? ' · 对齐待复量 ' + pendN : '') + (badN ? ' · 错位 ' + badN : '') + (repairedN ? ' · 已修 ' + repairedN : '') + (healedN ? ' · 自愈 ' + healedN : '')); if (needRemeasure) scheduleRemeasure(); } function sigMapClear() { // WeakMap 无法清空,直接换一个实例即可 sigMap = new WeakMap(); } function clearAllMarkers() { clearRemeasure(); restoreAll(); sigMapClear(); } function setEnabled(on) { cfg.enabled = !!on; saveSettings(); if (styleEl) styleEl.disabled = !cfg.enabled; if (cfg.enabled) { pushRootVars(); applyAll(true); } else { clearAllMarkers(); clearRootVars(); setStatus('已停用:页面样式已还原'); } syncPanel(); } /* ================================================================== * 8. 参数面板 * ================================================================ */ var panel = null, statusEl = null, fontStatusEl = null, ctrl = {}; function el(tag, props, kids) { var n = document.createElement(tag); if (props) Object.keys(props).forEach(function (k) { if (k === 'style') n.setAttribute('style', props[k]); else if (k.indexOf('on') === 0) n.addEventListener(k.slice(2), props[k]); else if (k === 'text') n.textContent = props[k]; else n.setAttribute(k, props[k]); }); (kids || []).forEach(function (c) { if (c) n.appendChild(c); }); return n; } function row(label, control, extraClass) { return el('div', { 'class': 'fp-ts-row' + (extraClass ? ' ' + extraClass : '') }, [el('span', { text: label }), control]); } // 加减步进控件:左「−」右「+」,中间显示当前值;连点按 step 递增/递减,超界自动夹在 LIMITS 内 function stepper(key, step, digits) { var box = el('div', { 'class': 'fp-ts-stepbox' }); var val = el('span', { 'class': 'fp-ts-stepval' }); var minus = el('button', { type: 'button', 'class': 'fp-ts-stepbtn', text: '\u2212' }); var plus = el('button', { type: 'button', 'class': 'fp-ts-stepbtn', text: '\uff0b' }); function fmt(v) { var n = parseFloat(v); if (!isFinite(n)) n = cfg[key]; return String(digits ? parseFloat(n.toFixed(digits)) : Math.round(n)); } function show(v) { val.textContent = fmt(v); } function nudge(dir) { if (!cfg.enabled) { show(cfg[key]); return; } var next = parseFloat(num(parseFloat(cfg[key]) + step * dir, cfg[key], key).toFixed(4)); if (next === cfg[key]) { show(cfg[key]); return; } setCfg(key, next); } minus.addEventListener('click', function () { nudge(-1); }); plus.addEventListener('click', function () { nudge(1); }); box.appendChild(minus); box.appendChild(val); box.appendChild(plus); return { root: box, setValue: show, setDisabled: function (off) { minus.disabled = off; plus.disabled = off; box.classList.toggle('fp-ts-stepbox-off', !!off); } }; } function setCfg(key, value) { cfg[key] = value; cfgStamp++; saveSettings(); pushRootVars(); applyAll(true); syncPanel(); } function buildPanel() { if (panel) return; panel = el('div', { id: PANEL_ID }); var fold = el('button', { 'class': 'fp-ts-fold', type: 'button', text: '\u2212', onmousedown: function (e) { e.stopPropagation(); }, onclick: function () { panel.classList.toggle('fp-ts-collapsed'); fold.textContent = panel.classList.contains('fp-ts-collapsed') ? '+' : '\u2212'; } }); var head = el('div', { 'class': 'fp-ts-head' }, [ el('span', { text: '粉笔一页成卷' }), el('span', { text: '粉笔脚本君出品', 'style': 'font-size:11px;color:#a0a0a0;user-select:none;' }), fold ]); // 启停 var sw = el('input', { type: 'checkbox', onchange: function () { setEnabled(sw.checked); } }); ctrl.sw = sw; var toggleRow = row('启用页面内排版', sw); // 联网补字:安卓平板、iPad 这类没装宋体的设备靠它兜底 var wf = el('input', { type: 'checkbox', onchange: function () { setWebFont(wf.checked); } }); ctrl.webFont = wf; var webFontRow = row('缺宋体时联网补字', wf); // 字号 / 行高(加减调整) ctrl.fontSize = stepper('fontSize', 1, 0); ctrl.lineHeight = stepper('lineHeight', 0.05, 2); // 选项列数 ctrl.cols = el('select', { onchange: function () { setCfg('cols', ctrl.cols.value); } }, [ el('option', { value: 'auto', text: '自动(4/2/1)' }), el('option', { value: '1', text: '1 列' }), el('option', { value: '2', text: '2 列' }), el('option', { value: '4', text: '4 列' }) ]); ctrl.qSpacing = stepper('qSpacing', 1, 0); var btns = el('div', { 'class': 'fp-ts-btns' }, [ el('button', { type: 'button', text: '恢复默认', onclick: function () { resetCfg(); } }), el('button', { type: 'button', text: '重新排版', onclick: function () { applyAll(true); } }) ]); statusEl = el('div', { 'class': 'fp-ts-status', text: '' }); fontStatusEl = el('div', { 'class': 'fp-ts-status fp-ts-fontstatus', text: '' }); var body = el('div', { 'class': 'fp-ts-body' }, [ toggleRow, webFontRow, row('正文字号 (px)', ctrl.fontSize.root), row('正文行高', ctrl.lineHeight.root), row('选项列数', ctrl.cols), row('题间距 (px)', ctrl.qSpacing.root), btns, statusEl, fontStatusEl ]); panel.appendChild(head); panel.appendChild(body); panel.addEventListener('mousedown', function (e) { e.stopPropagation(); }); panel.addEventListener('click', function (e) { e.stopPropagation(); }); document.body.appendChild(panel); syncPanel(); } function resetCfg() { cfg = JSON.parse(JSON.stringify(DEFAULTS)); cfgStamp++; saveSettings(); if (cfg.webFont) setupWebFont(); else { clearWebFontLinks(); webFontState = 'off'; } if (styleEl) styleEl.disabled = !cfg.enabled; if (cfg.enabled) { pushRootVars(); applyAll(true); } else { clearAllMarkers(); clearRootVars(); } syncPanel(); } function syncPanel() { if (!panel) return; ctrl.sw.checked = cfg.enabled; ctrl.webFont.checked = cfg.webFont; ctrl.fontSize.setValue(cfg.fontSize); ctrl.lineHeight.setValue(cfg.lineHeight); ctrl.qSpacing.setValue(cfg.qSpacing); ctrl.cols.value = cfg.cols; ['fontSize', 'lineHeight', 'qSpacing'].forEach(function (k) { ctrl[k].setDisabled(!cfg.enabled); }); ctrl.cols.disabled = !cfg.enabled; refreshFontStatus(); } function setStatus(text) { if (statusEl) statusEl.textContent = text; } /* ================================================================== * 9. 页面变化监听(SPA 切题 / 异步加载新卡片) * ================================================================ */ var pending = null; var mo = null; var APPLY_DELAY = 250; // 常规(切题 / 新增卡片)去抖 var REPAIR_DELAY = 80; // 抢修被限流挡下时的兜底重试 function scheduleApply(delay) { if (pending) return; pending = setTimeout(function () { pending = null; applyAll(false); }, typeof delay === 'number' ? delay : APPLY_DELAY); } // 从节点向上找「本脚本排版过的卡片根」:标记被框架抹了也照样认得出(靠节点身份) function nearestApplied(node) { for (var n = node; n && n.nodeType === 1; n = n.parentElement) { if (appliedRoots.has(n)) return n; } return null; } /* 就地抢修(本版消除「闪一下默认排版」的关键) * MutationObserver 回调是在当前任务的微任务阶段执行的,早于浏览器绘制。 * 只要在这里(而不是去抖计时器里)把排版补回去,浏览器就根本画不到 * 「默认排版」那一帧,闪一下自然消失。 * 抢修走 handleCard:标记被抹会自动先 clearCard 再整卡重排, * 反复互刷的限流也统一由它内部的 repairAllowed 把关。 */ function repairNow(card) { if (!card || !card.isConnected) return; applying = true; try { handleCard(card); } finally { applying = false; } } // 观察器只负责「页面换了」这件事:新增/删除题目、SPA 切题、卡片被框架重建。 // 本脚本自己对卡片写的 class / style / data-fp-ts* 一律忽略(标记完好即视为自己人), // 否则每次排版都会再触发一次回调,白白重排一轮。 var OWN_ATTRS = { 'class': 1, 'style': 1, 'data-fp-ts': 1, 'data-fp-ts-cols': 1, 'data-fp-ts-num': 1 }; // 新插入的节点是否像一道题(切题 / 追加题目时用来判断要不要抢在绘制前排版) var CARD_HINT = 'app-ti, .ti-item, .question-item, .choice-radio'; function looksLikeCard(node) { if (!node || node.nodeType !== 1) return false; if (node.matches && node.matches(CARD_HINT)) return true; return !!(node.querySelector && node.querySelector(CARD_HINT)); } function startObserver() { if (mo || !document.body) return; mo = new MutationObserver(function (muts) { if (!cfg.enabled || applying) return; var need = false; var eager = false; // 新题卡插入 → 同样抢在绘制前排版 var cand = []; // 本轮疑似被框架重建的卡片(去重后统一自检) for (var i = 0; i < muts.length; i++) { var m = muts[i]; var t = m.target; if (!t || t.nodeType !== 1 || !t.closest) { need = true; continue; } if (t.closest('#' + PANEL_ID)) continue; // 面板自身的增删改 var host = nearestApplied(t); if (host) { // 变化发生在已排版卡片内部 if (cand.indexOf(host) < 0) cand.push(host); // 卡内真的增删了节点(解析区、材料、选项被追加…):除了抢修,照旧再走一轮 // 全量重扫,把新增的子结构也识别进去(签名没变时这一步是空跑,代价可忽略) if (m.type === 'childList') need = true; continue; } if (m.type === 'childList' && m.addedNodes) { for (var k = 0; k < m.addedNodes.length; k++) { if (looksLikeCard(m.addedNodes[k])) { eager = true; break; } } } need = true; // 卡片外的变化照旧走一轮(签名兜底) } if (cand.length) { var didRepair = false, retry = false; for (var j = 0; j < cand.length; j++) { var c = cand[j]; /* v1.1.5:标记完好还不够——补偿值可能已被框架从 style 里抹掉, * 这种情况同样要立刻补写,否则题干就停在未补偿位置、再也修不回来。 */ if (!c.isConnected || (markersIntact(c) && !stemFixMissing(c))) continue; repairNow(c); // 同步补回,赶在绘制之前 didRepair = true; if (!markersIntact(c) || stemFixMissing(c)) retry = true; // 被限流 / 又被抹 → 稍后再兜一次 } if (didRepair && pending) { clearTimeout(pending); pending = null; } if (retry) scheduleApply(REPAIR_DELAY); } if (eager) applyAll(false); // 新题卡:当场排,别等去抖(否则会闪一帧默认版式) else if (need) scheduleApply(); }); mo.observe(document.body, { childList: true, subtree: true, attributes: true }); } /* ================================================================== * 10. 启动 * ================================================================ */ function boot() { cfg = readSettings(); injectStyles(); if (styleEl) styleEl.disabled = !cfg.enabled; buildPanel(); setupWebFont(); // 本机没宋体就联网补一份(异步,不影响首屏) startObserver(); /* v1.1.5:预算用尽后旧逻辑不会再给机会(后台标签页 / 最小化期间量不成,回到前台也没人管)。 * 回前台、窗口获焦时重开预算,把还没量准的卡片重扫一遍。 */ var rearm = function () { if (!cfg.enabled) return; if (document.visibilityState === 'hidden') return; if (!hasUnsettledCards()) return; resetRemeasureBudget(); scheduleApply(80); }; document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') rearm(); }); window.addEventListener('focus', rearm); /* 系统字体(宋体)晚到位会改变字宽度量 → 旧补偿值一律作废重量。 */ if (document.fonts && document.fonts.ready && document.fonts.ready.then) { document.fonts.ready.then(function () { if (!cfg.enabled) return; resetRemeasureBudget(); applyAll(true); }); } /* v1.1.5:慢速兜底扫描(加载后 60 秒内每 2 秒一次)。覆盖「失败发生在预算用尽之后、 * 页面又不再产生任何事件」的死角:只要还有没量准的卡片就重开预算再扫一遍; * 全部量准时空跑,60 秒后自停。 */ var sweeps = 0; var sweepTimer = setInterval(function () { sweeps++; if (sweeps > 30) { clearInterval(sweepTimer); return; } if (!cfg.enabled || !hasUnsettledCards()) return; resetRemeasureBudget(); applyAll(false); }, 2000); // 窗口尺寸变化会改变可用宽 → 重新判定列数(去抖,走同一条 scheduleApply 通道) window.addEventListener('resize', function () { if (cfg.enabled) scheduleApply(); }); // v1.1.3:用户在本卡点过选项(选答案)→ 打上「反馈态」标。捕获阶段先于框架处理器执行, // 所以框架重渲染抹标记之前这个标就已经落在卡片上了,后续重排一律只复用缓存不重量测。 // 只认「点在选项上」:点题干、图片、材料页签不算作答,免得白白关掉该卡的自检通道。 document.addEventListener('click', function (e) { var t = e.target; if (!t || !t.closest) return; if (!t.closest('.fp-ts-opt, .choice-radio, .input-radio, label, app-choice-radio')) return; var host = nearestApplied(t) || t.closest(CARD_HINT); // 卡根被整体换掉时兜底认卡 if (host) host.__fpTsAnswered = true; }, true); /* v1.1.4:只读排查口子。控制台执行 __fpTsDebug() 打印每张卡的对齐现场: * fix(已固化的补偿值)/ 写在style(此刻真正生效的值)/ 已量准 / 反馈态 / * 残差d(此刻还差多少,|d|≥0.5 就是肉眼可见的错位)/ 待复量 / 键一致。 * 出错时对着这张表看一眼,就能分清是「量歪了」「值被作废了」还是「压根没量到」。 */ window.__fpTsDebug = function () { var rows = []; findCards().forEach(function (card, i) { var info = probe(card); var d = (info.stemEl && info.groupEl && info.units.length) ? stemResidual(info, card) : null; var pending = card.hasAttribute('data-fp-ts-stemfix-pending'); var r2 = function (v) { return v == null ? null : Math.round(v * 100) / 100; }; rows.push({ 序号: i, 题号: (info.numEl ? (info.numEl.textContent || '') : '').slice(0, 12), fix: r2(card.__fpTsStemFix), 写在style: r2(parseFloat(card.style.getPropertyValue('--fp-ts-stemfix')) || 0), 已量准: (!pending && card.__fpTsStemFixHits === 0 && card.__fpTsStemFix != null), 反馈态: answerRevealed(card), 残差d: r2(d), 待复量: pending, 跳过对齐: (card.getAttribute('data-fp-ts-stemfix-noreach') === stemFixKey(card)), 键: stemFixKey(card), 键一致: card.__fpTsStemFixKey === stemFixKey(card), 自愈次数: card.__fpTsDriftHeal || 0 }); }); console.table(rows); var out = { epoch: stemFixEpoch, 共享表: stemFixByKey, 视口: window.innerWidth + 'x' + window.innerHeight, 可见性: document.visibilityState, 卡片: rows }; console.log('__fpTsDebug 现场', out); return out; }; if (cfg.enabled) { pushRootVars(); applyAll(true); } else { clearRootVars(); setStatus('已停用:页面样式已还原'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();