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:
1 parent
328ab68f18
commit
280654f9e3
11 files changed
+621
-55
No files matched your search
@@ -1,9 +1,11 @@
|
||||
---
|
||||
// 论文内置阅读视图(非路由共享件,_ 前缀不成页):双语薄壳 papers/agensh.astro 与
|
||||
// en/papers/agensh.astro 共用。许可证红线:arXiv nonexclusive-dist 1.0 禁止第三方
|
||||
// 再分发——PDF 只经 iframe 指向 arXiv 原站,仓内不存任何 PDF 副本。
|
||||
// 微信内置浏览器内嵌 PDF 基本不可用:UA 检测在 head 前置(无闪烁),微信下不发起
|
||||
// iframe 请求,直接呈现兜底卡(含"右上角用浏览器打开"指引)。
|
||||
// en/papers/agensh.astro 共用。
|
||||
// 许可证红线:arXiv nonexclusive-dist 1.0 禁止第三方再分发——PDF 只由**访客浏览器直取
|
||||
// arXiv**(响应带 access-control-allow-origin: *),仓内不存任何 PDF 副本。
|
||||
// 渲染改用 pdf.js canvas 逐页画(iframe 方案实测 arXiv PDF 请求被浏览器 PDF 插件策略
|
||||
// ERR_ABORTED,无法内嵌全文);pdfjs 只在 /papers/ 页动态 import,不进全站 bundle。
|
||||
// 微信内置浏览器:不发起 PDF 请求,直接兜底卡(含"右上角用浏览器打开"指引)。
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
@@ -57,17 +59,26 @@ const p = PAPERS[paper];
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<!-- iframe 无初始 src:脚本确认非微信后才挂载,微信内不产生无用请求 -->
|
||||
<div class="paper-stage">
|
||||
<iframe
|
||||
id="paper-frame"
|
||||
class="paper-frame"
|
||||
data-src={p.pdf}
|
||||
title={p.alt[locale]}
|
||||
allow="fullscreen"
|
||||
></iframe>
|
||||
<!-- 阅读工具条:页码 / 上一下一页 / 缩放 -->
|
||||
<div class="paper-toolbar" id="paper-toolbar" hidden>
|
||||
<span class="pt-page" id="paper-page-label">{t('paper_page_label', locale)} – / –</span>
|
||||
<span class="pt-group">
|
||||
<button type="button" class="pt-btn" id="paper-prev" aria-label={t('paper_prev_page', locale)}>‹</button>
|
||||
<button type="button" class="pt-btn" id="paper-next" aria-label={t('paper_next_page', locale)}>›</button>
|
||||
</span>
|
||||
<span class="pt-group">
|
||||
<button type="button" class="pt-btn" id="paper-zoom-out" aria-label={t('paper_zoom_out', locale)}>−</button>
|
||||
<span class="pt-zoom" id="paper-zoom-label">100%</span>
|
||||
<button type="button" class="pt-btn" id="paper-zoom-in" aria-label={t('paper_zoom_in', locale)}>+</button>
|
||||
</span>
|
||||
<span class="pt-status" id="paper-status">{t('paper_loading', locale)}</span>
|
||||
</div>
|
||||
|
||||
<!-- 兜底卡:微信 UA(JS 加 is-wechat)或 iframe 加载超时后显形 -->
|
||||
<div class="paper-stage" id="paper-stage">
|
||||
<!-- canvas 逐页容器(pdf.js 渲染;脚本按页懒创建 canvas) -->
|
||||
<div class="paper-canvas-wrap" id="paper-canvas-wrap" data-pdf={p.pdf} role="document" aria-label={p.alt[locale]}></div>
|
||||
|
||||
<!-- 兜底卡:微信 UA / 加载失败或超时 / 无 JS 时显形 -->
|
||||
<div class="paper-fallback" id="paper-fallback" hidden>
|
||||
<p class="paper-fallback-title">{t('paper_fallback_title', locale)}</p>
|
||||
<p class="paper-fallback-hint" data-wechat-only hidden>{t('paper_wechat_hint', locale)}</p>
|
||||
@@ -88,34 +99,156 @@ const p = PAPERS[paper];
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline data-astro-rerun>
|
||||
// data-astro-rerun:ClientRouter 软导航后内联脚本默认不重跑,不加则从别的页面切进
|
||||
// 本页时 iframe 永不挂载(ThemeScript 同款坑,2026-10-25 已记录)
|
||||
(function () {
|
||||
var frame = document.getElementById('paper-frame');
|
||||
if (!frame) return;
|
||||
var fallback = document.getElementById('paper-fallback');
|
||||
var wechat = /MicroMessenger/i.test(navigator.userAgent);
|
||||
document.documentElement.classList.toggle('is-wechat', wechat);
|
||||
<script>
|
||||
// pdf.js 客户端渲染:文档一次取回,页面 canvas 按 IntersectionObserver 懒渲染(长文不爆内存)。
|
||||
// 软导航换页后按当前 DOM 重新接线(astro:page-load);任何失败一律显兜底卡,不阻塞阅读。
|
||||
const boot = async () => {
|
||||
if (!/^\/(?:en\/)?papers\/[^/]+\/?$/.test(location.pathname)) return;
|
||||
const wrap = document.getElementById('paper-canvas-wrap') as HTMLElement | null;
|
||||
const toolbar = document.getElementById('paper-toolbar') as HTMLElement | null;
|
||||
const fallback = document.getElementById('paper-fallback') as HTMLElement | null;
|
||||
const statusEl = document.getElementById('paper-status') as HTMLElement | null;
|
||||
const pageLabel = document.getElementById('paper-page-label') as HTMLElement | null;
|
||||
const zoomLabel = document.getElementById('paper-zoom-label') as HTMLElement | null;
|
||||
if (!wrap || !fallback || wrap.dataset.paperBooted === '1') return;
|
||||
wrap.dataset.paperBooted = '1';
|
||||
|
||||
function showFallback(withWechatHint) {
|
||||
if (!fallback) return;
|
||||
const showFallback = (wechat = false) => {
|
||||
fallback.hidden = false;
|
||||
var hint = fallback.querySelector('[data-wechat-only]');
|
||||
if (hint) hint.hidden = !withWechatHint;
|
||||
}
|
||||
document.documentElement.classList.toggle('is-wechat', wechat);
|
||||
const hint = fallback.querySelector('[data-wechat-only]') as HTMLElement | null;
|
||||
if (hint) hint.hidden = !wechat;
|
||||
};
|
||||
const setStatus = (text: string) => {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = text;
|
||||
statusEl.hidden = text === '';
|
||||
};
|
||||
|
||||
if (wechat) {
|
||||
// 微信 webview 内嵌 PDF 基本不可用:不挂载 iframe,直接兜底 + 浏览器指引
|
||||
// 微信内置浏览器:不发起 PDF 请求(渲染必然不可用,白等带宽)
|
||||
if (/MicroMessenger/i.test(navigator.userAgent)) {
|
||||
showFallback(true);
|
||||
return;
|
||||
}
|
||||
var settled = false;
|
||||
frame.addEventListener('load', function () { settled = true; });
|
||||
frame.src = frame.dataset.src;
|
||||
// arXiv 现无 X-Frame-Options(实测 2026-11-01),超时兜底只作弱网/拦截保险
|
||||
setTimeout(function () { if (!settled) showFallback(false); }, 9000);
|
||||
})();
|
||||
|
||||
let doc: any = null;
|
||||
let zoom = 1;
|
||||
const pageEls: { el: HTMLDivElement; canvas: HTMLCanvasElement; page: number; rendered: boolean; ratio: number }[] = [];
|
||||
let io: IntersectionObserver | null = null;
|
||||
let renderedAny = false;
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
console.warn('[paper] 渲染失败,回落兜底卡', err);
|
||||
showFallback(false);
|
||||
};
|
||||
// 12s 内连第一页都没画出来就兜底(弱网/被拦截)
|
||||
const guard = window.setTimeout(() => { if (!renderedAny) showFallback(false); }, 12000);
|
||||
|
||||
try {
|
||||
const pdfjs: any = await import('pdfjs-dist');
|
||||
const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
doc = await pdfjs.getDocument({ url: wrap.dataset.pdf }).promise;
|
||||
window.clearTimeout(guard);
|
||||
setStatus('');
|
||||
if (toolbar) toolbar.hidden = false;
|
||||
|
||||
// 先建全部页占位(页高按第一页比例估算,避免滚动跳动),IO 到达再真正 render
|
||||
const first = await doc.getPage(1);
|
||||
const baseVp = first.getViewport({ scale: 1 });
|
||||
const wrapWidth = wrap.clientWidth || 720;
|
||||
const renderScale = () => (wrapWidth / baseVp.width) * zoom * (window.devicePixelRatio || 1);
|
||||
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'paper-page';
|
||||
el.dataset.page = String(n);
|
||||
const canvas = document.createElement('canvas');
|
||||
el.appendChild(canvas);
|
||||
wrap.appendChild(el);
|
||||
pageEls.push({ el, canvas, page: n, rendered: false, ratio: baseVp.height / baseVp.width });
|
||||
el.style.aspectRatio = `${baseVp.width} / ${baseVp.height}`;
|
||||
}
|
||||
|
||||
const renderPage = async (entry: (typeof pageEls)[number]) => {
|
||||
if (entry.rendered) return;
|
||||
entry.rendered = true;
|
||||
const page = await doc.getPage(entry.page);
|
||||
const vp = page.getViewport({ scale: renderScale() });
|
||||
const c = entry.canvas;
|
||||
c.width = vp.width;
|
||||
c.height = vp.height;
|
||||
c.style.width = '100%';
|
||||
c.style.height = 'auto';
|
||||
await page.render({ canvasContext: c.getContext('2d')!, viewport: vp, canvas: c }).promise;
|
||||
entry.el.style.aspectRatio = '';
|
||||
if (!renderedAny) {
|
||||
renderedAny = true;
|
||||
setStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
const entry = pageEls.find((p) => p.el === e.target);
|
||||
if (entry) void renderPage(entry);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '400px 0px' },
|
||||
);
|
||||
pageEls.forEach((p) => io!.observe(p.el));
|
||||
|
||||
// 页码跟随滚动(页中点越过视口 40% 即视为当前页)
|
||||
const currentPage = () => {
|
||||
const mid = window.scrollY + window.innerHeight * 0.4;
|
||||
for (const p of pageEls) {
|
||||
if (p.el.getBoundingClientRect().bottom + window.scrollY > mid) return p.page;
|
||||
}
|
||||
return pageEls.length;
|
||||
};
|
||||
const label = () => {
|
||||
if (pageLabel) pageLabel.textContent = `${currentPage()} / ${doc.numPages}`;
|
||||
};
|
||||
addEventListener('scroll', label, { passive: true });
|
||||
label();
|
||||
|
||||
const gotoPage = (n: number) => {
|
||||
const target = pageEls[Math.max(0, Math.min(pageEls.length - 1, n - 1))];
|
||||
target?.el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
};
|
||||
document.getElementById('paper-prev')?.addEventListener('click', () => gotoPage(currentPage() - 1));
|
||||
document.getElementById('paper-next')?.addEventListener('click', () => gotoPage(currentPage() + 1));
|
||||
|
||||
const rerenderVisible = async () => {
|
||||
if (zoomLabel) zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
||||
// 仅重绘当前视口内的页(其余等 IO 重新触发)
|
||||
for (const p of pageEls) {
|
||||
const r = p.el.getBoundingClientRect();
|
||||
if (r.bottom > -200 && r.top < window.innerHeight + 200) {
|
||||
p.rendered = false;
|
||||
await renderPage(p);
|
||||
} else {
|
||||
p.rendered = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.getElementById('paper-zoom-in')?.addEventListener('click', () => {
|
||||
zoom = Math.min(2.5, zoom + 0.25);
|
||||
void rerenderVisible();
|
||||
});
|
||||
document.getElementById('paper-zoom-out')?.addEventListener('click', () => {
|
||||
zoom = Math.max(0.6, zoom - 0.25);
|
||||
void rerenderVisible();
|
||||
});
|
||||
} catch (err) {
|
||||
window.clearTimeout(guard);
|
||||
fail(err);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('astro:page-load', () => void boot());
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -161,19 +294,62 @@ const p = PAPERS[paper];
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paper-download:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* 工具条:灰阶克制,mono 小字 */
|
||||
.paper-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.paper-toolbar[hidden] { display: none; }
|
||||
.pt-group { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.pt-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-soft);
|
||||
color: var(--ink-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pt-btn:hover { border-color: var(--accent); }
|
||||
.pt-page { color: var(--ink-2); }
|
||||
.pt-zoom { min-width: 3.2em; text-align: center; color: var(--ink-2); }
|
||||
.pt-status { color: var(--ink-4); }
|
||||
|
||||
.paper-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.paper-frame {
|
||||
width: 100%;
|
||||
/* canvas 页容器:纵向滚动,页间留缝,纸色底 */
|
||||
.paper-canvas-wrap {
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: var(--surface-2, #f4f4f2);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--space-3) 0;
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
/* 微信内不挂 iframe,避免空白框占位 */
|
||||
:global(html.is-wechat) .paper-frame { display: none; }
|
||||
.paper-canvas-wrap:empty { min-height: 12rem; }
|
||||
.paper-page {
|
||||
width: min(100%, calc(var(--measure) + 80px));
|
||||
margin: 0 auto var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.paper-page canvas { display: block; width: 100%; }
|
||||
.paper-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -184,9 +360,9 @@ const p = PAPERS[paper];
|
||||
gap: var(--space-2);
|
||||
text-align: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-2, #f4f4f2);
|
||||
background: var(--bg-soft);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius, 8px);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.paper-fallback[hidden] { display: none; }
|
||||
.paper-fallback-title { font-weight: 600; margin: 0; }
|
||||
|
||||
Reference in new issue
Block a user