Skip to content
/ Michaël Hompus

Generating PDFs in .NET is often painful—low-level drawing APIs, rigid libraries, and pricey licenses all get in the way. Playwright flips the script: design with plain HTML and CSS, then export directly to PDF. This walkthrough shows how to load a template, replace placeholders at runtime, and generate a styled PDF with Playwright in .NET.

Generating a nicely formatted PDF is surprisingly hard with traditional .NET libraries. Pixel‑perfect layout often requires low‑level drawing APIs or proprietary tooling.

When looking for a solution, I stumbled upon Microsoft Playwright, a library for end-to-end testing in the browser.

This in itself might not sound like a good fit for PDF generation, but if you know that the print preview inside a Chromium browser is actually a rendered PDF, it makes more sense.

So if we can render HTML in a browser, we can also export it to PDF. And this gives us all the flexibility of HTML and CSS for layout, without the hassle of low-level drawing APIs.

For this article, we will create a simple .NET console application that generates a PDF from an HTML template using Playwright. Playwright integration is not limited to .NET, there are also libraries for JavaScript, Python, and Java, so you can use it in your preferred language.

1. Add Playwright to the application

Start by adding the Microsoft.Playwright package. After restoring packages run the Playwright CLI to download the required browsers.

The smallest installation is Chromium with only the shell dependencies, which is sufficient for PDF generation:

Terminal window
.\bin\Debug\net9.0\playwright.ps1 install chromium --with-deps --only-shell

With the dependencies in place you can launch Chromium in headless mode:

using Microsoft.Playwright;
// Create an instance of Playwright
using var playwright = await Playwright.CreateAsync();
// Launch a Chromium browser instance
await using var browser = await playwright.Chromium.LaunchAsync();

2. Create an HTML template with placeholders

Create a template that contains placeholders for the dynamic parts. The demo project ships with html-template.html embedded as a resource.

It also shows how the CSS page rules can be used to set the page size, margins, and headers/footers:

html-template.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{title}}</title>
<style>
@page {
size: A4;
margin: 2cm;
@top-center { content: "Page Header"; }
@bottom-right { content: counter(page) " of " counter(pages); }
}
</style>
</head>
<body>
<h1>PDF Generation Demo</h1>
<p>{{body}}</p>
<p><address>https://blog.hompus.nl</address></p>
</body>
</html>

3. Replace placeholders at runtime

Load the template and swap the placeholders for real values before rendering. To keep things simple, we’ll generate some random body text with a Lorem Ipsum library:

// Create a temporary directory to store the generated files
var tempDir = Directory.CreateTempSubdirectory("pdfs_");
// Define the path for the HTML file that will be used as input for the PDF
var outputPath = Path.Combine(tempDir.FullName, "pdf-input.html");
// Load the embedded HTML template resource from the assembly
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("CreatePdfs.Playwright.html-template.html");
// Read the HTML template into a byte buffer
Span<byte> buffer = new byte[stream.Length];
stream.ReadExactly(buffer);
// Convert the byte buffer to a UTF-8 string
var templateHtml = Encoding.UTF8.GetString(buffer);
// Generate placeholder content for the body using Lorem.NETCore
// Settings: Generate 20 paragraphs with between 3 and 8 sentences per paragraph, and between 8 and 10 words per sentence.
var generatedBody = string.Join("</p><p>", LoremNETCore.Generate.Paragraphs(8, 20, 3, 8, 20));
// Replace placeholders in the HTML template with actual content
using var outputFile = File.CreateText(outputPath);
outputFile.Write(templateHtml!
.Replace("{{title}}", "Hello, World!") // Replace the title placeholder
.Replace("{{body}}", generatedBody) // Replace the body placeholder with generated content
);
outputFile.Close();

4. Generate the PDF

Finally instruct Playwright to navigate to the generated HTML file and export a PDF:

