From 280654f9e379d956261c5ee7eb46866bef161df5 Mon Sep 17 00:00:00 2001
From: wait4xx
Date: Mon, 2 Nov 2026 15:59:28 +0800
Subject: [PATCH] =?UTF-8?q?feat(reader):=20PDF=20=E6=94=B9=20pdf.js=20?=
=?UTF-8?q?=E6=B8=B2=E6=9F=93+=E6=96=9C=E4=BD=93=20bug=20=E6=A0=B9?=
=?UTF-8?q?=E6=B2=BB+=E8=AF=84=E8=AE=BA=E5=81=87=E6=8C=82=E8=BD=BD?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D+=E6=8E=92=E7=89=88=E7=B1=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
【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
---
astro.config.mjs | 6 +-
content/posts/fix3-test.md | 34 ++++
content/posts/media-sample.md | 2 +-
docs/写作指南.md | 32 +++-
package.json | 2 +
pnpm-lock.yaml | 137 +++++++++++++++
src/components/ArtalkComments.astro | 88 +++++++++-
src/lib/rehype-class-whitelist.ts | 50 ++++++
src/pages/papers/_PaperView.astro | 264 +++++++++++++++++++++++-----
src/styles/global.css | 43 ++++-
tests/e2e.mjs | 18 +-
11 files changed, 621 insertions(+), 55 deletions(-)
create mode 100644 content/posts/fix3-test.md
create mode 100644 src/lib/rehype-class-whitelist.ts
diff --git a/astro.config.mjs b/astro.config.mjs
index 9fb1778..9eeb6bb 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -4,6 +4,8 @@ import pagefind from 'astro-pagefind';
import sitemap from '@astrojs/sitemap';
import { execSync } from 'node:child_process';
import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards';
+import rehypeClassWhitelist from './src/lib/rehype-class-whitelist';
+import rehypeRaw from 'rehype-raw';
// 构建透明(T7):commit/时间经 vite.define 注入,页脚与 /about 引用。
// 四重回退:CI 环境变量优先(GITEA_ACTIONS/GITHUB_SHA)→ 本地 git → dev 占位。
@@ -21,7 +23,9 @@ export default defineConfig({
integrations: [pagefind(), sitemap(), linkCardsReport()],
markdown: {
// 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts)
- rehypePlugins: [rehypeLinkCards],
+ // 顺序:rehypeRaw 先把 markdown 里的 raw HTML 解析成 hast element(否则白名单看不到 span),
+ // 再白名单净化(剥 style/on*/非白名单类),最后链接卡片(节点由插件自建,不经净化)
+ rehypePlugins: [rehypeRaw, rehypeClassWhitelist, rehypeLinkCards],
},
i18n: {
defaultLocale: 'zh',
diff --git a/content/posts/fix3-test.md b/content/posts/fix3-test.md
new file mode 100644
index 0000000..fd83fff
--- /dev/null
+++ b/content/posts/fix3-test.md
@@ -0,0 +1,34 @@
+---
+title: "排版能力样例"
+pubDate: 2026-11-02
+category: tech
+tags: [test, hidden]
+# 排版/斜体/白名单测试样张(fix3 车道):hidden 不列出,构建与 E2E 验证用
+draft: false
+---
+
+本篇验证三件事:斜体行内渲染、受限排版类白名单、图注类。
+
+## 斜体(应为行内,不换行不居中)
+
+这一句里有 *行内斜体强调* 夹在文字中间,它必须与正文同处一行。
+
+*这一整段是斜体独占段,它不该变成块级居中。*
+
+## 受限排版类(白名单)
+
+这一段是 ink-muted 灰字。
+这一段是霞鹜文楷字族。
+这一段是等宽字族 mono。
+
+首字下沉段落:秋天的海面像一块被风抚平的铁皮,落日把它的边缘烧红了一圈。
+
+所谓暮色,就是光慢慢地承认自己要走。
+
+## 白名单剔除(非法类与内联样式必须被剥离)
+
+这一段同时带了非法类、内联颜色与 onclick,构建后应只剩 ink-2 且 style/onclick 消失。
+
+## 图注(类驱动)
+
+这是显式图注样式(fig-cap),居中灰字。
diff --git a/content/posts/media-sample.md b/content/posts/media-sample.md
index 3f16e0f..d540986 100644
--- a/content/posts/media-sample.md
+++ b/content/posts/media-sample.md
@@ -14,7 +14,7 @@ draft: false

-*图注写法:斜体单独成段,photos 目录样片副本(1600×2400 竖幅)。*
+图注新写法:fig-cap 类,photos 目录样片副本(1600×2400 竖幅)。
## 视频(raw HTML,H.264 320×240,ffmpeg testsrc2 生成)
diff --git a/docs/写作指南.md b/docs/写作指南.md
index e3c9120..aaa00b4 100644
--- a/docs/写作指南.md
+++ b/docs/写作指南.md
@@ -22,10 +22,11 @@ content/posts/
```markdown

-*图:外滩的傍晚* ← 紧跟媒体的斜体独立段 = 图注(居中小字)
+图:外滩的傍晚
← 显式图注类(居中小字)
```
-- 图注可选;写法固定为 `*…*` 单独成段,紧跟图片
+- 图注可选;**必须用 `…
`**(2026-11-02 起)
+- ⚠️ 旧写法 `*斜体独立段*` 已不再自动当图注:此前它会把正文里所有独占成段的斜体都变成块级居中(用户实测的排版 bug),现在斜体恢复为正常行内强调
- 压缩纪律:入仓前长边 ≤2400px(可跑 `python3 scripts/photos_ingest.py`,对 posts 资产同样有效的思路,图片请自行用同规格压缩)
## 视频 / 音频
@@ -83,6 +84,33 @@ https://astro.build/
- **缓存**:结果存 `content/.link-cache.json`(入仓,保证构建确定性);条目 30 天后自动重抓。想强制刷新某条——删掉该 URL 的条目即可
- 缩略图取 `og:image`,无图不占位;图标取站点而非第三方 favicon 服务
+## 文章排版类(受限白名单)
+
+正文里可用 `` / `` 做**受限**排版。构建期有白名单过滤:**只放行下表类名,其它 class 与 `style`、`on*` 属性一律被剥离**(防注入 + 保护灰阶骨架)。
+
+| 类名 | 作用 |
+|---|---|
+| `ink-1` / `ink-2` / `ink-3` | 指定灰阶层(正文/次要/更淡) |
+| `ink-muted` | 最淡灰(注释感) |
+| `ink-accent` | 强调色(链接色,仅少量点缀) |
+| `font-serif` | 思源宋(书评正文族) |
+| `font-kai` | 霞鹜文楷(金句族) |
+| `font-sans` | 思源黑(UI/技术族) |
+| `font-mono` | 等宽族(标签/数值) |
+| `drop-cap` | 段落首字下沉(书评开篇) |
+| `quote-line` | 金句块(左侧竖线 + 文楷) |
+| `fig-cap` | 图注(居中小字) |
+
+```markdown
+次要说明
+金句可以换文楷
+
开篇段落,首字会下沉。
+所谓暮色,就是光慢慢地承认自己要走。
+```
+
+- **颜色只能走上面这些类**(内部用 tokens,亮暗双态都可读);写 `style="color:red"` 会被构建期剔除
+- 斜体就用普通 markdown `*斜体*`——行内强调,不会换行也不会居中
+
## 发布提醒
- push main → webhook 自动构建发布(约 10 秒)
diff --git a/package.json b/package.json
index f89701b..eab359e 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"astro-pagefind": "^2.0.1",
"exifr": "^7.1.3",
"lxgw-wenkai-screen-webfont": "^1.7.0",
+ "pdfjs-dist": "^6.4.299",
+ "rehype-raw": "^7.0.0",
"sharp": "^0.35.5"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7926bd7..ff4141b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,6 +38,12 @@ importers:
lxgw-wenkai-screen-webfont:
specifier: ^1.7.0
version: 1.7.0
+ pdfjs-dist:
+ specifier: ^6.4.299
+ version: 6.4.299
+ rehype-raw:
+ specifier: ^7.0.0
+ version: 7.0.0
sharp:
specifier: ^0.35.5
version: 0.35.5(@types/node@24.19.2)
@@ -427,6 +433,81 @@ packages:
'@mdx-js/mdx@3.1.1':
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
+ '@napi-rs/canvas-android-arm64@1.1.0':
+ resolution: {integrity: sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@napi-rs/canvas-darwin-arm64@1.1.0':
+ resolution: {integrity: sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/canvas-darwin-x64@1.1.0':
+ resolution: {integrity: sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@1.1.0':
+ resolution: {integrity: sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-gnu@1.1.0':
+ resolution: {integrity: sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-arm64-musl@1.1.0':
+ resolution: {integrity: sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-linux-riscv64-gnu@1.1.0':
+ resolution: {integrity: sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-gnu@1.1.0':
+ resolution: {integrity: sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-musl@1.1.0':
+ resolution: {integrity: sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-win32-arm64-msvc@1.1.0':
+ resolution: {integrity: sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/canvas-win32-x64-msvc@1.1.0':
+ resolution: {integrity: sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/canvas@1.1.0':
+ resolution: {integrity: sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==}
+ engines: {node: '>= 10'}
+
'@napi-rs/wasm-runtime@1.2.6':
resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -1369,6 +1450,10 @@ packages:
resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
engines: {node: '>=14.0.0'}
+ pdfjs-dist@6.4.299:
+ resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
+ engines: {node: '>=22.13.0 || >=24'}
+
piccolore@0.1.3:
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
@@ -2166,6 +2251,54 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@napi-rs/canvas-android-arm64@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-darwin-arm64@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-darwin-x64@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-gnu@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-musl@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-linux-riscv64-gnu@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-gnu@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-musl@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-win32-arm64-msvc@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas-win32-x64-msvc@1.1.0':
+ optional: true
+
+ '@napi-rs/canvas@1.1.0':
+ optionalDependencies:
+ '@napi-rs/canvas-android-arm64': 1.1.0
+ '@napi-rs/canvas-darwin-arm64': 1.1.0
+ '@napi-rs/canvas-darwin-x64': 1.1.0
+ '@napi-rs/canvas-linux-arm-gnueabihf': 1.1.0
+ '@napi-rs/canvas-linux-arm64-gnu': 1.1.0
+ '@napi-rs/canvas-linux-arm64-musl': 1.1.0
+ '@napi-rs/canvas-linux-riscv64-gnu': 1.1.0
+ '@napi-rs/canvas-linux-x64-gnu': 1.1.0
+ '@napi-rs/canvas-linux-x64-musl': 1.1.0
+ '@napi-rs/canvas-win32-arm64-msvc': 1.1.0
+ '@napi-rs/canvas-win32-x64-msvc': 1.1.0
+ optional: true
+
'@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
'@emnapi/core': 1.11.1
@@ -3457,6 +3590,10 @@ snapshots:
path-expression-matcher@1.6.2: {}
+ pdfjs-dist@6.4.299:
+ optionalDependencies:
+ '@napi-rs/canvas': 1.1.0
+
piccolore@0.1.3: {}
picocolors@1.1.1: {}
diff --git a/src/components/ArtalkComments.astro b/src/components/ArtalkComments.astro
index c9aa5de..af5362f 100644
--- a/src/components/ArtalkComments.astro
+++ b/src/components/ArtalkComments.astro
@@ -17,6 +17,10 @@ const locale = localeOf(Astro.url.pathname);
{' '}{t('pv_viewed', locale)}
+
+ 评论区加载失败。
+
+
-
-
+
+
+ {t('paper_page_label', locale)} – / –
+
+
+
+
+
+
+ 100%
+
+
+ {t('paper_loading', locale)}
+
-
+
+
+
+
+
{t('paper_fallback_title', locale)}
{t('paper_wechat_hint', locale)}
@@ -88,34 +99,156 @@ const p = PAPERS[paper];
-