Using the CairoGraphics library to render images onto PDFs. This article explores the basic image‑rendering functionalities provided by Cairo. The final objective is to incorporate image support into the Markdown minimum parser implemented in the previous article.

🦀 Index of the Complete Series.

160-feature-image.png
Rust: PDFs — Cairo and Pango — An Introduction to Image Rendering

🚀 The code for this post is in the following GitHub repository: pdf_07_image_intro.

Reference Documentation

To render images, we explore the cairo-rs crate’s ImageSurface struct. Only the following file formats are supported: png, pdf, svg, and ps. For this article, we use png images. The function we are interested in is:

Its native CairoGraphics documentation:

From the Context struct, we will explore the following methods:
pub fn save(&self) -> Result<(), Error>, pub fn restore(&self) -> Result<(), Error>, pub fn set_source_surface(&self, surface: impl AsRef<Surface>, x: f64, y: f64,) -> Result<(), Error>,
pub fn paint(&self) -> Result<(), Error>, pub fn translate(&self, tx: f64, ty: f64), and
pub fn scale(&self, sx: f64, sy: f64).

The save() and restore() methods are relatively straightforward. Their native CairoGraphics documentation:

The Rust methods set_source_surface() and paint() call the native functions:

The translate() and scale() methods fall under the Transformations category. Their native CairoGraphics documentation:

🙏 Please recall that we used a function from this transformations category in another previous article.

Repository Layout

💡 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_07_image_intro directory. The structure is:

.
├── Cargo.toml
├── set_env.bat
├── img
│   ├── 139015.png
│   ├── KTmCgCBjQXKLsO2JeBMVrA.png
│   └── Readme.md
└── src
    ├── image_layout.rs
    ├── main_01.rs
    ├── main_02.rs
    ├── main_03.rs
    ├── main_04.rs
    ├── main_05.rs
    ├── main_06.rs
    ├── main_07.rs
    ├── main.rs
    └── page_geometry.rs

We describe some modules in the following subsections. The rest will be covered in the sections that follow.

⓵ The src/page_geometry.rs module is copied unchanged from the immediate previous 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.

⓷ 💡 In the fifth article, we discussed the PKG_CONFIG_PATH user environment variable. This setting applies to all later articles. I did not mention it again from the sixth article onward. In the set_env.bat above, I include setting this variable so that we don’t forget it and avoid potential surprises.

The pdf_07_image_intro/Cargo.toml File

For the cairo-rs crate, in addition to the pdf feature—which we have been enabling—the png feature needs to be active as well. We can either edit Cargo.toml directly to enable these features, or use the cargo add command:

cargo add cairo-rs --features cairo-rs/pdf,png

💥 Note cairo-rs/pdf,png — there is no space before or after the comma (,).
Also, I am not entirely sure why the feature is named cairo-rs/pdf 😂.
The order of the required features does not appear to matter. The following order also works:

cargo add cairo-rs --features png,cairo-rs/pdf

Next, we cover the image‑rendering code—the main*.rs modules—and, in the process, the image_layout.rs module.

The Image Rendering Code

I am writing down the steps I took to understand image rendering.
🙏 I am simply sharing my learning process — please do not treat this article as a tutorial.

⓵ The pdf_07_image_intro/src/main_01.rs Module

The code excerpt below is the image‑rendering logic, written to be as simple as possible:

24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
    let surface = PdfSurface::new(a4_default_content_width(), 
        a4_default_content_height(), pdf_file_name)?;

    let context = Context::new(&surface)?;

    // Reserve the entire context. Painting an image will alter some context information.
    context.save().expect("Failed to save cairo context");

    // Define the input PNG image file name (ensure this file exists).
    let png_file_name = "./img/139015.png"; 
    // Load the PNG image into an ImageSurface.
    // The cairo library provides a function for this, accessible via the Rust bindings.
    let mut img_file = File::open(png_file_name)?;
    let image_surface = ImageSurface::create_from_png(&mut img_file)
        .map_err(|e| format!("Failed to create image surface from PNG: {}", e))?;

    // Draw the Image onto the PDF Surface:
    // Set the image surface as the source pattern for drawing
    // Draw at position (A4_DEFAULT_MARGINS.left, A4_DEFAULT_MARGINS.top).
    context.set_source_surface(&image_surface, A4_DEFAULT_MARGINS.left, A4_DEFAULT_MARGINS.top)?;

    // Paint the source surface onto the current target surface (the PDF surface).
    context.paint()?;

    // Restore the original context.
    context.restore().expect("Failed to restore cairo context");

