Rust: PDFs — Pango and Cairo Layout — Supporting Headers
Headers are text rendered in larger font sizes, optionally in bold, italic, or bold italic. Following Markdown, we support six heading levels: #..######. This article continues and extends the work from the sixth article. The final PDF produced here renders all natural headers using distinct, externally configured font settings.
🦀 Index of the Complete Series.
![]() |
|---|
| Rust: PDFs — Pango and Cairo Layout — Supporting Headers |
🚀 The code for this post is in the following GitHub repository: pdf_05_header.
💡 Please note that Pango also supports HTML markup. I am not taking
that route because I prefer to retain as much control as possible over how the
input text is processed.
💡 Please note: on both Windows and Ubuntu, I’m running Rust version
rustc 1.90.0 (1159e78c4 2025-09-14).
This is once again a one‑off project—I don’t plan to update it in future development. I want to keep a log of progress exactly as it occurred. Future code may copy this and make changes to it. I’ve placed the project under the pdf_05_header directory. The structure is:
.
├── Cargo.toml
├── set_env.bat
├── config
│ ├── config-linux.toml
│ └── config-windows.toml
├── src
│ ├── config.rs
│ ├── document.rs
│ ├── main.rs
│ └── page_geometry.rs
└── text
└── essay.txt
The config.rs, document.rs, and main.rs
modules under src/ are new code, which we will discuss in a later
section.
⓵ The src/page_geometry.rs module is copied unchanged from the
last article.
👉 Changing any margin value in the A4_DEFAULT_MARGINS constant will
change the layout of the text in the PDF.
⓶ 💡 The code requires the Pango, HarfBuzz, Cairo,
etc. libraries. 🐧 On Ubuntu, all required libraries are globally recognised. 🪟 On Windows,
I haven’t added the paths for the libraries’ DLLs to the PATH environment
variable. In each new Windows terminal session, I run the following once:
set PATH=C:\PF\harfbuzz\dist\bin\;%PATH%
set PATH=C:\PF\vcpkg\installed\x64-windows\bin\;%PATH%
set PATH=C:\PF\pango\dist\bin;C:\PF\cairo-1.18.4\dist\bin;C:\PF\fribidi\dist\bin;%PATH%
Alternatively, you can simply run
set_env.bat.
After that, cargo run works as expected.
⓷ The text/essay.txt file — this is the same text file used in the
fourth article and
sixth article.
However, the actual intended content is only about one‑fifth of the versions used in those
two articles. I have just realised that both contained a lot of duplicated text—some
paragraphs appeared several times. This was purely a copy‑and‑paste mistake.
Also, all Unicode characters “═” (U+2550) have been replaced
with the = (equal sign) character. 🙏 We discuss these updates further in a
later section.
⓸ 💥 The build.rs module is not required—the code for the
last article
does not include a build.rs module. I did not notice this until finishing
that article. It appears that this module is no longer necessary, even though
Pango still uses the
HarfBuzz library underneath.
❷ Download and Install the
Be Vietnam Pro Font Program
As mentioned at the outset, for heading text we support bold,
italic, and bold italic. The font program
used must include faces for bold, italic, and
bold italic; otherwise, Pango will simply
fall back to the default face. The Arial Unicode MS font program
has only a single face, so it cannot be used to demonstrate the features we are
going to implement.
The Be Vietnam Pro font program includes all the
required faces and is well suited for our needs. Regarding copyright, we are not
using it for commercial purposes, so I hope that is okay 😂
⓵ Download the Font Program
Download Be_Vietnam_Pro.zip from
https://fonts.google.com/specimen/Be+Vietnam+Pro.
⓶ Install On 🪟 Windows
Unpack all individual ttf files from Be_Vietnam_Pro.zip.
Right‑click each ttf file and select Install from the
pop‑up menu.
You can verify the installation using Windows Font settings, as illustrated in the screenshot below:

