feat(reader): PDF 改 pdf.js 渲染+斜体 bug 根治+评论假挂载修复+排版类

【T1 PDF 内置阅读重做】iframe 对 arXiv PDF 实测 ERR_ABORTED(浏览器 PDF
插件策略差异)→ pdfjs-dist 6.4.299 客户端 canvas 渲染:只在 /papers/
动态 import(worker ?url 打包,不进全站 bundle);文档一次取回 + IO
懒渲染(rootMargin 400px)+ 页占位 aspect-ratio 防跳动 + 12s 首屏兜底 +
微信 UA 直接兜底;工具条页码/缩放/翻页/下载/arXiv/授权声明保留。红线:
PDF 由访客浏览器直取 arXiv(CORS * 实测),仓内零副本。真页实测:
13 页 canvas 全绘制、首屏墨迹像素非空、零 console 错误。

【T2 斜体排版 bug 真凶】global.css 的 `.prose-* p > em:only-child
{ display:block; text-align:center }` 图注样式按"斜体独占段"自动命中,
把所有正文独占段斜体变成块级居中。改:图注用显式 .fig-cap 驱动 + 补
em/i { display:inline; font-style:italic } 基线;media-sample 迁移新写法,
指南注明旧写法失效原因。实测独占段与行内斜体均 display:inline/italic。

【T3 评论"假挂载"真 bug】原 initArtalk 一进来就置 mounted=true——脚本
404/被拦截时把空容器误标已挂载,5s 兜底被绕过,正是"评论区一直空着"的
来源之一。改为库就绪才算成功;另加三触发路径(IO 主 + 锚点点击 + 滚到
页底 2s 兜底)、5s 失败提示+重试钮、软导航重挂、成功打
data-artalk-mounted=1 供 E2E。

【T4 文章排版类 + 安全白名单】11 个受限类(ink-1/2/3/muted/accent、
font-serif/kai/sans/mono、drop-cap、quote-line、fig-cap)全走 tokens 禁
任意 hex;新增 rehype-class-whitelist.ts:className 白名单 + 剥离 style
与 on*;实测注入用例只剩 class="ink-2",style/onclick 全无。新坑入档:
markdown raw HTML 在 hast 是 type:'raw' 节点、白名单插件看不见 span,必须
先 rehype-raw 再过滤 → 插件链 [rehypeRaw, rehypeClassWhitelist,
rehypeLinkCards]。新依赖:pdfjs-dist、rehype-raw。

车道:op-artalk(T1 6/6、T2 2/2、T3 3/3、T4 6/6);收编复验:build 46 页、
E2E 37/37、四专项 playwright 实测(PDF 13 canvas/斜体 inline/白名单净化/
评论 mounted 标记)

验证: node v26.13.0 + astro 7.4.4 构建 46 页 exit 0;E2E 37/37 PASS

Spec: none (阅读体验四件,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 15:59:28 +08:00
1 parent 328ab68f18
commit 280654f9e3
11 files changed
+621 -55

No files matched your search

+86 -2
View File
@@ -17,6 +17,10 @@ const locale = localeOf(Astro.url.pathname);
{' '}{t('pv_viewed', locale)}
</p>
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
<p class="atk-load-hint" id="artalk-retry" hidden>
<span>评论区加载失败。</span>
<button type="button" class="atk-retry-btn" id="artalk-retry-btn">重试</button>
</p>
<!-- Artalk 主题接管:--at-color-* 全套映射到站点 tokens(值均为 var(),明暗随
html[data-theme] 翻转 tokens 自动生效,一套规则双态成立)。
@@ -76,6 +80,26 @@ const locale = localeOf(Astro.url.pathname);
#artalk-container .atk-content a:hover {
text-decoration-color: var(--accent);
}
.atk-load-hint {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-2) 0 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
}
.atk-retry-btn {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-2);
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 10px;
cursor: pointer;
}
.atk-retry-btn:hover { border-color: var(--accent); }
.atk-pv-line {
font-family: var(--font-mono);
font-size: 12px;
@@ -107,9 +131,24 @@ const locale = localeOf(Astro.url.pathname);
document.addEventListener('astro:page-load', () => void bumpPv());
void bumpPv();
// ── 挂载健壮化(用户实测「总没加载出来」)──
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
const el = document.getElementById('artalk-container');
if (el) {
let mounting = false;
let mounted = false;
const retryEl = document.getElementById('artalk-retry');
const showRetry = (show: boolean) => { if (retryEl) retryEl.hidden = !show; };
const armTimeout = () => {
window.setTimeout(() => {
if (!mounted) showRetry(true);
}, 5000);
};
const mount = () => {
if (mounting || mounted) return;
mounting = true;
armTimeout();
if (document.getElementById('artalk-lib')) return initArtalk();
// 官方部署文档要求 CSS 手动引入(JS 不自动注入);此前缺失导致评论区自上线起跑在 UA 默认样式上
const cssReady = new Promise<void>((done) => {
@@ -130,6 +169,15 @@ const locale = localeOf(Astro.url.pathname);
};
const initArtalk = () => {
const lib = (window as unknown as { Artalk?: { init: (o: object) => void } }).Artalk;
// 只有库真正就绪才算挂载成功:否则脚本 404/被拦截时会把"空容器"误标成已挂载,
// 5s 兜底提示也被一并绕过(用户看到的正是"评论区一直空着")
if (!lib) {
showRetry(true);
return;
}
mounted = true;
showRetry(false);
el.dataset.artalkMounted = '1';
lib?.init({
el: '#artalk-container',
server: 'https://wait4x.online/artalk',
@@ -143,12 +191,48 @@ const locale = localeOf(Astro.url.pathname);
pvAdd: false,
});
};
// 兜底 ①:点任意指向评论区的锚点(含页内 #评论 跳转)立即挂载
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
if (!t.closest('a[href="#comments"], a[href="#artalk-container"], [data-artalk-jump]')) return;
mountNow();
});
// 兜底 ②:滚到页底 2s 仍未挂载 → 强制挂载(用户滚到底显然在找评论)
let bottomTimer = 0;
addEventListener('scroll', () => {
if (mounted || mounting) return;
const nearBottom = innerHeight + scrollY >= document.documentElement.scrollHeight - 200;
if (!nearBottom) return;
clearTimeout(bottomTimer);
bottomTimer = window.setTimeout(() => { if (!mounted) mountNow(); }, 2000);
}, { passive: true });
// 重试钮:重置脚本引用后重新挂载
document.getElementById('artalk-retry-btn')?.addEventListener('click', () => {
showRetry(false);
document.getElementById('artalk-lib')?.remove();
mounting = false;
mountNow();
});
// 软导航复核:换页后若容器在而从未挂载,重新按当前 DOM 接线
document.addEventListener('astro:page-load', () => {
const fresh = document.getElementById('artalk-container');
if (fresh && !fresh.dataset.artalkMounted) {
mounting = false;
mountNow();
}
});
// 进入视口 ~200px 前挂载;无 IO 环境直接挂
if ('IntersectionObserver' in window) {
new IntersectionObserver(
(entries, obs) => { if (entries.some((e) => e.isIntersecting)) { obs.disconnect(); mount(); } },
(entries, obs) => { if (entries.some((e) => e.isIntersecting)) { obs.disconnect(); mountNow(); } },
{ rootMargin: '200px' }
).observe(el);
} else mount();
} else mountNow();
function mountNow() {
if (mounted) return;
mount();
}
}
</script>