The code should be self‑explanatory. These are the essential calls required to render an image. The next part of the code — the text rendering — should already be familiar.

💡 Disable context.save() and context.restore(), then build and run the module. The text Hello, Cairo PDF with PNG! will no longer be visible. Now enable line 61:

61
// context.set_source_rgb(1.0, 0.0, 0.0); // or any color...

The text Hello, Cairo PDF with PNG! appears in red. Image rendering alters the context state, so using context.save() and context.restore() is the recommended approach to preserve context information.

💥 The most important thing to notice in the PDF output is that only part of the image fills the entire page. See the screenshot below:

160-01.png

This is intentional and expected, because we already knew the image is much larger than the effective page area. We need to scale the image to fit the page. Let’s look at the next module, where we do exactly that.

⓶ The pdf_07_image_intro/src/main_02.rs Module

To keep the code focused, this module does not render any text.
First, we introduce a helper function:

19
20
21
fn get_scaling_factor(img_surface: &ImageSurface) -> f64 {
    a4_default_content_width() / img_surface.width() as f64
}

The scaling factor ensures the image fits within
a4_default_content_width().
It may scale the image up or down depending on its width.
In all cases, the image is resized so that its width matches a4_default_content_width().

There are two changes and some new code related specifically to scaling.
The first change is:

30
    let surface = PdfSurface::new(A4.width, A4.height, pdf_file_name)?;

Note that the first two parameters are now A4.width and A4.height, instead of a4_default_content_width() and a4_default_content_height().

🦀 I cannot offer a concise explanation for why this is required.
Even though the image width is scaled to a4_default_content_width(),
when rendered at
(A4_DEFAULT_MARGINS.left, A4_DEFAULT_MARGINS.top),
it extends all the way to the right edge — the right margin disappears.
I suspect this is related to how scaling affects the coordinate system.
Using A4.width resolves the issue.

The second part is the following new and modified code:

45
46
47
48
49
50
51
52
53
54
55
56
    let scale_factor = get_scaling_factor(&image_surface);

    // Move to the top-left content area (unscaled)
    context.translate(A4_DEFAULT_MARGINS.left, A4_DEFAULT_MARGINS.top);

    // Apply scale transformation
    context.scale(scale_factor, scale_factor); 

    // Draw the Image onto the PDF Surface:
    // Set the image surface as the source pattern for drawing
    // Draw the image at (0, 0) in scaled coordinates.
    context.set_source_surface(&image_surface, 0.0, 0.0)?;

The call to context.translate() must come before context.scale() — that is simply how Cairo’s transformation stack works. As a reminder from a
previous article,
Cairo’s effective coordinate system (when used with PangoCairo) is top‑left.

● The method context.translate(tx, ty) does the following:

  • It treats the point (tx, ty) as if it were (0, 0). The origin moves to (tx, ty).
  • It does not move the drawing — it moves the coordinate system.
  • All subsequent drawing commands are interpreted relative to the new origin.
  • The underlying surface (PDF or image) does not change its coordinate system.
  • Only the user’s view of the coordinate system changes.

● The call context.scale(sx, sy) scales the coordinate system.

context.set_source_surface(&image_surface, 0.0, 0.0) draws the image
at (0, 0) in the scaled coordinate space.

The generated PDF is shown in the screenshot below:

160-02.png

⓷ The pdf_07_image_intro/src/main_03.rs Module

Similar to main_02.rs, this module experiments with avoiding context.translate(tx, ty) and instead using an original_factor of 1.0 / scale_factor as compensation.
The result is the same, but this is not an appropriate approach. 🙏 Please also read the inline documentation.

⓸ The Shared Auxiliary Module pdf_07_image_intro/src/image_layout.rs

