面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
网页内容导出(一):PDF 与 @media print 的分层处理

网页内容导出(一):PDF 与 @media print 的分层处理

2026年6月9日1,6085分钟
✦AI 生成摘要

window.print() 背后是一套完整的 CSS 分层:隐藏交互元素、把宽屏侧栏批注转为行内注释、保留代码块颜色、处理加密内容。记录本站 PDF 导出的完整样式方案。


目录
  • 1. 两种「离开屏幕」的需求
  • 2. 导出 PDF:@media print 的分层处理
  • 2.1 基础重置与隐藏
  • 2.2 批注在 PDF 里的形态
  • 2.3 代码块与有色元素的颜色保留
  • 2.4 打印专用目录
  • 2.5 哲思页的定向打印
  • 3. 加密内容的处理
  • 3.1 笔记页:结构性隐性守卫
  • 3.2 哲思页:条件渲染守卫
  • 4. 小结
目录
  • 1. 两种「离开屏幕」的需求
  • 2. 导出 PDF:@media print 的分层处理
  • 2.1 基础重置与隐藏
  • 2.2 批注在 PDF 里的形态
  • 2.3 代码块与有色元素的颜色保留
  • 2.4 打印专用目录
  • 2.5 哲思页的定向打印
  • 3. 加密内容的处理
  • 3.1 笔记页:结构性隐性守卫
  • 3.2 哲思页:条件渲染守卫
  • 4. 小结
目录
  1. 1. 两种「离开屏幕」的需求
  2. 2. 导出 PDF:@media print 的分层处理
  3. 2.1 基础重置与隐藏
  4. 2.2 批注在 PDF 里的形态
  5. 2.3 代码块与有色元素的颜色保留
  6. 2.4 打印专用目录
  7. 2.5 哲思页的定向打印
  8. 3. 加密内容的处理
  9. 3.1 笔记页:结构性隐性守卫
  10. 3.2 哲思页:条件渲染守卫
  11. 4. 小结
前端Next.js
相关文章
  • 01
    网页内容导出(二):图片导出的 DOM 重建与批注内联2026/06
  • 02
    Prefetch 的完整图景2026/07
  • 03
    用 View Transitions + Skeleton 消灭页面跳转的割裂感2026/07
← 上一篇从阅读到知识(一):个人阅读档案的设计与实现
下一篇 →网页内容导出(二):图片导出的 DOM 重建与批注内联

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

1. 两种「离开屏幕」的需求

本站的文章有两个导出入口:导出 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 重建与批注内联逻辑在下篇展开。


2. 导出 PDF:@media print 的分层处理

2.1 基础重置与隐藏

打印样式的第一层是全局重置,告诉浏览器不要擅自调整颜色:

globals.css
@media print {
  * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }

浏览器默认在打印时会去掉背景色和部分前景色(「墨水节省」模式),print-color-adjust: exact 关闭这个行为,让代码块高亮、diff 颜色、批注颜色都原样保留。

第二层是隐藏屏幕专用的交互元素:

globals.css
  .site-nav,
  .article-topbar,
  .article-actions,
  .toc-sidebar,
  .toc-inline,
  .annotation-sidebar,
  footer,
  .ProseLightbox
  { display: none !important; }

注意 .annotation-sidebar 在这里被整体隐藏——宽屏下悬浮在右侧的批注卡片不适合直接出现在 PDF 里(绝对定位会叠在正文上,或者被截断)。批注的 PDF 呈现方式是另一套机制,见下节。

2.2 批注在 PDF 里的形态

Revision 组件在 JSX 里预先渲染了一个打印专用节点:

Revision.tsx
{(original || note) && (
  <span className="revision-print-note">
    {original && <span style={{ textDecoration: "line-through" }}>{original}</span>}
    {note && `(Chen 注:${note})`}
  </span>
)}

这个 <span> 平时是 display: none,打印时显示:

globals.css
.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 干预。对比图片导出需要在运行时把侧栏卡片克隆并重新插入正文,这条路要干净得多。

2.3 代码块与有色元素的颜色保留

PDF 里的代码块需要两个处理:

globals.css
@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 并固定颜色值:

globals.css
@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 值更可靠。

2.4 打印专用目录

屏幕上目录是折叠的浮动侧栏,不适合 PDF。为此在正文前额外插入了一个打印专用目录:

NoteLayout.tsx
{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 最后一页底部还有一个打印专用页脚:

NoteLayout.tsx
<div className="print-footer">
  <span>© {new Date().getFullYear()} Vic Chen · 面向哲思的编程与架构</span>
  <span>CC BY-NC-ND 4.0</span>
</div>

同样是屏幕隐藏、打印显示。

2.5 哲思页的定向打印

哲思页面有多张卡片,打印时需要只打印当前展开的那一张,而不是整页。这通过在 ReflectionCard 里挂载一个 class 实现:

ReflectionCard.tsx
const handlePrint = () => {
  document.body.classList.add("printing-reflection");
  window.print();
  window.addEventListener("afterprint", () => {
    document.body.classList.remove("printing-reflection");
  }, { once: true });
};

CSS 用这个 class 做精确隐藏:

globals.css
@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 } 自动移除监听器。


3. 加密内容的处理

本站的笔记页和哲思页都支持加密——服务端用 AES-GCM 加密正文,只下发密文载荷,客户端本地解密后渲染。

3.1 笔记页:结构性隐性守卫

笔记页的导出按钮在 NoteLayout 里,始终渲染,不感知是否加密。对于打印,加锁时 ProtectedNote 渲染的是密码输入表单,window.print() 触发后 PDF 里只有这个表单,没有可读内容。明文正文从未出现在服务端 HTML 或 RSC 载荷里,也就不会意外进入 PDF。

图片导出的守卫机制类似,依赖同一个 DOM 结构特征——下篇会展开。

3.2 哲思页:条件渲染守卫

哲思页的处理更直接——PrintBar(含 PDF 和图片两个导出按钮)本身就在条件渲染分支里:

ReflectionCard.tsx
{/* 普通内容:非加密,展开且已序列化 */}
{!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 组件里,可以直接跟着内容状态走。


4. 小结

PDF 导出的逻辑基本上是纯 CSS 的——@media print 里的规则覆盖了批注形态、颜色保留、分页控制、专用目录和页脚的全部细节,JavaScript 只需要调用 window.print() 和挂载一个临时 class。

图片导出没有这么省心。modern-screenshot 需要手动重建截图内容:选取范围、处理 CSS 变量、把宽屏侧栏的批注卡片克隆后内联插入正文克隆体——这些都是命令式的 DOM 操作,没有 CSS 声明式那种「写好规则,浏览器执行」的简洁。下篇记录这个过程。

background
:
transparent
!important
;
border-bottom: 1px solid #999 !important;
}
}
!important
;
}
}
exact
;
print-color-adjust: exact;
}
.follow-mark {
background: #f5f3ff !important;
border-bottom-color: #7c3aed !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
=
{
`print-toc-item depth-${
it
.
depth
}`
}>
<a href={`#${it.id}`}>{it.text}</a>
</li>
))}
</ol>
</nav>
)}
.printing-reflection
.reflection-timeline-gutter
{
display: none !important;
}
body.printing-reflection .reflection-card-root {
border: none !important;
box-shadow: none !important;
}
}
/* 加密内容:已解锁展开 */
}
{isUnlocked && open && (
<div className="prose reflection-prose"
dangerouslySetInnerHTML={{ __html: html! }} />
)}
{isUnlocked && open && (
<div className="prose reflection-prose">
<PrintBar title={meta.title} cardId={meta.date} />
</div>
)}