My fascination with Pango and CairoGraphics has led me to explore text rotation. I find it very interesting. It becomes straightforward once we understand a few key ideas. In this article, we focus on ±90° rotation for left‑to‑right text only.

🦀 Index of the Complete Series.

157-feature-image.png
Rust: PDFs — Text Rotation with Cairo and Pango

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

Cairo Coordinates Recap

Recall from our previous discussion, Cairo Units and Coordinates — that Cairo’s effective coordinate system (when used with PangoCairo) is top‑left. We can visualise it as illustrated below:

157-01-cairo-coordinates.png

For Latin‑based writing systems, as well as scripts such as Khmer, Lao, and Thai, text flows horizontally from 0 toward X+, and vertically from 0 toward Y+.

Cairo Text Rotation

We perform text rotation through Cairo. We rotate the cairo::Context, then draw the text using pangocairo::functions::show_layout() — the same function we used in the previous article.

The official cairo::Context method pub fn rotate(&self, angle: f64) has no documentation. In the underlying Cairo C library, the corresponding cairo_rotate() function belongs to the Transformations category, described as “Transformations — Manipulating the current transformation matrix”.

I am reproducing the official cairo_rotate() documentation below:

cairo_rotate()
void
cairo_rotate (cairo_t *cr,
              double angle);
Modifies the current transformation matrix (CTM) by rotating the user-space axes by angle radians. The rotation of the axes takes places after any existing transformation of user space. The rotation direction for positive angles is from the positive X axis toward the positive Y axis.

Parameters

cr: a cairo context angle: angle (in radians) by which the user-space axes will be rotated Since: 1.0


💡 Please note: the angle parameter to cairo_rotate() is in radians.
Briefly, 360° = 2π radians and 180° = π radians.
To convert degrees to radians, multiply degrees by π/180.
To convert radians to degrees, multiply radians by 180/π. For example:

  • 90° → radians: 90 × (π / 180) = 1.57 radians
  • 1.57 radians → degrees: 1.57 × (180 / π) = 90°

I did high school in Vietnam back in the 1980s… radians felt foreign 😂 and suggested nothing. I have since confirmed that they are taught — and called radian — but I remember none of it. Degrees make far more sense to me, both visually and intuitively.

🦀 In Rust, converting 90° to radians is simple:
90.0_f64.to_radians() or 90.0_f32.to_radians().
Next, we will look at some Rust code that performs text rotation.

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

.
├── Cargo.toml
├── set_env.bat
└── src
    ├── main_01.rs
    ├── main_02.rs
    ├── main_03.rs
    ├── main_04.rs
    ├── main_05.rs
    ├── main.rs
    └── page_geometry.rs

All main*.rs modules under src/ are self‑contained Rust programs written in the listed order to help me understand text rotation.
We discuss these modules in the listed order.

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.

Text Rotation Study Code

In this section, we look at several small study programs. Our focus is on ±90° text rotation.

90° Rotation

We start with the pdf_04_text_rotation/src/main_01.rs module — most of the code should already be familiar. The only new line is line 45:
context.rotate(90.0_f64.to_radians());

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    layout.set_text("Kỷ độ Long Tuyền đới nguyệt ma");

    // Save the current state
    context.save()?;

    context.move_to(A4_DEFAULT_MARGINS.left, A4_DEFAULT_MARGINS.top);

    // Both produce the same result.
    // context.rotate(90.0 * PI / 180.0);
    context.rotate(90.0_f64.to_radians());

    show_layout(&context, &layout);

    // Restore the context to the original matrix state for subsequent drawing operations
    context.restore()?;

When I first began studying text rotation, I assumed that before calling cairo_rotate(), we would need to call cairo_translate().
However, cairo_move_to() is sufficient.

The output it produces is shown below:

157-02-rotate-plus-90-degrees.png

In this output, both A4_DEFAULT_MARGINS.top and A4_DEFAULT_MARGINS.left are set to 57.0 PostScript points.
However, visually, the left margin appears smaller than the top margin.
According to the documentation:

The rotation direction for positive angles is from the positive X axis toward the positive Y axis.

This means that after rotation, X+ now points down, and Y+ now points left.
It follows that the rotated user‑space X origin effectively becomes
A4_DEFAULT_MARGINS.leftline height.
If we compensate the X origin by one line height (in PostScript points), the visual origin aligns correctly with the intended A4_DEFAULT_MARGINS.left.

We implement the above compensation in pdf_04_text_rotation/src/main_02.rs. We added lines 38–39 and updated line 44:

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    layout.set_text("Kỷ độ Long Tuyền đới nguyệt ma");

    let (_, logical) = layout.extents();
    let line_height = logical.height() as f64 / pango::SCALE as f64;

    // Save the current state
    context.save()?;

    context.move_to(A4_DEFAULT_MARGINS.left + line_height, A4_DEFAULT_MARGINS.top);

    // Both produce the same result.
    // context.rotate(90.0 * PI / 180.0);
    context.rotate(90.0_f64.to_radians());

    show_layout(&context, &layout);

    // Restore the context to the original matrix state for subsequent drawing operations
    context.restore()?;

The new code is not conceptually new—we have used these calls in previous articles. The user‑space axes are rotated, but the text itself is not; it retains the same metrics it would have in the default user‑space orientation. The output is now:

157-03-rotate-plus-90-degrees.png

Both margin look visually identical. But let’s verify that by printing the same line of text just below the rotated text, at the same X coordinate.