// The output path for the generated PDF file
var pdfPath = outputPath.Replace(".html", ".pdf");
// Create a new browser page
var page = await browser.NewPageAsync();
// Navigate to the generated HTML file
await page.GotoAsync(outputPath); // The browser can load files from the local file system
// Generate a PDF from the HTML content
await page.PdfAsync(new PagePdfOptions
{
DisplayHeaderFooter = true, // Enable header and footer in the PDF
Landscape = false, // Use portrait orientation
PreferCSSPageSize = true, // Use CSS-defined page size
Tagged = true, // Enable tagged PDF for accessibility (e.g., helps screen readers navigate)
Path = pdfPath, // Define the output path for the PDF
Outline = true, // Include an outline (bookmarks) in the PDF
});

The resulting file can be saved, returned from an API or just opened. The complete example project is available on GitHub.

Notes and next steps

  • Background jobs – The same code runs perfectly in a background worker like Hangfire, letting you offload PDF generation from the request pipeline.

  • Fonts and styling – Any font that the browser can render can be embedded via CSS. Add @font-face rules and Playwright will include the fonts in the PDF. Just remember that fonts might not be available in a container, so to be safe, add the font as woff files to your project and reference them in the CSS.

    @font-face {
    font-family: 'Open Sans';
    src: url('/fonts/OpenSans.woff') format('woff');
    }
    body { font-family: 'Open Sans', sans-serif; }

Playwright may not be the first tool you think of for PDF generation, but it gives you a full browser engine and a flexible API.

It turns out the best PDF library is… a browser pretending it’s a printer.

Filed under C#
Last update:
/ Michaël Hompus

As I continue migrating from WordPress to Astro, I am rebuilding key plugin features without third-party dependencies. In this post, I will show how I replaced syntax highlighting, recent post widgets, and external link management using Astro’s flexible ecosystem.

This post is a continuation of my migration journey from WordPress to Astro. If you have not read the previous posts, you might want to start with Why I Switched from WordPress to Astro, How I Moved My Blog Content and how I am Replacing WordPress Plugins with Astro: Acronyms, Meta Tags & Tag Clouds.

In this post, I cover how I replaced the next set of WordPress plugins with equivalent functionality in Astro:

  1. Enlighter
  2. Recent Post Widget Extended
  3. WP External Links

Enlighter

The Enlighter plugin provided syntax highlighting for code blocks in WordPress, allowing custom styles and line numbers.

Astro Implementation

I replaced Enlighter with Expressive Code. It is a powerful and flexible replacement for syntax highlighting in Astro.

Here’s how I set it up:

astro.config.mjs
import { defineConfig } from "astro/config";
import expressiveCode from "astro-expressive-code";
export default defineConfig({
integrations: [
expressiveCode({
plugins: [pluginLineNumbers()],
defaultProps: {
wrap: true,
showLineNumbers: false,
},
styleOverrides: {
codeFontFamily: "var(--font-monospace)",
codeFontSize: "0.78125rem",
codeLineHeight: "1.6",
uiFontSize: "0.78125rem",
lineNumbers: {
highlightForeground: "#85c7ebb3",
},
},
}),
],
});

I configured it to wrap long lines by default and disabled line numbers unless explicitly enabled. Additionally, I set the font family, size, and other styles to match the styling of my blog.

Using My Visual Studio Code Theme

I prefer the syntax highlighting theme in Visual Studio Code over the default GitHub styling.

Expressive Code allows you to define custom themes based on VS Code exports. So, I exported my theme and applied it in Expressive Code!

src/config/vscode-theme.jsonc
{
"$schema": "vscode://schemas/color-theme",
"type": "dark",
"colors": {
"actionBar.toggledBackground": "#383a49",
"activityBar.activeBorder": "#0078d4",
"activityBar.background": "#181818",
...
},
"tokenColors": []
}

Full VS Code theme available here: vscode-theme.jsonc

Because the theme file is a jsonc file. I had to load it in a specific manner in the astro.config.mjs file:

