Skip to content

Commit c4867c4

Browse files
QuLogicanntzer
authored andcommitted
Implement support for OpenType font variations
This works similar to OpenType font features, in that you tack on an additional string to the path with a pipe, e.g., `font.ttf|feat=1|vari=2` specifies `font.ttf` with features of `feat=1` and variations of `vari=2`.
1 parent fcf17e2 commit c4867c4

File tree

5 files changed

+122
-2
lines changed

5 files changed

+122
-2
lines changed

README.rst

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ Noteworthy points include:
3333
kerning; floating point surfaces are supported with cairo≥1.17.2).
3434
- Optional multithreaded drawing of markers and path collections.
3535
- Optional support for complex text layout (right-to-left languages, etc.) and
36-
OpenType font features (see `examples/opentype_features.py`_), and partial
36+
OpenType font features (see `examples/opentype_features.py`_) and variations
37+
(see `examples/opentype_variations.py`_) (requires cairo≥1.16.0), and partial
3738
support for color fonts (e.g., emojis), using Raqm_. **Note** that Raqm
3839
depends by default on Fribidi, which is licensed under the LGPLv2.1+.
3940
- Support for embedding URLs in PDF (but not SVG) output (requires
@@ -49,6 +50,7 @@ Noteworthy points include:
4950
.. _Matplotlib: http://matplotlib.org/
5051
.. _Raqm: https://github.com/HOST-Oman/libraqm
5152
.. _examples/opentype_features.py: examples/opentype_features.py
53+
.. _examples/opentype_variations.py: examples/opentype_variations.py
5254
.. _examples/operators.py: examples/operators.py
5355

5456
Installation
@@ -106,6 +108,9 @@ path <add_dll_directory_>`_).
106108
cairo 1.15.4 added support for PDF metadata and links; the presence of this
107109
feature is detected at runtime.
108110
111+
cairo 1.16.0 added support for font variations; the presence of this feature
112+
is detected at runtime.
113+
109114
cairo 1.17.2 added support for floating point surfaces, usable with
110115
``mplcairo.set_options(float_surface=True)``; the presence of this feature
111116
is detected at runtime. However, cairo 1.17.2 (and only that version) also
@@ -456,7 +461,16 @@ language_ tag can likewise be set with ``|language=...``; currently, this
456461
always applies to the whole buffer, but a PR adding support for slicing syntax
457462
(similar to font features) would be considered.
458463

464+
OpenType font variations can be selected by appending an additional ``|`` to
465+
the filename, followed by a `Cairo font variation string`_ (e.g.,
466+
``"/path/to/font.otf||wdth=75"``); see `examples/opentype_variations.py`_. This
467+
support requires Cairo>=1.16. Note that features are parsed first, so if you do
468+
not wish to specify any features, you must specify an empty set with two pipes,
469+
i.e., ``font.otf|variations`` will treat ``variations`` as features, *not*
470+
variations.
471+
459472
.. _HarfBuzz feature string: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string
473+
.. _Cairo font variation string: https://www.cairographics.org/manual/cairo-cairo-font-options-t.html#cairo-font-options-set-variations
460474
.. _language: https://host-oman.github.io/libraqm/raqm-Raqm.html#raqm-set-language
461475

462476
The syntaxes for selecting TTC subfonts and OpenType font features and language

examples/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Roboto Flex.ttf

examples/opentype_variations.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from collections import namedtuple
2+
from pathlib import Path
3+
import string
4+
from urllib.parse import quote
5+
from urllib.request import urlretrieve
6+
from zipfile import ZipFile
7+
8+
from matplotlib import font_manager as fm, gridspec, pyplot as plt
9+
from matplotlib.widgets import Slider
10+
11+
12+
VariableAxis = namedtuple("VariableAxis", "min max default name")
13+
14+
DEFAULT_FONT = "Roboto Flex"
15+
DEFAULT_SIZE = 32
16+
VARIABLE_AXES = {
17+
"Optical Size": VariableAxis(8, 144, DEFAULT_SIZE, "opsz"),
18+
"Weight": VariableAxis(100, 1000, 400, "wght"),
19+
"Width": VariableAxis(25, 151, 100, "wdth"),
20+
"Slant": VariableAxis(-10, 0, 0, "slnt"),
21+
"Ascender Height": VariableAxis(649, 854, 750, "YTAS"),
22+
"Counter Width": VariableAxis(323, 603, 468, "XTRA"),
23+
"Descender Depth": VariableAxis(-305, -98, -203, "YTDE"),
24+
"Figure Height": VariableAxis(560, 788, 738, "YTFI"),
25+
"Grade": VariableAxis(-200, 150, 0, "GRAD"),
26+
"Lowercase Height": VariableAxis(416, 570, 514, "YTLC"),
27+
"Thin Stroke": VariableAxis(25, 135, 79, "YOPQ"),
28+
"Uppercase Height": VariableAxis(528, 760, 712, "YTUC"),
29+
}
30+
31+
path = Path(__file__).with_name(f"{DEFAULT_FONT}.ttf")
32+
if not path.exists():
33+
url = f"https://fonts.google.com/download?family={quote(DEFAULT_FONT)}"
34+
print(f"Downloading {url} to {path}")
35+
tmpfile, _ = urlretrieve(url)
36+
with ZipFile(tmpfile) as zfd:
37+
for member in zfd.namelist():
38+
if (member.startswith(DEFAULT_FONT.replace(" ", "")) and
39+
member.endswith(".ttf")):
40+
path.write_bytes(zfd.read(member))
41+
break
42+
43+
44+
def generate_font(family, size, axes):
45+
args = ",".join(f"{VARIABLE_AXES[title].name}={value}"
46+
for title, value in axes.items())
47+
font = Path(__file__).with_name(f"{family}.ttf||{args}")
48+
return fm.FontProperties(fname=font, size=size)
49+
50+
51+
fig = plt.figure(figsize=(10, 3))
52+
gs = gridspec.GridSpec(2, 1, figure=fig, height_ratios=[2, 1],
53+
bottom=0.05, top=1, left=0.14, right=0.95)
54+
55+
default_font = generate_font(
56+
DEFAULT_FONT, DEFAULT_SIZE,
57+
{title: axis.default for title, axis in VARIABLE_AXES.items()})
58+
text = fig.text(
59+
0.5, 2/3,
60+
f"{string.ascii_uppercase}\n{string.ascii_lowercase}\n{string.digits}",
61+
font=default_font,
62+
horizontalalignment="center", verticalalignment="center")
63+
64+
fig.text(0.5, 0.36, "Font Variations", horizontalalignment="center")
65+
option_gs = gs[1].subgridspec(6, 2, wspace=0.6)
66+
67+
sliders = {
68+
title: Slider(fig.add_subplot(ss), title,
69+
axis.min, axis.max, valstep=1, valinit=axis.default)
70+
for (title, axis), ss in zip(VARIABLE_AXES.items(), option_gs)
71+
}
72+
73+
74+
def update_font(value):
75+
fp = generate_font(
76+
DEFAULT_FONT, DEFAULT_SIZE,
77+
{title: slider.val for title, slider in sliders.items()})
78+
text.set_font(fp)
79+
80+
81+
for slider in sliders.values():
82+
slider.on_changed(update_font)
83+
84+
plt.show()

src/_util.cpp

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ cairo_user_data_key_t const REFS_KEY{},
5959
INIT_MATRIX_KEY{},
6060
FT_KEY{},
6161
FEATURES_KEY{},
62+
VARIATIONS_KEY{},
6263
IS_COLOR_FONT_KEY{};
6364
py::object RC_PARAMS{},
6465
PIXEL_MARKER{},
@@ -665,13 +666,14 @@ cairo_font_face_t* font_face_from_path(std::string pathspec)
665666
if (!font_face) {
666667
auto match = std::smatch{};
667668
if (!std::regex_match(pathspec, match,
668-
std::regex{"(.*?)(#(\\d+))?(\\|(.*))?"})) {
669+
std::regex{"(.*?)(#(\\d+))?(\\|([^|]*))?(\\|(.*))?"})) {
669670
throw std::runtime_error{
670671
"Failed to parse pathspec {}"_format(pathspec).cast<std::string>()};
671672
}
672673
auto const& path = match.str(1);
673674
auto const& face_index = std::atoi(match.str(3).c_str()); // 0 if absent.
674675
auto const& features_s = match.str(5);
676+
auto const& variations = match.str(7);
675677
FT_Face ft_face;
676678
if (auto const& error =
677679
FT_New_Face(detail::ft_library, path.c_str(), face_index, &ft_face)) {
@@ -709,6 +711,12 @@ cairo_font_face_t* font_face_from_path(std::string pathspec)
709711
[](void* ptr) -> void {
710712
delete static_cast<std::vector<std::string>*>(ptr);
711713
});
714+
CAIRO_CHECK_SET_USER_DATA(
715+
cairo_font_face_set_user_data,
716+
font_face, &detail::VARIATIONS_KEY, new std::string(variations),
717+
[](void* ptr) -> void {
718+
delete static_cast<std::string*>(ptr);
719+
});
712720
// Color fonts need special handling due to cairo#404 and raqm#123; see
713721
// corresponding sections of the code.
714722
if (FT_IS_SFNT(ft_face)) {
@@ -775,6 +783,16 @@ void adjust_font_options(cairo_t* cr)
775783
: aa.ptr() == Py_False ? CAIRO_ANTIALIAS_NONE
776784
: aa.cast<cairo_antialias_t>());
777785
}
786+
auto const& variations = *static_cast<std::string*>(
787+
cairo_font_face_get_user_data(font_face, &detail::VARIATIONS_KEY));
788+
if (!variations.empty()) {
789+
if (detail::cairo_font_options_set_variations) {
790+
detail::cairo_font_options_set_variations(options, variations.c_str());
791+
} else {
792+
py::module::import("warnings").attr("warn")(
793+
"cairo_font_options_set_variations requires cairo>=1.16.0");
794+
}
795+
}
778796
// The hint style is not set here: it is passed directly as load_flags to
779797
// cairo_ft_font_face_create_for_ft_face.
780798
cairo_set_font_options(cr, options);

src/_util.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ extern std::array<uint8_t, 0x10000>
3131
#define CAIRO_TAG_LINK "Link"
3232
extern void (*cairo_tag_begin)(cairo_t*, char const*, char const*);
3333
extern void (*cairo_tag_end)(cairo_t*, char const*);
34+
// Copy-pasted from cairo.h, backported from 1.16.
35+
extern void (*cairo_font_options_set_variations)(cairo_font_options_t *, const char *);
3436

3537
// Modified from cairo-pdf.h.
3638
enum cairo_pdf_version_t {};
@@ -75,6 +77,7 @@ extern void (*cairo_svg_surface_restrict_to_version)(
7577
#define ITER_CAIRO_OPTIONAL_API(_) \
7678
_(cairo_tag_begin) \
7779
_(cairo_tag_end) \
80+
_(cairo_font_options_set_variations) \
7881
_(cairo_pdf_get_versions) \
7982
_(cairo_pdf_surface_create_for_stream) \
8083
_(cairo_pdf_surface_restrict_to_version) \

0 commit comments

Comments
 (0)