window.print() 背后是一套完整的 CSS 分层:隐藏交互元素、把宽屏侧栏批注转为行内注释、保留代码块颜色、处理加密内容。记录本站 PDF 导出的完整样式方案。
本站的文章有两个导出入口:导出 PDF 和导出为图片,背后是完全不同的技术路径。
PDF 用的是 window.print()——调用浏览器原生打印对话框,用户可以选择「存储为 PDF」。控制权在 CSS:通过 @media print 媒体查询声明每个元素在打印时应该如何呈现,浏览器负责执行。
图片用的是 modern-screenshot——一个把 DOM 序列化为 SVG foreignObject 再转 canvas 的库。控制权在 JavaScript:需要手动选取截图范围、处理 CSS 变量、把分散在不同 DOM 树里的内容重组进同一张图。
两者面对同一个核心挑战:本站的批注系统。文章里的 <Revision> 组件会把批注注册到 RevisionContext,宽屏下由 AnnotationSidebar 渲染为页面右侧的浮动卡片——这些卡片绝对定位,既不在 article 里,也不在 header 里,和正文是平级的独立 DOM 节点。两种导出都必须单独处理它们。
本篇记录 PDF 导出的处理方式;图片导出的 DOM 重建与批注内联逻辑在下篇展开。
打印样式的第一层是全局重置,告诉浏览器不要擅自调整颜色:
@media print {
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }浏览器默认在打印时会去掉背景色和部分前景色(「墨水节省」模式),print-color-adjust: exact 关闭这个行为,让代码块高亮、diff 颜色、批注颜色都原样保留。
第二层是隐藏屏幕专用的交互元素:
.site-nav,
.article-topbar,
.article-actions,
.toc-sidebar,
.toc-inline,
.annotation-sidebar,
footer,
.ProseLightbox
{ display: none !important; }注意 .annotation-sidebar 在这里被整体隐藏——宽屏下悬浮在右侧的批注卡片不适合直接出现在 PDF 里(绝对定位会叠在正文上,或者被截断)。批注的 PDF 呈现方式是另一套机制,见下节。
Revision 组件在 JSX 里预先渲染了一个打印专用节点:
{(original || note) && (
<span className="revision-print-note">
{original && <span style={{ textDecoration: "line-through" }}>{original}</span>}
{note && `(Chen 注:${note})`}
</span>
)}这个 <span> 平时是 display: none,打印时显示:
.revision-print-note { display: none; }
@media print {
.revision-print-note {
display: inline;
font-size: 0.85em;
color: #666;
font-style: italic;
}
.revision-print-note del { color: #c00; }
mark {
效果是批注以「(Chen 注:……)」的形式内嵌在被批注文字后面,删除线原文是红色,不需要视觉上独立的卡片,逻辑上也完整。mark 的高亮背景同时去掉,只留下一条下划线作为「这里曾被修改」的线索。
这个设计的关键在于:批注节点是在组件渲染时就写进 DOM 的,只是被 CSS 隐藏。打印时只要把 display: none 改成 display: inline 就能还原,不需要任何 JavaScript 干预。对比图片导出需要在运行时把侧栏卡片克隆并重新插入正文,这条路要干净得多。
PDF 里的代码块需要两个处理:
@media print {
.prose pre {
background: #f5f5f5 !important;
border: 0.5pt solid #ddd !important;
font-size: 8.5pt;
page-break-inside: avoid;
}
.prose pre code span {
color: var(--shiki-light)
Shiki(Chen 注:Shiki 是一个基于 TextMate 语法和 VS Code 主题的代码语法高亮库,rehype-pretty-code 在 MDX 处理管道里用它把代码块转成带样式的 HTML。) 的语法高亮在每个 token 上写了两个自定义属性:--shiki-light 和 --shiki-dark,对应浅色和深色主题。打印时强制用 --shiki-light,同时把背景色统一为浅灰,避免深色代码块在 PDF 里大面积黑底。page-break-inside: avoid 阻止代码块被分页截断。
对于 Diff 对比块和 Follow 批注这类有颜色语义的元素,也要单独指定 print-color-adjust: exact 并固定颜色值:
@media print {
.diff-del {
background: #ffd7d5 !important;
color: #c0392b !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.diff-add {
background: #d4f5d4 !important;
color: #1a6b2a !important;
-webkit-print-color-adjust:
不能直接用 var(--accent) 这类 CSS 变量,因为 PDF 渲染时根元素上的主题变量未必能正确解析(取决于浏览器实现),固定的 hex 值更可靠。
屏幕上目录是折叠的浮动侧栏,不适合 PDF。为此在正文前额外插入了一个打印专用目录:
{showToc && (
<nav className="print-toc" aria-label="目录">
<div className="print-toc-title">目录</div>
<ol className="print-toc-list">
{toc!.map((it) => (
<li key={it.id} className
这个 <nav> 平时 display: none,打印时出现,放在 <article> 之前。PDF 里的目录链接理论上是可点击跳转的(用 href="#id" 锚点),不过不同 PDF 阅读器对页内锚点的支持参差不齐,当作视觉目录更稳妥。
PDF 最后一页底部还有一个打印专用页脚:
<div className="print-footer">
<span>© {new Date().getFullYear()} Vic Chen · 面向哲思的编程与架构</span>
<span>CC BY-NC-ND 4.0</span>
</div>同样是屏幕隐藏、打印显示。
哲思页面有多张卡片,打印时需要只打印当前展开的那一张,而不是整页。这通过在 ReflectionCard 里挂载一个 class 实现:
const handlePrint = () => {
document.body.classList.add("printing-reflection");
window.print();
window.addEventListener("afterprint", () => {
document.body.classList.remove("printing-reflection");
}, { once: true });
};CSS 用这个 class 做精确隐藏:
@media print {
body.printing-reflection .site-nav,
body.printing-reflection footer,
body.printing-reflection .reflection-print-bar,
body.printing-reflection .reflections-page-header,
body.printing-reflection .reflections-month-label,
body.printing-reflection [data-print-hidden="true"] {
display: none !important;
}
body
data-print-hidden="true" 是打在其他卡片上的属性,打印时整体隐藏,只留下当前展开的这一张。afterprint 事件在打印对话框关闭后触发(取消也触发),用 { once: true } 自动移除监听器。
本站的笔记页和哲思页都支持加密——服务端用 AES-GCM 加密正文,只下发密文载荷,客户端本地解密后渲染。
笔记页的导出按钮在 NoteLayout 里,始终渲染,不感知是否加密。对于打印,加锁时 ProtectedNote 渲染的是密码输入表单,window.print() 触发后 PDF 里只有这个表单,没有可读内容。明文正文从未出现在服务端 HTML 或 RSC 载荷里,也就不会意外进入 PDF。
图片导出的守卫机制类似,依赖同一个 DOM 结构特征——下篇会展开。
哲思页的处理更直接——PrintBar(含 PDF 和图片两个导出按钮)本身就在条件渲染分支里:
{/* 普通内容:非加密,展开且已序列化 */}
{!isEncrypted && open && serialized && (
<div className="prose reflection-prose">
<MDXRemote {...serialized} components={mdxComponents} />
<PrintBar title={meta.title} cardId={meta.date} />
</div>
)}
{
锁定状态下(isEncrypted && !isUnlocked)两个分支都不进入,PrintBar 不存在于 DOM,导出按钮从视觉上也不出现,无需任何额外判断。
两种方式的差异来自组件结构:文章页的工具栏是 layout 级的,与正文渲染分离,只能依赖 DOM 结构做隐性守卫;Reflections 的工具栏和内容在同一个 ReflectionCard 组件里,可以直接跟着内容状态走。
PDF 导出的逻辑基本上是纯 CSS 的——@media print 里的规则覆盖了批注形态、颜色保留、分页控制、专用目录和页脚的全部细节,JavaScript 只需要调用 window.print() 和挂载一个临时 class。
图片导出没有这么省心。modern-screenshot 需要手动重建截图内容:选取范围、处理 CSS 变量、把宽屏侧栏的批注卡片克隆后内联插入正文克隆体——这些都是命令式的 DOM 操作,没有 CSS 声明式那种「写好规则,浏览器执行」的简洁。下篇记录这个过程。