astro.config.mjs
import { defineConfig } from "astro/config";
import { readFileSync } from "fs";
import expressiveCode, { ExpressiveCodeTheme } from "astro-expressive-code";
const jsoncString = readFileSync(new URL(`./src/config/vscode-theme.jsonc`, import.meta.url), 'utf-8')
const vscodeTheme = ExpressiveCodeTheme.fromJSONString(jsoncString)
export default defineConfig({
integrations: [
expressiveCode({
...
themes: [vscodeTheme]
})
],
})

As you can already see in all the code samples in my posts, the syntax highlighting is working beautifully.

Full implementation on GitHub: astro.config.mjs.

Recent Post Widget Extended

The Recent Post Widget Extended plugin displayed recent posts dynamically.

Astro Implementation

In Astro, I created a simple loop using Astro’s Content Collection API.

src/components/Sidebar.astro
---
import { getCollection } from 'astro:content';
const unsortedPosts = await getCollection('posts')
const posts: { data: { permalink: string; title: string } }[] = sortedPosts(unsortedPosts);

Next, I render the recent posts in the sidebar while filtering out the current post (if applicable):

src/components/Sidebar.astro
<aside id="recent-posts">
<h2>Recent Posts</h2>
<div>
<nav>
<ol>
{
posts
.slice(0, 6)
.filter((p) => p.data.permalink !== Astro.url.pathname)
.slice(0, 5)
.map((post) => (
<li>
<a href={post.data.permalink} target="_self">
{post.data.title}
</a>
</li>
))
}
</ol>
</nav>
</div>
</aside>

Full implementation on GitHub: Sidebar.astro.

The External Links plugin manages external links, adding icons and SEO attributes.

Astro Implementation

I replaced this with rehype-external-links, configuring it in astro.config.mjs:

astro.config.mjs
import rehypeExternalLinks from "rehype-external-links";
export default defineConfig({
markdown: {
rehypePlugins: [
[
rehypeExternalLinks,
{
content: {},
rel: ["noopener", "noreferrer", "external"],
target: "_blank"
}
]
],
},
});

Now every link that contains an external URL will open in a new tab with the noopener, noreferrer, and external attributes.

Using CSS on a tags where the rel attribute contains external, I added an external link icon:

global.css
a[rel~="external"] span {
width: 10px;
height: 10px;
display: inline-block;
margin-left: 0.3em;
background-image: url(/icon-13.png);
}

You can see this implementation in action with various external links on this page.

If you want to exclude certain domains from being treated as external links, you can use a test function in the configuration. I used this to exclude LinkedIn links.

View the full code on GitHub: astro.config.mjs and global.css.


This was the next set of 3 plugins I replaced with Astro functionality.

In the next post, I will cover more plugins, including paging, related posts, and more.

Filed under Azure
Last update:
/ Michaël Hompus

Moving from WordPress to Astro meant rethinking how I implemented various features that were previously handled by plugins. In this post, I explain how I replaced key WordPress plugin functionalities in Astro, including acronyms, metadata, and tag clouds.

This post is a continuation of my migration journey from WordPress to Astro. If you have not read the previous posts, you might want to start with:

In this post, I explain how I replaced 3 WordPress plugin functionalities in Astro.

  1. Acronyms 2
  2. Add Meta Tags
  3. Better Tag Cloud

Acronyms 2

The Acronyms 2 plugin allowed me to define a list of acronyms, and automatically generated tooltips with their meanings when they appeared in posts.

Astro Implementation

In Astro, I created a rehype plugin that processes text and wraps recognized acronyms with an <abbr> tag containing the full text of the acronym as the title attribute.

Note

rehype is an ecosystem of plugins that work with HTML as structured data, specifically ASTs.
This allows for easy manipulation, or extension, of HTML content.

The first time an abbreviation is encountered on a page, the plugin will also wrap the <abbr> tag with an <dfn> tag. This semantic tag is used to indicate the defining instance of a term.

A special case is when an acronym is used in a <code> or <pre> block. In this case, the plugin will not wrap the acronym with an <abbr> tag.

