Skip to content

Notes

Creating a custom-sized PDF document from a web page using standard browser features is still somewhat tricky. For a recent project, I was curious to find adequate workflows for all major browsers on macOS.

I’ve used two simple test pages, one for each page orientation: landscape (64cm × 36cm) and portrait (36cm × 64cm). The relevant CSS bits to configure the print output:

@page {
	/* landscape page size */
	size: 64cm 36cm;
	
	/* portrait page size */
	/* size: 36cm 64cm; */ 

	/* disable page margins */
	margin: 0;
}

html {
	/* maintain colors and backgrounds */
	print-color-adjust: exact;

	/* page background */
	background: #eee;
}

There are a few ways to produce PDFs in macOS browsers:

  • File → Print, then choose the Save as PDF destination (Firefox/Chrome);
  • File → Export as PDF (Safari);
  • File → Print, then Open PDF in Preview (Chrome);
  • File → Print, then Print using system dialog… (all browsers), with custom page size definitions that match the designs.

The results of applying these methods are tabulated below. Each result is linked to its corresponding PDF output.

Method Firefox Chrome Safari
Save as PDF P L 🛑 P L n/a
Export as PDF n/a n/a P 🛑 L 🛑
Open PDF in preview n/a P 🛑 L 🛑 n/a
System (L page def, P orient) P 🛑 L 🛑 P ⚠️ L P 🛑 L ⚠️
System (L page def, L orient) P 🛑 L 🛑 P L ⚠️ P ⚠️ L 🛑
System (P page def, P orient) P L P L ⚠️ P ⚠️ L 🛑
System (P page def, L orient) P L P ⚠️ L P 🛑 L ⚠️
Test result matrix for printing a web page to a PDF using various methods available in macOS browsers. P stands for the portrait test case, and L stands for the landscape test case. When discussing orientation, page def refers to the custom page size’s intrinsic orientation, while orient refers to the user-facing orientation option. Successful results are marked with ✅, usable results with ⚠️, and failures with 🛑.