The image‑rendering code from main_02.rs has been refactored into this module. The public render_image_block() function is now responsible for rendering images. 🦀 Please note the new parameters reduction_factor: f64 and centre_aligned: bool — these can be considered new features that were not part of the original implementation. This function is fully documented; please refer to its documentation.

⓹ The pdf_07_image_intro/src/main_04.rs Module

This module demonstrates placing text immediately below an image using the y coordinate returned from the render_image_block() function. In the module documentation, I note that I ACCIDENTALLY get a visually nice vertical gap between the image bottom and the first line of text.

💡 During the study process, I encountered several issues with placing text below an image.
I eventually resolved them, and the following sections explore the different solutions.
I refactored render_image_block() near the end of this process, and it turns out that its returned value can be used directly as the y coordinate for the first line of text.

The function render_image_block() is now responsible for rendering images:

49
50
51
52
    // Define the input PNG image file name (ensure this file exists).
    let png_file_name = "./img/139015.png";
    let image_bottom = render_image_block(png_file_name, 
        1.0, false, A4_DEFAULT_MARGINS.top, &context)?;

The helper function layout_ink_metrics() is not new — we have used this code in previous articles to obtain layout line heights. The rest of the code is also familiar and should be self‑explanatory.

🦀 I plan to use this approach later on to render an image and its caption as a single unit.

The generated PDF is shown in the screenshot below:

160-03.png

⓺ The pdf_07_image_intro/src/main_05.rs Module

This is a retrospective module, written based on main_04.rs above.
I aim to demonstrate how I lost the vertical space:

62
63
64
65
66
    // Text ink metrics.
    let (y_bearing, height) = layout_ink_metrics(&layout);

    // Text appears below the image.
    let baseline_y = image_bottom - y_bearing;

Please note image_bottom - y_bearing — this is the cause of the problem.

⓻ The pdf_07_image_intro/src/main_06.rs Module

This module demonstrates one of the solutions I was given to address the problem shown in main_05.rs. The main focus of this module is:

60
61
62
63
64
65
66
67
68
    // Setting a natural vertical space between the image and the text line.
    let metrics = context.font_extents()?;
    let ascent = metrics.ascent() as f64 / pango::SCALE as f64;
    // let descent = metrics.descent() as f64 / pango::SCALE as f64;

    let y_bearing_equiv = -ascent;

    // Text appears below the image.
    let baseline_y = image_bottom - y_bearing_equiv;

The final result is essentially identical to main_04.rs.

⓼ The pdf_07_image_intro/src/main_07.rs Module

This module demonstrates another solution I was given to address the problem shown in main_05.rs. The main focus of this module is:

60
61
62
63
64
65
66
67
68
    // Setting a natural vertical space between the image and the text line, using `Pango`.
    let ctx = layout.context();
    let metrics = ctx.metrics(Some(&desc), None);

    let ascent = metrics.ascent() as f64 / pango::SCALE as f64;
    // let descent = metrics.descent() as f64 / pango::SCALE as f64;

    // Text appears below the image.
    let baseline_y = image_bottom + ascent;

The final result is essentially identical to main_04.rs.

⓽ The pdf_07_image_intro/src/main.rs Module

In this final demo, we exercise the two new parameters reduction_factor: f64 and centre_aligned: bool of the render_image_block() function.

For both images, we apply a reduction_factor of 0.75, small enough to fit everything onto a single page. For the first image, centre_aligned is true, centring the image within a4_default_content_width().
For the second image, centre_aligned is false, so it defaults to left‑justified.

For each text y coordinate, we simply use the returned value from render_image_block(), as demonstrated in the main_04.rs module discussed above.

The generated PDF is shown in the screenshot below:

160-04.png

We could certainly make the layout much nicer… but that is a task for another day.

What’s Next

I do apologise that this article is a bit long — I did not intend for it to grow this much. The next main objective remains the same: enabling the Markdown parser we last discussed to support images with captions, where images are specified using relative paths, similar to how it is done in LaTeX. But we are not quite there yet. In the next iteration, we will extend the render_image_block() function to support rendering an image and its caption as a single image block, and give the extended version some essential intelligence, such as ensuring the image block can be rendered properly within the available effective height.

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:

🦀 Index of the Complete Series.