src/plugins/rehypeAbbreviate.js
if (current.tagName === 'code' || current.tagName === 'pre') {
return; // Skip this node
}

See the full code on GitHub: rehypeAbbreviate.js.

Because there is no database to store the acronyms, I defined them in a YAML file and imported them into the plugin.

src/config/acronyms.yaml
ACRONYMS:
AST: Abstract Syntax Tree
HTML: HyperText Markup Language

The YAML file is loaded in the astro.config.mjs file and passed to the rehypeAbbreviate plugin.

astro.config.mjs
import { defineConfig } from "astro/config";
import yaml from "@rollup/plugin-yaml";
import yamlParser from "yaml";
import { readFileSync } from "fs";
import rehypeAbbreviate from "./src/plugins/rehypeAbbreviate.js";
export default defineConfig({
markdown: {
rehypePlugins: [
[
rehypeAbbreviate,
{ acronyms: yamlParser.parse(readFileSync("./src/config/acronyms.yaml", "utf8")).ACRONYMS}
],
],
},
vite: {
plugins: [yaml()],
},
});

I expect that loading the acronyms from a YAML file could be improved, but for now, it works well enough.

Add Meta Tags

The Add Meta Tags plugin generated metadata for search engines and social media previews.

Astro Implementation

Most of the metadata is defined in the BaseHead.astro file, which is included in one of the layout files that pass on contextual values.

src/layouts/BaseHead.astro
<meta property="og:url" content={Astro.url}>
<meta property="og:title" content={title}>
<meta property="og:description" content={description}>

Some properties are conditionally included based on availability. For example, the article object is passed to the layout when rendering a blog post.

Sections and tags can even occur multiple times, so I use the Array map function to generate multiple <meta> tags.

src/layouts/BaseHead.astro
{article && (
<meta property="og:type" content="article" />
<meta property="article:published_time" content={article.published} />
<meta property="article:modified_time" content={article.modified} />
)}
{article && article.sections && article.sections.map((section) =>
<meta property="article:section" content={section}>
)}
{article && article.tags && article.tags.map((tag) =>
<meta property="article:tag" content={tag}>
)}

View the full code on GitHub: BaseHead.astro.

Better Tag Cloud

The Better Tag Cloud plugin displayed a tag cloud where tags were weighted based on post frequency.

Astro Implementation

I used the PHP code from the plugin as a reference and, with some help from ChatGPT, created a function that:

  1. Gets a map of tags and their posts.

    src/js/util.js
    export function getTagsWithPosts(paths) {
    const posts = sortedPosts(paths);
    const tagsMap = new Map();
    posts.forEach(post => {
    post.data.tags?.forEach(tag => {
    if (!tagsMap.has(tag)) {
    tagsMap.set(tag, []);
    }
    tagsMap.get(tag).push(post);
    });
    });
    return tagsMap;
    }
  2. Sorts the tags by post count.

  3. Get the top 50 tags.

  4. Sort the tags alphabetically.

  5. Calculate the weight of each tag based on the post count. Using 8pt as the smallest font size and 22pt as the largest font size.

    src/components/Sidebar.astro
    return tagArray
    .sort(([tagA], [tagB]) => tagA.localeCompare(tagB))
    .map(([tag, tagPosts]) => ({
    tag,
    count: tagPosts.length,
    fontSize: calculateFontSize(tagPosts.length),
    }));
  6. Render the tags in the sidebar.

    src/components/Sidebar.astro
    <aside id="tag-cloud">
    <h2>Tags</h2>
    <div>
    {
    tags.map(tagItem =>
    <a
    href={`/tag/${urlifyToken(tagItem.tag)}/`}
    title={`${tagItem.count} posts tagged with "${tagItem.tag}"`}
    rel="tag"
    style={`font-size: ${tagItem.fontSize}pt;`}>{tagItem.tag}</a>
    )
    }
    </div>
    </aside>

The full code can be found on GitHub: Sidebar.astro and util.js.


These are the first 3 plugins I replaced with Astro functionality.