There are probably some browser bug reports to write, or link to, for the various failure modes, but I won’t dwell on them now. The important thing is that for each major browser on macOS, as long as you’re okay with a clunky workflow, there’s at least one method that roughly works without any extra software:

  • In Firefox, if you’re printing a portrait PDF, you’re in luck: the Save as PDF destination works out of the box. Otherwise, you have to Print using the system dialog…, create a custom page size that’s intrinsically in portrait orientation regardless of your design’s orientation, and save the PDF from there. For both test cases, the custom page size needs to be 360 mm in width and 640 mm in height, with 0 mm margins all around. [Gecko#1882056]
  • In Chrome, the Save as PDF destination works out of the box regardless of your design’s orientation. Additionally, you can produce the PDF through the system dialog, using a custom page size whose intrinsic orientation matches your design, and choosing Portrait as the orientation in the print dialog. Otherwise the PDF ends up rotated 90°, hence the ⚠️ “usable result” rating (you can press ⌘R in Preview to rotate the page afterwards).
  • In Safari, print through the system dialog following the same instructions as in Chrome. The method earns a mere ⚠️ “usable result” rating as the gray background on the <html> element in the test pages doesn’t fill the entire page in the PDFs. This is probably solvable with more CSS, but I haven’t investigated.
  • Edge and Vivaldi work the same as Chrome, as they share the Chromium engine.

Both test pages were a single page. I should hope these methods work just as well for multi-page documents. And if the built-in PDF capabilities just don’t cut it, Vasilis van Gemert pointed to an approach that works across browsers: installing a virtual PDF printer such as RWTS PDFwriter.

While working on HTTP caching, a refresher, I learned about a recent development that immediately piqued my interest. Authored by Domenic Denicola and Jeremy Roman at Google, the No-Vary-Search HTTP response header field proposal enables a server to declare that some aspects of the URL’s query string are irrelevant for the purposes of caching.

Its syntax can express that the order of parameters doesn’t matter, or that some (or all) of the parameters haven’t influenced the content of the response. Barry Pollard has recently explained how that works in Fixing the URL params performance penalty but, in short, a typical response would look like:

HTTP/2 200
No-Vary-Search: params=("utm_campaign" "utm_source")

Cache performance is all well and good, but I’m more enthusiastic for the header’s potential to fix other things. I’ve written before that I’m not a fan of <link rel=canonical> and the way browsers have decided to use it to inappropriately override the original URL, introducing several annoying problems in the process.

Given the way the canonical link relation is defined and incentivized by search engines, I don’t see it as a salvageable mechanism to obtain ‘clean URLs’.

But if you’ve read about No-Vary-Search and thought to yourself, “gosh, this proposal to unambiguously identify which query parameters can safely be yeeted sure sounds like a swell way to get a nice clean ol’ link” — why yes, yes it is. If servers implemented this header in good faith (and correctly) it could serve as an excellent signal for web browsers wishing to share and bookmark stripped-down URLs.

Big if, but fingers crossed, am I right?

Another Eleventy recipe, this time for bundling JavaScript, CSS, and other asset types with esbuild. You can adapt it to how you prefer your markup, or to other bundlers such as Vite. I’ve also used this approach to produce hashed front-end assets for WordPress and Kirby.

In Eleventy, you’ll be able to reference a JavaScript source file in your Nunjucks template and have the src attribute point to a browser-ready, content-hashed bundle:

<!-- From this… -->
<script type='module' src='{{ "js/widget.js" | bundle }}'></script>

<!-- …to this -->
<script type='module' src='/dist/widget.HSJDN132X.js'></script>

For this, we’ll tap into esbuild’s ability to produce as part of the build process a JSON metafile that maps source files to their destinations. The relevant part in the metafile is the outputs property:

{
	outputs: {
		'static/dist/widget.HSJDN132X.js': {
			entryPoint: 'static/js/widget.js'
			cssBundle: 'static/css/widget.css'
		}
	}
}

Assuming assets for our project live in a passthrough static/ directory, and esbuild puts the bundles in the static/dist/ directory, here’s an implementation for the async bundle filter:

import { join, relative } from 'node:path';
import esbuild from 'esbuild';

// .eleventy.js
export default function(config) {

	const STATIC_DIR = 'static';
	config.addPassthroughCopy({ [STATIC_DIR]: '/' });

	let buildCache = {};
	config.addAsyncFilter('bundle', async infile => {
		buildCache[infile] ??= new Promise(async resolve => {
			const entryPoint = join(STATIC_DIR, infile);
			const { metafile } = await esbuild.build({
				entryPoints: [entryPoint],
				outdir: join(STATIC_DIR, 'dist'),
				bundle: true,
				format: 'esm',
				entryNames: '[name].[hash]',
				assetNames: '[name].[hash]',
				metafile: true
			});
			const entry = Object.entries(metafile.outputs)
				.find(it => it[1].entryPoint === entryPoint);
			const outfile = '/' + relative(STATIC_DIR, entry[0]);
			resolve(outfile);
		});
		return await buildCache[infile];
	});
}

Let’s unpack how the bundle filter works.

It implements a basic cache so that repeated calls to build the same entry point don’t result in duplicate work:

let buildCache = {};
config.addAsyncFilter('bundle', async infile => {
	buildCache[infile] ??= new Promise(async resolve => {
		const result = await doTheWork();
		resolve(result);
	};
	return await buildCache[infile];
});

The work being an asynchronous build which, by virtue of the metafile: true option, returns a metafile property:

const entryPoint = join(STATIC_DIR, infile);
const { metafile } = await esbuild.build({
	entryPoints: [entryPoint],
	outdir: join(STATIC_DIR, 'dist'),
	bundle: true,
	format: 'esm',
	entryNames: '[name].[hash]',
	assetNames: '[name].[hash]',
	metafile: true
});

Finally, in the metafile, we locate the key corresponding to our entry point and make sure to format its output path appropriately. Node.js’s path module is more robust than string concatenation here, as join() and resolve() will sort out trailing slashes and segments like ./ and ../ in our entry point:

const entry = Object.entries(metafile.outputs).find(
	it => it[1].entryPoint === entryPoint
);
const outfile = '/' + relative(STATIC_DIR, entry[0]);
resolve(outfile);

This works out of the box for JavaScript and CSS entrypoints. Furthermore, whenever a JavaScript entrypoint imports CSS styles, esbuild includes a handy cssBundle property in the corresponding outputs object, so we can return from the bundle filter the paths for both the JS entrypoint and its CSS dependency:

const url = '/' + relative(STATIC_DIR, entry[0]);
const css = entry[1].cssBundle && ('/' + relative(STATIC_DIR, entry[1].cssBundle));
resolve({ url, css });

…to use in templates accordingly:

<link 
	rel='stylesheet' 
	type='text/css' 
	href='{{ ('js/widget.js' | bundle).css }}'
>

<script 
	type='module' 
	src='{{ ('js/widget.js' | bundle).url }}'
></script>

Note that for other types of imported content, you’ll have to specify the appropriate loader depending on how you want them handled:

await esbuild.build({
	// …
	loader: { 
		// Images
		'.png': 'file',
		'.jpg': 'file',
		'.svg': 'file',

		// Fonts
		'.woff': 'file',
		'.woff2': 'file',
		
		// etc.
	}
});

Handling updates. To make the technique work with Eleventy’s watch/serve mode, there are a couple of final tweaks to be made: we must trigger a rebuild whenever our source assets change using addWatchTarget(), and to clear the build cache to make room for the refreshed results.

config.addWatchTarget('static/!(dist/**/*)');
config.on('eleventy.beforeWatch', changedFiles => {
	buildCache = {};
});

The static/!(dist/**/*) glob pattern is an attempt to watch all files under the static/ directory, excluding the static/dist/ subtree, which prevents an infinite loop. I’ve tried a bunch of things, and this is the pattern that seems to do the trick for Eleventy’s underlying picomatch glob library.

I’ve started incorporating sample PNGs to illustrate typefaces in the Atlas of Type, and secretly hoped they’d work as OpenGraph previews just as well. Turns out tightly cropped, black-on-transparent images like the one below were not on most platforms’ og:image bingo cards:

A two-line preview of the Betània Patmos typeface, rendered in two lines in black against a transparent background

The sample PNG for the recently-released Betània Patmos, emphasised with a 1px border.

The results were uniformly atrocious. To render well, the samples need a solid background and some padding:

A better og image card shows the typeface against a white background with some padding applied.

As the Atlas is a 100% static Eleventy website, these preview images need to be computed beforehand, ideally without manual involvement. A scan of the docs for the trusty eleventy-img plugin revealed that the 6.x release last week coincidentally added access to sharp, the underlying image processing library, so compositing the sample PNGs onto a white canvas was no big deal.

This is the .eleventy.js config to define an og_image shortcode that takes an image from static/img/typeface/ and generates a corresponding 1200×800 preview image in static/img/card/:

import path from 'node:path';
import ImagePlugin from '@11ty/eleventy-img';

const TARGET_IMG_WIDTH = 1200;
const TARGET_IMG_HEIGHT = 800;

config.addShortcode('og_image', async function(src) {
	const inputPath = path.join('static', src);
	const img = await ImagePlugin(inputPath, {
		formats: ['png'],
		urlPath: '/img/card',
		outputDir: 'static/img/card',
		transform: async sharp => {
			const metadata = await sharp.metadata();
			const pad_width = (TARGET_IMG_WIDTH - metadata.width) / 2;
			const pad_height = (TARGET_IMG_HEIGHT - metadata.height) / 2;
			sharp
				.flatten({ background: 'white' })
				.extend({
					top: Math.floor(pad_height),
					bottom: Math.ceil(pad_height),
					left: Math.floor(pad_width),
					right: Math.ceil(pad_width),
					background: 'white'
				});
		},
		// disable hashing, return original file name.
		filenameFormat: function (id, src) {
			return path.relative('static/img', src);
		}
	});
	return img.png[0].url;
});

Note: I’m probably losing some performance by disabling the built-in hashing, but I wanted predictable URLs for the og:images. If you don’t care about that, remove the custom filenameFormat() function.

Usage in a Nunjucks template, and the resulting markup:


<!-- template -->
<meta 
	property="og:image" 
	content="{{ site.url }}{% og_image '/img/typeface/my-sample.png' %}"
>

<!-- result -->
<meta 
	property="og:image" 
	content="https://type-atlas.xyz/img/card/typeface/my-sample.png"
>

Addendum to point out a small gotcha. Because the og:image cards are put back in the static/ folder, which itself gets duplicated via Passthrough File Copy into the output folder, the first build with new source images won’t see the freshly generated cards. Eleventy 2.x added the ability to skip the actual copying in serving mode, and opt into serving the files from their original locations. This seems like a generally good addition to your Eleventy config:

config.setServerPassthroughCopyBehavior("passthrough");

This is for local development. As for deployment, make sure at least one prior build has run so cards for new images have a chance to get generated.

This is well-trodden ground, but this how I do HTTP 301 redirects in Eleventy, mostly to have a reference for the Apache vs. nginx syntax. I’m sticking with the Hugo aliases convention in the front-matter data:

---
title: My post
aliases:
  - /my-previous-url/
  - /my-other-previous-url/
---

Here’s the Eleventy configuration to gather all the aliases in a collection of { from, to } objects. All trailing slashes are removed from the paths, to make their usage clearer in the redirects.

function stripTrailingSlashes(str = '') {
	return str.replace(/^\/|\/$/g, '');
}

config.addCollection('aliases', function (api) {
	return api
		.getAll()
		.map(page => {
			return (page.data.aliases ?? []).map(alias => {
				return {
					from: stripTrailingSlashes(alias),
					to: stripTrailingSlashes(page.url)
				};
			};
		})
		.flat();
});

With the collection in place, it becomes a matter of producing a server-specific redirects file. Apache can use a .htaccess file in the website’s root, produced by the Nunjucks template below:

---
permalink: /.htaccess
eleventyExcludeFromCollections: true
---

ErrorDocument 404 /404.html

<IfModule mod_alias.c>
{% for alias in collections.aliases %}
RedirectMatch 301 ^/{{ alias.from }}/?$ /{{ alias.to }}/{% endfor %}
</IfModule>

When it comes to nginx, producing the set of redirects is only half the story. Let’s first generate a 301.redirects file in the website root:

---
permalink: /301.redirects
eleventyExcludeFromCollections: true
---

{% for alias in collections.aliases %}
rewrite ^/{{alias.from}}/?$ /{{alias.to}}/ permanent;{% endfor %}

This file does nothing by default. It needs to be included in the nginx configuration:

server {
	include path/to/301.redirects;
}

There’s a number of ways to go about it, which I won’t document here. But if the technique invovles leaving the physical 301.redirects file in the website’s root directory, you’ll probably want to make it inaccessible to the outside world with something along the lines of:

location = /301.redirects {
	internal;
}

One gotcha is that aliases added to pages having eleventyExcludeFromCollections: true won’t be included in the redirects file, an aspect I noticed when moving some Atom feeds around. Eleventy doesn’t yet have an API to grab excluded pages, so for now I’ve added manual entries to .htaccess.

Reviewing a recent post about my favorite records of 2024, I noticed something off about two lists rendered next to each other. The unordered list has a larger line height than the ordered one, but only in Firefox. Why’s that?

Firefox dev tools show that the unordered list item has a height of 31 pixels, while the ordered one has just 28px pixels

Firefox devtools showed in the Fonts tab that the marker is using a font named -moz-bullet-font, and not inheriting the font from its ancestors like the Rules panel implied.

The Mozilla Bullet font is defined inline in Firefox’s default stylesheet for HTML, and was introduced three years ago to replace the bullet images previously in use. It contains glyphs for the all the simple symbolic counters: disc, circle, and square, as well as disclosure-open and disclosure-closed for all writing modes.

The Mozilla Bullet font has glyphs for disc, square and circle bullets, as well as disclosure triangles

Using a browser-defined font like this is codified in the spec: When used in list-style-type, a UA may instead render these styles using a UA-generated image or a UA-chosen font instead of rendering the specified character in the element’s own font. If using an image, it must look similar to the character, and must be sized to attractively fill a 1em by 1em square.

The reason why there’s no rule in the default stylesheet to apply this font…

::marker { 
	font-family: -moz-bullet-font; 
}

…is because the marker must pick up the parent’s font-family in all other situations, including when changing the marker’s presentation via the content property. Instead, it’s all handled at the code level, and thus invisible to the Rules tab in devtools. (Technically a bug, but one whose resolution might not justify the effort.)

How to fix the spacing? As with other things that may affect the line height, throwing in line-height: 0 seems to do the trick, and you may very well leave it at that.

::marker {
	line-height: 0;
}

But let’s see if we can align Firefox with what other browsers are doing, and inherit the font in all cases.

::marker {
	font: inherit;
}

One immediate problem with this is the default character sequence used in unordered lists, "• " (bullet followed by space), looks dainty and crammed against the text in most fonts. Mozilla Bullet works around that by defining a wider space character. Similarly, Chrome and Safari seem to use internal adjustments to the spacing to make it look a bit better than straight-up rendering the two characters.

As Šime Vidas points out, the possibilities for spacing list markers are quite limited. Moreover, built-in counter styles such as disc can’t be overriden. The kind of solution robust enough for a CSS reset is therefore a bit verbose, and involves defining new counter styles with better spacing (via space characters, no less). This would be just for fixing the disc list markers:

@counter-style disc-fixed {
	system: cyclic;
	/* bullet character… */
	symbols: \2022; 
	/* …followed by three spaces for better spacing */
	suffix: "   ";  
}

ul, menu, dir {
	list-style-type: disc-fixed;
}

::marker {
	font: inherit;
}

Kind of a hard sell, to be honest. But yeah, that’s where the line-height thing comes from.

I’m a sporadic writer. This year, I only managed a couple of articles between my end-of-year music lists doubling as mileposts, and a decade’s worth of writing fits comfortably on a single page.

To shake things up, I’ve been meaning to carve out a separate section for posts that are shorter, smaller in scope, and more frequent. A bit like what Tom MacWright, Simon Willison, or David Bushell are doing.

So, here we are. The inaugural post in the new Notes section.

If you’re reading this in a feed reader and you’ve subscribed before December 2024, you’re receiving the combined feed that includes articles, my linkblog, and these notes. If it suits you better, you can now subscribe to individual feeds.