在幻灯片里放漂亮的代码高亮,我走了多远的弯路

4 阅读11分钟

本文是对Highlighted code in slides 的编译整理


一个完美主义者的烦恼

这一切,都源于一个简单的执念:我要在演示幻灯片里放语法高亮的代码。

不是这种——

let addr: SocketAddr = config.address.parse()?;
let ln = TcpListener::bind(&addr).await?;
info!("🦊 {}", config.base_url);

(纯文本,一个颜色,毫无灵魂)

而是这种——

let addr: SocketAddr = config.address.parse()?;
let ln = TcpListener::bind(&addr).await?;
info!("🦊 {}", config.base_url);

(关键字、类型、宏各自带着精心挑选的颜色,让人一眼就能分辨代码结构)

你可能会说:装个 highlight.js,完事了。

但我不是那种人。

我有自己的博客引擎,有自己的 Markdown 处理流水线,那条流水线会调用 tree-sitter,用的是我多年来像诗人收集韵脚一样积攒下来的语法规则集。然后我会生成精简的 HTML 标记,让读者的浏览器不至于在看到一篇文章时就崩溃。

所以问题不是"要不要高亮",而是"怎么把我博客上已经高亮好的代码,原封不动地搬进幻灯片"。


市面上的方案,都不够好

当我开始准备 P99 CONF 的演讲幻灯片时,我去查了一下现有方案。

Google Slides 有一些代码高亮插件,但问题是:

  • 大多数插件其实是给 Google Docs(另一个产品)设计的,不是 Slides;
  • 高亮一段代码要花好几秒,因为插件代码像是跑在山景城某台共享咖啡机上;
  • 默认配色方案,说实话,惨不忍睹——关键字是紫色加粗,其他部分是某种蓝绿色,宏是绿色……一锅大杂烩。

然后我注意到了一件事——

当你从网页上复制文字,然后粘贴到 Google Slides,格式会被保留下来。

这个特性通常令人抓狂,你需要专门用"粘贴并匹配样式"来避免它(macOS 上的快捷键是 Option-Shift-Command-V,堪称手指瑜伽)。

但反过来想:如果我主动生成一段精心构造的 HTML,专门用来粘贴进 Slides,会怎样?

这个思路并不新鲜,网上已经有 Slides Code Highlighter 之类的工具。它们挺好用,但我不满足——我想用的是我自己博客上那套配色,那是我亲手调出来的颜色,我熟悉每一个色值。

而且我有一个更实际的理由:我现在写文章和做视频是同步进行的,先写文章,再把文章里的代码片段搬进幻灯片。博客上的代码已经高亮好了,就在那里,等着我——为什么不直接拿来用?


加一个按钮

于是我给自己的博客加了一个"复制"按钮。

这个按钮只有我登录后才能看到,普通读者看不见。

它的功能不是把代码复制成纯文本(GitHub 上那种),而是连同 HTML 标记一起复制,专门用来粘贴进幻灯片。

剪贴板 API 的门道

实现这个按钮,首先要搞清楚浏览器剪贴板的工作方式。

当用户选中文字并按下 Ctrl+CCmd+C 时,会触发一个 copy 事件。你可以监听这个事件,覆盖写入剪贴板的内容。

(顺带一提:这也是某些网站能往你剪贴板里塞"此内容受版权保护"字样的原因。)

你可以调用 preventDefault() 阻止默认的复制行为,但你没法读取剪贴板里原有的内容——浏览器出于安全考虑不允许这样做。

你也可以用 document.execCommand("copy") 模拟按键操作,虽然已经被标记为废弃,但各浏览器都还支持。同样,你可以在 copy 事件里覆盖写入内容,但依然无法读取原有内容。

真正干净的解法是用现代的 Clipboard API

async function writeToClipboard() {
  const text = "Hello, world!";
  const html = "<h1>Hello, world!</h1>";

  await navigator.clipboard.write([
    new ClipboardItem({
      'text/plain': new Blob([text], { type: 'text/plain' }),
      'text/html': new Blob([html], { type: 'text/html' })
    })
  ]);

  console.log("内容已以纯文本和 HTML 两种格式写入剪贴板");
}

Clipboard API 的精妙之处在于,它允许你同时以多种 MIME 类型写入剪贴板——这和真实的操作系统剪贴板工作方式完全吻合。不同的应用程序粘贴时,会各自取用它能理解的那种格式。

用 macOS 上 Sindre Sorhus 的 Pasteboard Viewer 可以看到剪贴板里实际装了什么:

  • 纯文本区里是 Hello, world!
  • HTML 区里是包含内联样式的完整 HTML 片段