In the next post, I will cover more plugins, including syntax highlighting, paging, and more.

Filed under Azure
Last update:
/ Michaël Hompus

After 15 years of blogging with WordPress, I decided to take the plunge and migrate my blog to Astro. This post outlines how I moved my content, step by step, and the tools I used to make the transition.

After 15 years of blogging with WordPress, I decided to take the plunge and migrate my blog to Astro.

This post outlines how I moved my content, step by step, and the tools I used to make the transition.

This is a follow-up to my previous post: Why I Switched from WordPress to Astro: Faster, Cheaper, and Greener.

The Starting Point

As mentioned in my previous post, social reasons pushed me away from WordPress. However, even before these revelations, I was exploring alternatives. I liked WordPress’ editor and ease of use but found it frustrating to keep a virtual machine running 24/7 for a blog that was not updated regularly.

Initially, I considered an arrangement where the site could generate static content whenever updates occurred and shut down the VM otherwise. When some enthusiastic colleagues at Info Support introduced me to Astro, I realized it could be the perfect opportunity to rethink my blog entirely.

At Info Support, a team had recently rebuilt the Knowledge Center website with Astro, and their success inspired me to give it a try.

Why Not Use WordPress with Astro?

Astro does offer the option to render WordPress content using the WordPress API, but that solution still requires a running backend. This did not fit my use case, and the plugin security issues caused by Matt Mullenweg’s actions made the choice clear: I had to move away from WordPress entirely.

This realization motivated me to dive deeper and fully convert my blog to Markdown as the storage format. I found a helpful guide (How to Convert a WordPress blog to an Astro Static Site) and repository (okTurtles’ wordpress-to-astro) to kickstart my migration.

Steps to Migrate

1. Downloading the Site

I used the following wget command to download my entire site:

wget
wget -m -k -p -E https://blog.hompus.nl -D static.hompus.nl,blog.hompus.nl -H

Note

wget is a tool to download website content. In this command, the -m flag enables mirroring, -k converts links for local viewing, -p downloads necessary files, -E ensures proper file extensions, -D defines a list of domains allowed to be followed, and -H enables the spanning of hosts when downloading contents.

This approach was not perfect, but it got the job done and gave me a local copy of my site to work with.

2. Exporting Comments

Since comments have been disabled on my blog for a long time, I did not bother exporting them. This simplified the migration process significantly.

3. Exporting Posts

To export my posts, I used the Jekyll Exporter plugin. While the output was not perfect, it provided a solid starting point for importing my posts into Astro.

4. Starting with the wordpress-to-astro Project

I cloned the wordpress-to-astro project as a base for my new blog. This gave me a working Astro setup with my WordPress content preloaded.

Customizing the Blog

Once I had a basic version of the blog running, I completely overhauled the layouts, styles, and structure to fit my preferences. Although I maintained the look and feel of my original blog, I took this opportunity to modernize the technology stack.

My old blog was still built in valid XHTML 1.1. Updating to HTML5 and CSS3 allowed me to use modern web practices and improve accessibility.

Looking Ahead

In a future post, I’ll dive into the code behind my Astro blog and share more about the specific components and customizations I implemented. If you can’t wait, you can explore the source code of my blog on GitHub: https://github.com/eNeRGy164/blog.

For now, I’m thrilled with the results. The migration process taught me a lot, and the end result is a faster, greener, and more cost-effective blog that meets my needs perfectly.

Filed under Azure
Last update:
/ Michaël Hompus

For years, WordPress was my go-to platform for blogging, but it was time for a change—one that aligns better with my values, both environmentally and financially. In this post, I share the reasons behind my decision to move to an Astro app deployed on Azure Static Web Apps.

For years, WordPress was my go-to platform for blogging, but it was time for a change—one that aligns better with my values, both environmentally and financially.

While WordPress has been a fantastic tool for blogging, offering countless features and an active community, I recently decided it was time to move on.

My blog now runs as a static web app built with Astro and published on Azure Static Web Apps.