⓷ Install On 🐧 Ubuntu
Unpack all ttf files from Be_Vietnam_Pro.zip into
/home/behai/Public/, then copy them to
/usr/local/share/fonts/ using:
$ sudo mv /home/behai/Public/*.* /usr/local/share/fonts/.
We can verify they are present using sudo ls -l /usr/local/share/fonts.
The result is shown in the screenshot below:

Next, update the system font cache using:
$ sudo fc-cache -f -v
This command produces a long output. To confirm that the new font program
Be Vietnam Pro has been installed, run:
$ fc-list | grep "Be Vietnam Pro"
It appears that Be Vietnam Pro has indeed been installed, as shown
in the screenshot below:

I did struggle a bit while developing the code. It took several iterations to arrive at the final version presented in this article. The final code feels straightforward enough that I decided not to include any intermediate versions.
⓵ The Updated
pdf_05_header/text/essay.txt File
Further to the fixes discussed earlier, we also made additional updates to support headers. The first lines now look like this:
# Thỏa Hiệp Án Fontainebleau 14/09/1946: ông Hồ cấu kết với Pháp để tiêu diệt các đảng quốc gia.
###Tác Giả: Hứa Hoành.
## Kéo rốc sang Pháp làm gì?
...
As mentioned at the outset, we are using Markdown format:
# indicates header 1, ## indicates
header 2, and so on. The input text file uses headers only up to
header 3.
We now discuss the configuration files
pdf_05_header/config/config-windows.toml and
pdf_05_header/config/config-linux.toml, where we specify
font information for the different text types; and their management module
pdf_05_header/src/config.rs.
Instead of having both config-windows.toml and config-linux.toml,
we could use a single config.toml. For now, let’s look at
config-windows.toml:
[fonts]
paragraph = { family = "Be Vietnam Pro", size = 12, weight = "normal", style = "normal" }
# Headers 1, 2, 3, 4, 5, 6.
headers = [
{ family = "Be Vietnam Pro", size = 20, weight = "bold", style = "italic" },
{ family = "Be Vietnam Pro", size = 16, weight = "bold", style = "normal" },
{ family = "Be Vietnam Pro", size = 14, weight = "bold", style = "italic" },
{ family = "Be Vietnam Pro", size = 15, weight = "bold", style = "italic" },
{ family = "Be Vietnam Pro", size = 14, weight = "normal", style = "normal" },
{ family = "Be Vietnam Pro", size = 13, weight = "bold", style = "normal" }
]
page_number = { family = "Arial", size = 10, weight = "bold", style = "normal" }
● paragraph — the font applied to normal text. The family
is Be Vietnam Pro; size is 12 points;
weight = "normal" means regular (not bold);
style = "normal" means upright (not italic).
● headers — loosely speaking, headers[0] is the font
applied to header 1, and headers[5] is the font applied
to header 6.
● page_number — the font applied to the page number. This is the only difference from config-linux.toml, where we also use Be Vietnam Pro instead of Arial.
The pdf_05_header/src/config.rs module is simple and should be self‑explanatory. We take advantage of the serde and toml crates to load the configuration. 🦀 We define the struct Config so that deserialisation can take place automatically.
⓷ The
pdf_05_header/src/document.rs Module
This module defines the structures that hold the parsed contents of the input text file.
● The enum Block — each Block represents a single line from the input text file. Consider the first few lines shown earlier:
# Thỏa Hiệp Án Fontainebleau 14/09/1946: ông Hồ cấu kết với Pháp để tiêu diệt các đảng quốc gia.
###Tác Giả: Hứa Hoành.
## Kéo rốc sang Pháp làm gì?
...
This results in five Blocks:
-
Header—level: 1;text: “Thỏa...gia.”. -
Paragraph—text: blank line. -
Header—level: 3;text: “Tác Giả: Hứa Hoành.”. -
Paragraph—text: blank line. -
Header—level: 2;text: “Kéo rốc sang Pháp làm gì?”.
● The
struct PositionedBlock — we apply the appropriate font to each
Block and ask Pango to lay out the text in memory. As we have
studied before, Pango breaks the text into lines. Each
PositionedBlock contains the measured and broken lines that fit on a single
page. It follows that a single Block may span multiple
PositionedBlocks. Refer to the inline documentation for a detailed
description of each field.
We discard the layout memory after measuring. When writing to the PDF, we iterate over
the PositionedBlock vector, apply the same font again (ensuring identical
line breaking), and write the lines to the PDF document.
⓸ The
pdf_05_header/src/main.rs Module
We cover some of the new code here; the rest is very similar to what we have already discussed in previous articles in this series.
● The
to_pango_description() method — creates and returns a
pango::FontDescription
based on the
struct FontSpec information defined in config.rs.
As mentioned earlier, the selected font must
provide the faces for the weights and styles referenced in this method.
● The
parse_blocks_from_file() function — reads the input text file
line by line and parses each line into a Block, as described in
this section. It returns a vector of
Blocks representing the entire input text file.
● The
measure_block() function — this is perhaps the most
tricky part of the entire codebase. For each Block in the vector,
we create a memory layout using the nominated font. Then, for each layout line,
we determine whether there is enough vertical space remaining on the current page
to fit it; if not, we advance to the next page. This process is very similar to
writing to the actual PDF document, except that we do not draw anything yet.
Instead, we store the relevant layout information in
PositionedBlock, as discussed earlier.
🦀 The same font is applied to the entire Block’s text, which means
all layout lines have the same height. However, inside the loop we still retrieve
the height of each layout line:
196
197
for line_index in 0..layout.line_count() {
line_height = measure_line_height(line_index, &layout);
I am aware of this oddity. In the future, we may support additional features that
could vary text height within a single Block, so keeping this logic
in place is safer.
I was caught by the following detail:
209
210
211
// Advance y so the next line does not overlap.
// `line_height` of the line that `line_index` points to.
y += line_height;
Without advancing y by line_height, under certain
conditions the first blank line on the next page is lost.
This method returns a vector of PositionedBlock. There can be more
PositionedBlocks than Blocks — this is not important
for the implementation, but it is an interesting detail from a logical perspective.
● The
output_positioned_block() function — it should be clear by now
that a PositionedBlock contains either the entire text or
a partial segment of only a single Block.
It uses the Block information to create the font for the layout, and the
PositionedBlock information to write the corresponding text to the PDF.
● The remaining code that we have not discussed should be straightforward and self‑explanatory.
The screenshots below show some PDF pages generated on 🐧 Ubuntu:
This heading feature feels very useful, and I’m really glad I took the time to explore it. In the future, I would like to support additional features—for example, bold, italic, and bold italic within normal paragraphs. That will be a story for another day.
Thanks for reading! I hope this post helps others who are looking to deepen their understanding of PDF technology. As always—stay curious, stay safe 🦊
✿✿✿
Feature image sources:
- https://www.omgubuntu.co.uk/2024/03/ubuntu-24-04-wallpaper
- https://in.pinterest.com/pin/337277459600111737/
- https://medium.com/analytics-vidhya/rust-adventures-from-java-class-to-rust-struct-1d63b66890cf/
- https://www.pngitem.com/download/ibmJoR_rust-language-hd-png-download/
- https://en.wikipedia.org/wiki/Cairo_%28graphics%29#/media/File:Cairo_banner_closeup.svg
- https://ur.wikipedia.org/wiki/%D9%81%D8%A7%D8%A6%D9%84:HarfBuzz.svg
- https://en.wikipedia.org/wiki/Pango