不同浏览器(Safari、Firefox、Chromium)会在我们的 HTML payload 外面套上各自略有差异的包装——这有点烦人,但大局无碍。


计算样式:真正的挑战

现在问题来了。

当浏览器在 copy 事件触发时把选中内容序列化成 HTML,它做了一件有趣的事:会把计算后的样式写成内联的 style 属性。

看起来很棒——但它做得不够彻底,或者说做得有问题。

比如,对于一个链接,浏览器可能生成这样的样式:

color: light-dark(rgb(232, 12, 12), rgb(255, 116, 116));

light-dark() 是一个 CSS 函数,它让浏览器根据当前的深色/浅色模式,在两个颜色之间自动选择。但当这个值被粘贴进 Google Slides 时,Slides 并不认识 light-dark() 这个函数,于是颜色就消失了,文字变成了默认的黑色。

而如果我们直接取 element.outerHTML,得到的又是不带任何内联样式的原始 HTML:

<div class="bottom-nav-previous">
    Looking for <a href="/">the homepage</a>?
</div>

class 名对于粘贴目标来说毫无意义,样式也不见了。

好在浏览器提供了一个接口——computedStyleMap()(Firefox 除外,不知道为什么)——可以获取元素在当前状态下真正生效的样式值。

// 在深色模式下,这行代码会返回 rgb(255, 116, 116)
$0.querySelector("a").computedStyleMap().get("color").toString()

这样就能拿到当前模式下实际渲染的颜色,而不是 light-dark() 这个函数本身。


完整实现代码

有了思路,代码就顺理成章了:

let copyToClipboardInner = async (source) => {
  // true = 深拷贝
  let target = source.cloneNode(true);

  let process = ({ source, target }) => {
    target.removeAttribute('class');
    target.removeAttribute('style');

    // 博客里用 <i> 标签来节省字节,但粘贴到外部时
    // <i> 会带上斜体默认样式,所以转换成 <span>
    if (target.tagName.toLowerCase() === 'i') {
      let span = document.createElement('span');
      span.innerHTML = target.innerHTML;
      target.parentNode.replaceChild(span, target);
      target = span;
    }

    // CSS 属性在 JS 里是驼峰命名,而不是 kebab-case
    let computedStyle = source.computedStyleMap();
    target.style.color = normalizeColor(computedStyle.get('color').toString());
    target.style.fontWeight = computedStyle.get('font-weight').toString();

    for (let i = 0; i < source.children.length; i++) {
      process({
        source: source.children[i],
        target: target.children[i]
      });
    }
  }
  process({source, target});

  let wrapper = document.createElement("div");
  let params = new URLSearchParams(window.location.search);
  wrapper.style.fontSize = params.get('fontsize') || "16pt";
  wrapper.style.fontFamily = params.get('fontfamily') || "Source Code Pro";
  wrapper.appendChild(target);

  const clipboardItem = new ClipboardItem({
    'text/html': new Blob([(wrapper.outerHTML)], { type: 'text/html' }),
    'text/plain': new Blob([(wrapper.innerText)], { type: 'text/plain' })
  });

  // 注意:即使 write 是异步的,这段代码也必须在用户操作的响应函数里执行
  // 不能先发网络请求再写剪贴板
  await navigator.clipboard.write([clipboardItem]);
};

这段代码做了几件事:

  1. 深拷贝 DOM 节点,在副本上操作,不影响原始页面;
  2. 去掉 class 和 style 属性,因为它们在目标环境里没有意义;
  3. <i> 标签换成 <span>,防止斜体默认样式干扰;
  4. 从计算样式里提取颜色和字重,写成内联样式;
  5. 递归处理所有子元素,保证整段代码块每个 token 的颜色都被正确提取;
  6. 用 URL 参数支持自定义字体大小和字体族,方便在不同尺寸的幻灯片里调整;
  7. 最终同时写入 text/htmltext/plain 两种格式。

代码最初是用 Claude 3.5 Sonnet 原型开发的,作者后来手工重写用于发布。


颜色,才是真正的难题

按钮加好了,用了一段时间,工作正常。然后有一天,它坏了。

点击按钮,确实复制了一些东西,但粘贴进 Google Slides 之后,代码全部变成了黑色。背景也是黑色,整个幻灯片一片漆黑,我以为彻底崩了。

Google Slides 没变,Safari 没变。那是什么变了?

我重新设计了博客的 CSS。

Display P3:更宽的色域

看看新的 CSS:

