Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion extern/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ else
'brotli=disabled',
'bzip2=disabled',
get_option('system-libraqm') ? 'harfbuzz=disabled' : 'harfbuzz=static',
'mmap=auto',
'png=disabled',
'tests=disabled',
'zlib=internal',
Expand Down
26 changes: 26 additions & 0 deletions lib/matplotlib/tests/test_ft2font.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import itertools
import io
import os
import shutil
import sys
from pathlib import Path
from typing import cast

Expand Down Expand Up @@ -156,6 +158,30 @@ def __fspath__(self):
assert font.fname == file_bytes


def test_ft2font_unicode_path(tmp_path):
file = tmp_path / 'DéjàVu-Sans-日本語.ttf'
shutil.copyfile(fm.findfont('DejaVu Sans'), file)

font = ft2font.FT2Font(str(file))
font.set_text('foo')
assert font.fname == str(file)

file_bytes = os.fsencode(file)
font = ft2font.FT2Font(file_bytes)
font.set_text('foo')
assert font.fname == file_bytes


def test_ft2font_no_mmap(monkeypatch):
# Simulate platforms without the mmap module (e.g. WASI), which should fall
# back to reading the whole file into memory.
monkeypatch.setitem(sys.modules, 'mmap', None)
file = fm.findfont('DejaVu Sans')
font = ft2font.FT2Font(file)
font.set_text('foo')
assert font.fname == file


def test_ft2font_invalid_args(tmp_path):
# filename argument.
with pytest.raises(TypeError, match='to a font file or a binary-mode file object'):
Expand Down
76 changes: 48 additions & 28 deletions src/ft2font_wrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -357,14 +357,17 @@ class PyFT2Font final : public FT2Font
using FT2Font::FT2Font;

py::object py_file;
// Font data buffer read by FreeType when constructed from a path, holding
// either an mmap of the file or a bytes fallback.
py::buffer_info mem;
FT_StreamRec stream;
py::list fallbacks;

~PyFT2Font()
{
// Because destructors are called from subclass up to base class, we need to
// explicitly close the font here. Otherwise, the instance attributes here will
// be destroyed before the font itself, but those are used in the close callback.
// be destroyed before the font itself, but those are referenced by FreeType.
close();
}

Expand Down Expand Up @@ -435,21 +438,6 @@ read_from_file_callback(FT_Stream stream, unsigned long offset, unsigned char *b
return (unsigned long)n_read;
}

static void
close_file_callback(FT_Stream stream)
{
PyObject *type, *value, *traceback;
PyErr_Fetch(&type, &value, &traceback);
PyFT2Font *self = (PyFT2Font *)stream->descriptor.pointer;
try {
self->py_file.attr("close")();
} catch (py::error_already_set &eas) {
eas.discard_as_unraisable(__func__);
}
self->py_file = py::object();
PyErr_Restore(type, value, traceback);
}

const char *PyFT2Font_init__doc__ = R"""(
Parameters
----------
Expand Down Expand Up @@ -514,22 +502,50 @@ PyFT2Font_init(py::object filename, std::optional<long> hinting_factor = std::nu
}

memset(&self->stream, 0, sizeof(FT_StreamRec));
self->stream.base = nullptr;
self->stream.size = 0x7fffffff; // Unknown size.
self->stream.pos = 0;
self->stream.descriptor.pointer = self;
self->stream.read = &read_from_file_callback;
FT_Open_Args open_args;
memset((void *)&open_args, 0, sizeof(FT_Open_Args));
open_args.flags = FT_OPEN_STREAM;
open_args.stream = &self->stream;

auto PathLike = py::module_::import("os").attr("PathLike");
if (py::isinstance<py::bytes>(filename) || py::isinstance<py::str>(filename) ||
py::isinstance(filename, PathLike))
{
// Open with Python so path errors raise the usual exceptions, and
// retain the closed object to back fname.
self->py_file = py::module_::import("io").attr("open")(filename, "rb");
self->stream.close = &close_file_callback;
// Give FreeType the whole file as an in-memory buffer, so that glyph
// loads read from it directly rather than routing through the Python
// file object.
py::object data;
py::object mmap_module;
try {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not available on WASI. Is the fallback sufficient here or do we need to keep the old code-path around for that case?

@scottshambaugh scottshambaugh Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback is still solves the issue relative to the old method because it loads the whole contents of the file into memory (rather than mmap's selective loading).

But, I didn't realize mmap wasn't available on some platforms. Updated with an import check, and a test for that fallback path.

mmap_module = py::module_::import("mmap");
} catch (py::error_already_set &eas) {
if (!eas.matches(PyExc_ImportError)) {
throw;
}
// Some platforms (e.g. WASI) don't provide the mmap module.
}
if (mmap_module) {
try {
data = mmap_module.attr("mmap")(
self->py_file.attr("fileno")(), 0,
"access"_a=mmap_module.attr("ACCESS_READ"));
} catch (py::error_already_set &eas) {
if (!eas.matches(PyExc_ValueError) && !eas.matches(PyExc_OSError)) {
throw;
}
// Zero-length or otherwise unmappable file.
}
}
if (!data) {
// Fall back to a copy of the whole file into memory.
data = self->py_file.attr("read")();
}
self->py_file.attr("close")();
self->mem = py::buffer(data).request();
open_args.flags = FT_OPEN_MEMORY;
open_args.memory_base = static_cast<const FT_Byte *>(self->mem.ptr);
open_args.memory_size = static_cast<FT_Long>(self->mem.size);
} else {
try {
// This will catch various issues:
Expand All @@ -542,7 +558,11 @@ PyFT2Font_init(py::object filename, std::optional<long> hinting_factor = std::nu
"First argument must be a path to a font file or a binary-mode file object");
}
self->py_file = filename;
self->stream.close = nullptr;
self->stream.size = 0x7fffffff; // Unknown size.
self->stream.descriptor.pointer = self;
self->stream.read = &read_from_file_callback;
open_args.flags = FT_OPEN_STREAM;
open_args.stream = &self->stream;
}

self->open(open_args, face_index);
Expand All @@ -553,10 +573,10 @@ PyFT2Font_init(py::object filename, std::optional<long> hinting_factor = std::nu
static py::object
PyFT2Font_fname(PyFT2Font *self)
{
if (self->stream.close) { // User passed a filename to the constructor.
return self->py_file.attr("name");
} else {
if (self->stream.read) { // User passed a file-like object to the constructor.
return self->py_file;
} else {
return self->py_file.attr("name");
}
}

Expand Down
Loading