Why make this change? Let me explain.

Social Reasons to Move Away from WordPress

It’s been 15 years since I switched my blog to WordPress. Over the years, WordPress has evolved significantly, introducing features like the Gutenberg editor and seamless automatic updates for both the core system and plugins. These updates made managing my blog easier, until I realized they also introduce new risks these days.

Matt Mullenweg, the co-founder of WordPress, is undermining the ecosystem with his actions (The Register: WordPress saga escalates as WP Engine plugin forcibly forked and legal letters fly). Automatic updates, while convenient, mean that at any time, the code running on my server could be altered. This is no longer acceptable for me.

I’ve always appreciated the WordPress community, particularly people like Joost de Valk, who have contributed so much to its ecosystem. I’ve had the pleasure of meeting Joost, and lots of his colleagues, at Yoast-hosted events and enjoyed presenting at WordCamp Nijmegen in 2018.

However, Matt’s recent behavior, including his attacks on Joost and others in the community (The Register: WordPress drama latest: Leader Matt Mullenweg exiles five contributors), reinforced my decision to move away from WordPress.

While these community concerns are troubling, they weren’t my only motivation for leaving WordPress behind.

Environmental Reasons to Move Away from WordPress

Running a WordPress blog requires a server and database running 24/7. Even when I didn’t write a blog post for months, the server has to remain operational. Beyond the VM itself, resources like storage, virtual networks, and backup services also ran continuously.

Over the course of a year, this setup consumes significant energy. Let’s calculate:

  • Estimated average power usage of a “Standard B2ls v2” VM: 50W
  • Annual power consumption: 50W × 24hours/day × 365days/year = 438kWh/year
  • Equivalent carbon emissions: 438kWh/year × 0,475kg CO₂/kWh = 208,05kg CO₂/year
    (0.475kg CO₂/kWh is an average for The Netherlands)

These figures underscore how running a VM 24/7 contributes to both higher energy consumption and a larger carbon footprint.

By switching to a static web app, I’ve significantly reduced my website’s energy consumption and environmental impact.

With Astro, the only compute power used is during content generation when I update the blog. The static web apps serves the pre-generated content, meaning no server resources are wasted on idle time—an efficient alternative to 24/7 VM hosting.

Financial Reasons to Move Away from WordPress

Yes, WordPress itself is free, but Azure resources are not.

Hosting a WordPress site requires an always-on VM, storage, networking, and backups, which quickly add up in cost.

Here’s a breakdown of my Azure expenses for the WordPress setup:

  • VM cost: €28,91/month
  • Storage and networking: €11,21/month
  • Total annual cost: €481,44/year

By switching to Azure Static Web Apps, I eliminated these recurring costs. Static web hosting is not only greener but also significantly cheaper. Actually, I run on the Free tier! (Hey, I’m Dutch after all!)

The Free tier includes SSL certificates and custom domains at no cost, perfect for hosting a personal blog.

Another cost-saving bonus: With WordPress, I used WP Rocket for optimization, which cost me approximately €59 per year. WP Rocket handled minification, gzip compression, and caching. Now, Azure Static Web Apps provide gzip and Brotli compression out of the box, while Astro automatically minifies output. No additional plugins are required.

And the results? My Google Lighthouse score is now 4 × 100! (As long as I avoid YouTube embeds, because even Google doesn’t follow its own guidelines. Go figure.)

What Did the Move Cost Me?

Astro was new to me, so building an Astro blog that matched my old site took some time and effort. Not everything is available out of the box, but the flexibility was worth it.

I write this blogpost on my laptop, using Visual Studio Code and can run the whole blog in seconds by just executing npx astro dev.

I’ll dive into the details of my Astro setup, web components, and WordPress plugin replacements in a future blog post.

For now, I’m thrilled with the move. My blog is faster, cheaper, greener, and no longer reliant on WordPress updates. If you’re considering a similar switch, I’d love to hear about your experience!

Filed under Azure
Last update: