Highlighted code in slides
Thanks to my sponsors: Antoine Rouaze, Thor Kamphefner, Thehbadger, Jonathan Adams, Chris Walker, Makoto Nakashima, Sindre Johansen, Jim, prairiewolf, clement, Sylvie Nightshade, Guillaume E, Taneli Kaivola, Ives van Hoorne, Stephan Buys, Mateusz Wykurz, Marcus Griep, Raphaël Thériault, Josiah Bull, Romet Tagobert and 266 more
This is a dual feature! It's available in video form and in text form.
I have obsessed about this long enough, I think it’s only fair I (and you!) get some content out of it.
When I started writing this article, I was working on my P99 CONF slides. Those slides happen to include some bits of code. And because I’m a perfectionist, I would like this code to be syntax highlighted, like this:
let addr: SocketAddr = config.address.parse()?;
let ln = TcpListener::bind(&addr).await?;
info!("🦊 {}", config.base_url);
Not like this:
let addr: SocketAddr = config.address.parse()?;
let ln = TcpListener::bind(&addr).await?;
info!("🦊 {}", config.base_url);
A perfectionist? What, you set up highlight.js and called it a day?
Oh no. No no no.
I have my own blog engine with my own Markdown processing pipeline that calls out to my tree-sitter, using my own builds of a collection of grammars I’ve collected over the years like a poet collects rhymes, and then I generate compact HTML markup so that your browser doesn’t explode at the mere sight of one of my articles.
…
Well.
We all have hobbies.
Unfortunately, most of the time, I see either:
- Code as text (no colors)
- Screenshots of their code editor (big files, looks crap on high-density displays)
A few weeks ago, I started looking for better options, because surely someone else was bothered by this — there are dozens of us! Dozens!
Not invented here
And I found… some plug-ins for Google Slides (most of them only work on Google Docs, a different product) that take SEVERAL SECONDS to highlight one block of code, since the plug-in code appears to run on-demand on a shared coffee maker in Mountain View.
Oh and it looks absolutely dreadful because, to be fair, colors are hard, but to be real, most developers have absolutely no sense of style.
But then I noticed something.
When you copy text from a web page… and you paste it into Google Slides… it retains some of its formatting.
In fact, I’ve known that forever. Everyone knows that. It’s a really annoying feature: you have to do “clear formatting” after pasting, or you have to remember whichever keyboard combination pastes without formatting.
On macOS, this operation is named “Paste and match style” and it has the heinous shortcut “Option-Shift-Command-V”.
Bright side? Free finger yoga!
We have to go out of our way to make sure that pasting doesn’t retain style.
The problem is… it retains a bit too much. It retains font-size for example, and it retains background color, but only sometimes? Which is something you almost definitely never want.
But if you were to feed it carefully-formatted HTML… HTML generated only for the purpose of being pasted into Google Slides, then maybe… it could work?
That idea is hardly new: you’ll find slides code highlighters around the web.
But I like the code highlighting on my website. I chose my colors (and fonts!) with love.
I revel in the knowledge that, even though it’s only doing “syntactic” highlighting and not “semantic” highligting (like something like rust-analyzer can), there’s still a full-on parser running behind the scenes, not just a set of regular expressions.
And most importantly: I now tend to work on both articles and videos at the same time: first writing the article, and then making slides I can work into the video at editing time.
So, all the code I want on the slides is already on my website, taunting me, just BEGGING to be copied into my clipboard so that I can paste them into slides.
Adding a button
So I added a button.
You can’t see it, but I can — I’m only showing it to logged-in admins.
Not just in text form, like a GitHub README would do, but also as HTML markup.
That’s harder than it looks though. Because when you, the user, the human interacting with a browser, when you select text, and hit Ctrl+C or Cmd+C, you’re doing something that JavaScript code can’t really do.
Well, it can, but it has very little control over it.
Technically, when you press that key combination, a copy event is dispatched.
You can add a handler for that event, which lets you override what will go in the clipboard.
That’s how some annoying websites have your clipboard filled with, “Oh, you can’t copy from this website. You need to pay us.” instead of the thing you copied.
You can also call preventDefault()
to, well, prevent anything from being copied in
the system clipboard. But you can’t read what’s in the clipboard, because presumably
security and privacy and things like that.
You can also generate a synthetic copy event. However, it will not affect the system clipboard at all.
You can use document.execCommand, Which actually does the same thing that hitting the key combination does. But it’s deprecated to hell, and you shouldn’t use it, even though all the browsers now support it.
And if you do use that, remember that you do get to override what gets written by intercepting the copy event. But you don’t get to read what was there in the first place.
At this point, you may as well do the right thing and use the Clipboard API.
It lets you read and write clipboard items asynchronously, and items can be made available in multiple mime types…
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("Content copied to clipboard as both text and HTML.");
}
…which matches the way clipboards work in real-life!
On macOS, Sindre Sorhus’s wonderful Pasteboard Viewer shows us what ends up in the “General pasteboard” when we run this code from Safari:
If we execute it from Firefox, the HTML version is a bit different:
Each browser will add its own little wrapper around our HTML payload, which is mildly upsetting, but not a huge deal in the grand scheme of things.
However, we have a much bigger problem.
Computed styles
To be honest, the heading kind of gives it away….
Well Bear, people want TOCs, and TOCs they will have.
So when you press Ctrl+C
or Cmd+C
or do execCommand("copy")
, whatever
is currently selected on the page is copied to the clipboard as both plain text
and as HTML.
And RTF! And others.
But… it’s making interesting choices. Look at this payload for example (formatted for your viewing comfort):
<head>
<meta charset="UTF-8" />
</head>
<div
class="bottom-nav-previous"
style='margin: 0px; padding: 0px; border: 0px; box-sizing: border-box; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 18.9px; font-style: normal; font-variant-caps: normal; font-weight: 300; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;'
>
Looking for<span class="Apple-converted-space"> </span
><a
href="http://ftl.localhost:1111/"
style="margin: 0px; padding: 0px; border: 0px; box-sizing: border-box; color: light-dark(rgb(232, 12, 12), rgb(255, 116, 116)); text-decoration: none;"
>the homepage</a
>?
</div>
<br class="Apple-interchange-newline" />
ftl.localhost:1111
is the domain I use when developing my website / writing
articles, just pretend it says fasterthanli.me
First off, Apple-converted-space
, ouch. But also, if we pretty-print the style of,
say, the link:
margin: 0px;
padding: 0px;
border: 0px;
box-sizing: border-box;
color: light-dark(rgb(232, 12, 12), rgb(255, 116, 116));
text-decoration: none;
We notice a few things: first, it bakes in a “reset” inline whenever it can (specifically around dimensions and the box model).
But second, it does some style computation (it doesn’t rely on the fact that
there is, somewhere, a CSS rule that applies to <a>
tags, it actually
copied the color
directive inline)
But it doesn’t go far enough! It’s giving us both the dark color of the link and the light color of the link:
…which the browser then chooses depending on which color scheme is active now, thanks to the light-dark CSS function.
Most browsers have a way to override light/dark mode in their dev tools:
Safari:
Firefox:
Chromium:
Really, it should be picking which color is active right now — I want it to look in the slides, how it looks to me right now.
So, we cannot rely on the HTML generated by our user agent (in this instance, that means “browser”) when a “copy” event is fired and we have part of the page selected.
Instead, we could find the relevant node and grab its outerHTML
property.
If I select that same paragraph and run this code from dev tools:
$0.outerHTML
It evaluates to:
<div class="bottom-nav-previous">
Looking for <a href="/">the homepage</a>?
</div>
Which… doesn’t have any inline styles at all.
Note that the innerHTML
property, perhaps more well-known,
evaluates to this:
Looking for <a href="/">the homepage</a>?
And the contextText
property simply evaluates to this:
Looking for the homepage?
We don’t want class names, but we do want inline styles, at least some of them: not background colors, but text color, and perhaps font weight.
Luckily, browsers (except Firefox for some reason) provide a way to inspect the computed style of an element.
You know, the styles you see in the “Computed” tab of the dev tools, the ones that are active right now.
Again, after right-clicking that paragraph at the bottom of my blog and choosing “Inspect”, the following:
$0.computedStyleMap().get("color").toString()
Evaluates to rgb(255, 255, 255)
.
And if we narrow it down to the link inside:
$0.querySelector("a").computedStyleMap().get("color").toString()
We get rgb(255, 116, 116)
in dark mode, and rgb(232, 12, 12)
in light mode. Victory!
Code listing
With a bit more elbow grease vanilla.js application, we can
come up with the complete code to copy an element to the clipboard
as HTML, keeping the color and font-weight of all its children:
let copyToClipboardInner = async (source) => {
// true = do a deep clone
// cf. https://developer.mozilla.org/en-US/docs/Web/API/Node/cloneNode
let target = source.cloneNode(true);
let process = ({ source, target }) => {
target.removeAttribute('class');
target.removeAttribute('style');
// I used `<i>` to save bytes on markup, but they will have default
// styles wherever we paste them, so let's turn them into spans for this.
if (target.tagName.toLowerCase() === 'i') {
let span = document.createElement('span');
span.innerHTML = target.innerHTML;
target.parentNode.replaceChild(span, target);
target = span;
}
// properties are camelCase here, even though in CSS they're 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' })
});
// note: even though `write` is async, this must all happen in response
// to a user action. we couldn't first make a network request, for example.
await navigator.clipboard.write([clipboardItem]);
};
Disclaimer:
This code was prototyped with Claude 3.5 Sonnet, which, if you haven’t tried, you’re simply not aware what code assistants can do.
I have rewritten the code by hand for publication.
Now if you actually went and read the code above, you might be
wondering what that normalizeColor
function does.
Well, that brings us to today.
Color is hard
I added that copy button weeks ago, when working on another video (which I will finish one day, I swear).
But today, as I used that button to make my P99 CONF slides, it stopped working. Clicking it copied something to the clipboard, of course, but… pasting it into Google Slides showed all-black code.
(On a black background. I thought it was completely broken at first).
Upon investigation, it turns out that:
- Nothing changed in Google Slides
- Nothing changed in Safari
But something must have changed…
Well, I did redesign my website.
Let’s take a look at the new 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)
);
}
What’s with this weird font-weight
of 120? “Normal” weight is
traditionally 400, and “Bold” is usually 700.
Well, when using variable-width fonts, tradition doesn’t apply: you get the full range from 0 to 1000, and apparently the folks at Berkeley Graphics chose to make “Normal” 100 and “Bold” 150 when they made Berkeley Mono.
The colors are in Display P3, a variant of DCI-P3 that uses a D65 white point and the sRGB tone reproduction curve.
What does it mean? It means if you have an HDR display, then one one of these rows will look more intense than the other.
If you don’t have an HDR display, or a compatible browser, then they should look the same, as the Display P3 colors should be tone-mapped to the nearest sRGB color.
To simulate that effect, we could imagine a gamut even narrower than sRGB, and use it for the first line:
It’s not the same difference, but it’s the same kind of difference.
When picking “wide gamut” colors like Display P3, Safari’s color picker shows you where sRGB stops:
You can read more about wide gamut color on css-tricks, a site which has recently gained a new lease on life.
So that’s why my “copy code as HTML” button broke!
As you can imagine, Google Slides does not actually inject the CSS you give it straight into the page: they parse it, extract whichever styles they support, and use that.
And it would appear they don’t support Display P3 colors! Hence, everything came out black.
Hence, we need to do a transformation ourselves.
Which is… easier said than done:
The process of transforming a color outside of a given gamut to a color that is as close as possible but is inside gamut is called gamut mapping and is the subject of entire books
See?
Sometimes there’s good reason to pull in third-party dependencies.
let normalizeColor = (propValue) => {
// Convert all colors (including display-p3) to
// sRGB using color.js with proper gamut mapping
return new Color(propValue)
.to("srgb")
.toGamut({space: "srgb", method: "css"})
.toString();
}
Closing thoughts
Now I have pretty slides:
(Also, you can go watch my talk if you want)
But between you and me? Google Slides is a pretty bad product.
In 2024, you still cannot insert an SVG in there. I don’t want to hear about your EPS-based workarounds because I love myself.
Also, did they really need to put in a keyboard shortcut for “rotate element”?
To whoever needs to hear it: Keynote
does support display-p3
color and SVG. I’ve switched to it just for
that!
This is (was? you're done reading I guess) a dual feature! It's also available in video form.
Here's another article just for you:
Rust generics vs Java generics
In my previous article, I said I needed to stop thinking of Rust generics as Java generics, because in Rust, generic types are erased.
Someone gently pointed out that they are also erased in Java, the difference was elsewhere. And so, let’s learn the difference together.
Java generics
I learned Java first (a long, long time ago), and their approach to generics made sense to me at the time.