/* from bundle.scss */
.code-block .code-block-inner i.hh1, .code-block .code-block-inner i.hh23 {
  color: light-dark(
    color(display-p3 0.5764705882 0.3725490196 0.2235294118),
    color(display-p3 0.7843137255 0.537254902 0.6078431373)
  );
}

颜色值从 rgb(...) 变成了 color(display-p3 ...)

Display P3 是 DCI-P3 的一个变体,采用 D65 白点和 sRGB 调性重现曲线。简单来说,它是一个比 sRGB 更宽的色域——如果你有一块 HDR 显示器,Display P3 的颜色会看起来比 sRGB 更鲜艳、更饱和。

问题就出在这里:

Google Slides 不支持 Display P3 颜色。

当我把这些颜色粘贴进去,Slides 不认识 color(display-p3 ...) 这个格式,就直接当作无效值,回退到了默认的黑色。

这就是一切变成黑色的原因。

色域映射(Gamut Mapping)

修复方案是:在写入剪贴板之前,把 Display P3 的颜色转换成 sRGB

但这说起来简单,做起来有坑。

"将超出某个色域的颜色转换到该色域内最接近的颜色",这个过程叫做色域映射(Gamut Mapping),是有整本书在讲的专题。你不能简单地把三个数值截断到 [0, 1] 范围,因为那样会导致颜色失真——就好像你把一首歌录音时削掉了所有超过音量上限的部分,听起来会有严重的失真。

正确的做法是在感知均匀的色彩空间(如 Oklab)里找最近邻,然后再转换回目标色域。

幸好有现成的库可以用:Colors.js。于是 normalizeColor 函数变成了这样:

let normalizeColor = (propValue) => {
  // 使用 color.js 把所有颜色(包括 display-p3)转换成 sRGB,
  // 并做正确的色域映射
  return new Color(propValue)
    .to("srgb")
    .toGamut({space: "srgb", method: "css"})
    .toString();
}

这一步处理放在前面代码里的 target.style.color = normalizeColor(...) 调用处——每一个 token 的颜色,在写入剪贴板之前都会经过这个转换,确保最终写进 HTML 的永远是标准的 rgb(...) 格式,Google Slides 能正常认识。

这也是作者承认"有时候引入第三方依赖是有道理的"的时刻——色彩科学是一个深坑,能不自己造轮子就不要造。


几个值得记住的细节

关于字重

代码里提取的不只是颜色,还有 font-weight。这里有一个有趣的地方:作者用的是 Berkeley Mono 可变字体,"正常"字重是 100,"粗体"是 150——和传统的 400/700 完全不同。可变字体让字重可以是 0 到 1000 之间的任意值,打破了"要么普通要么粗体"的二元限制。

关于 <i><span>

博客引擎为了节省 HTML 字节,用 <i> 标签来标记各种 token(而不是 <span>)。这在浏览器里没有任何问题,因为 CSS 重置掉了 <i> 的斜体样式。但当你把这些 HTML 粘贴进 Google Slides,Slides 会把 <i> 当作斜体标签来处理,于是所有代码都变成了斜体。所以代码里需要把所有 <i> 替换成 <span>

关于 Clipboard API 的限制

navigator.clipboard.write() 虽然是异步的,但它必须在用户操作的直接响应里被调用(比如点击事件的回调)。你不能先发一个网络请求、等待响应,然后再写剪贴板——浏览器会拒绝这样的写入,因为安全模型要求剪贴板写入必须在受信任的用户手势上下文中发生。这就意味着所有的数据处理必须是同步的,或者非常快地完成。

关于 Safari 和 Keynote

文末作者提到,Google Slides 在 2024 年还是不支持 SVG 插入。他已经切换到了 Keynote——Apple 的幻灯片工具,原生支持 Display P3 颜色,也支持 SVG。如果你也在 macOS 上做技术演讲,这是一个值得考虑的选项。


总结

这篇文章的表面话题是"怎么把代码高亮粘贴进幻灯片",但一路走来涉及的知识点相当密集:

  • 浏览器剪贴板的工作机制(copy 事件、execCommand、Clipboard API);
  • DOM 的 computedStyleMap() 接口;
  • CSS light-dark() 函数和浏览器的颜色计算;
  • Display P3 宽色域和 sRGB 色域之间的关系;
  • 色域映射的基本概念,以及为什么不能简单截断;
  • 可变字体的字重体系;
  • Clipboard API 的安全限制。

有时候,一个看似简单的"加一个复制按钮"的需求,背后藏着完整的技术知识体系。这大概就是做完美主义者的代价——也是乐趣所在。


原文:Highlighted code in slides — fasterthanli.me 配套视频:YouTube