In the pdf_04_text_rotation/src/main_03.rs module:

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
    layout.set_text("Kỷ độ Long Tuyền đới nguyệt ma");

    let (_, logical) = layout.extents();
    let line_height = logical.height() as f64 / pango::SCALE as f64;
    let line_width = logical.width() as f64 / pango::SCALE as f64;

    // Save the current state
    context.save()?;

    context.move_to(A4_DEFAULT_MARGINS.left + line_height, A4_DEFAULT_MARGINS.top);

    // Both produce the same result.
    // context.rotate(90.0 * PI / 180.0);
    context.rotate(90.0_f64.to_radians());

    show_layout(&context, &layout);

    // Restore the context to the original matrix state for subsequent drawing operations
    context.restore()?;

    context.move_to(A4_DEFAULT_MARGINS.left, A4_DEFAULT_MARGINS.top + line_width);
    show_layout(&context, &layout);

We added line 40 — and after restore the original user-space in line 54 context.restore()?; — we added lines 56-57: A4_DEFAULT_MARGINS.top + line_width gives the Y coordinate just below the rotated text. The text is rotated vertically, line_width can be treated as logical height since the rotated text actually occupies line_width PostScript points veritically. The output looks like:

157-04-rotate-plus-90-degrees.png

The compensation we have implemented appears to be correct.

-90° Rotation

The pdf_04_text_rotation/src/main_04.rs module implements -90° rotation. It is a copy of pdf_04_text_rotation/src/main_01.rs with only a single modification — the angle is negative, updating line 45 to:

43
44
45
    // Both produce the same result.
    // context.rotate(-90.0 * PI / 180.0);
    context.rotate(-90.0_f64.to_radians());

The PDF looks like the screenshot below:

157-05-rotate-minus-90-degrees.png

The X coordinate visually appears correct at A4_DEFAULT_MARGINS.left. However, because the user‑space axes rotate upward with a negative angle, the text is clipped.
To position the “top” of the text at the A4_DEFAULT_MARGINS.top Y coordinate, we must compensate by
A4_DEFAULT_MARGINS.top + line_width, similar to a previous discussion.

The pdf_04_text_rotation/src/main_05.rs module implements this compensation. We added lines 38–39 and updated line 44:

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    layout.set_text("Kỷ độ Long Tuyền đới nguyệt ma");

    let (_, logical) = layout.extents();
    let line_width = logical.width() as f64 / pango::SCALE as f64;

    // Save the current state
    context.save()?;

    context.move_to(A4_DEFAULT_MARGINS.left, A4_DEFAULT_MARGINS.top + line_width);

    // Both produce the same result.
    // context.rotate(-90.0 * PI / 180.0);
    context.rotate(-90.0_f64.to_radians());

    show_layout(&context, &layout);

    // Restore the context to the original matrix state for subsequent drawing operations
    context.restore()?;

There is no new logic here. The output PDF looks like the screenshot below; visually, both margins now align with A4_DEFAULT_MARGINS.left and A4_DEFAULT_MARGINS.top:

157-06-rotate-minus-90-degrees.png

Angles Other Than ±90°

As a final example, we look at rotating by angles other than ±90°. The pdf_04_text_rotation/src/main.rs module rotates the user‑space axes in 10° steps until reaching 360°:

47
48
49
50
51
    for degree in (10..370).step_by(10) {
        context.rotate((degree as f64).to_radians());
        show_layout(&context, &layout);
        context.set_matrix(cairo::Matrix::identity());
    }

Because Cairo transformations are cumulative, we must call context.set_matrix() to reset the transformation matrix after each draw.
Instead of context.set_matrix(), we can use context.save()?; and context.restore()?;, as shown in the snippet below.
Please refer to the module for full details:

        context.save()?;
        context.rotate((degree as f64).to_radians());
        show_layout(&context, &layout);
        context.restore()?;

Its output is shown in the screenshot below:

157-07-rotate-variant-degrees.png

The ±90° Rotation and cairo-rotate()’s Documentation Revisited

The cairo-rotate() documentation states:

Modifies the current transformation matrix (CTM) by rotating the user-space axes by angle radians. The rotation of the axes takes places after any existing transformation of user space. The rotation direction for positive angles is from the positive X axis toward the positive Y axis.

It took me a while to fully understand the above paragraph, even though through trial and error I did get the ±90° rotations working. We have already seen how the code behaves; now let’s unpack this visually to cement our understanding of this transformation function.

The image below illustrates how Cairo draws text in the default user‑space axes:

Cairo Text in Default User-Space Axes
Cairo Text in Default User-Space Axes

The text simply flows toward X+ for left‑to‑right text.

“The rotation direction for positive angles is from the positive X axis toward the positive Y axis.”
For a +90° rotation, X+ points down and Y+ points left. That is:

Cairo Text in +90° Rotated User-Space Axes
Cairo Text in +90° Rotated User-Space Axes

For a -90° rotation, X+ points up and Y+ points right. That is:

Cairo Text in -90° Rotated User-Space Axes
Cairo Text in -90° Rotated User-Space Axes

💡 The key point is that the user‑space axes are rotated — not the text.
Following the directions of X+ and Y+ is the key to understanding why the text appears rotated the way it does.
I initially tried to reason about this in terms of “clockwise” and “counter‑clockwise”, but that approach failed; it does not map cleanly to how Cairo rotates the coordinate system.

What’s Next

It was fun exploring this functionality. I also looked briefly into image rotation, though not as deeply as text rotation. I may study image rotation further and share my findings in another article.

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.