X Tutup
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
427 changes: 427 additions & 0 deletions LICENSE/LICENSE_OPENMOJI

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions doc/project/license.rst
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ Fonts
.. literalinclude:: ../../LICENSE/LICENSE_LAST_RESORT_FONT
:language: none

.. dropdown:: OpenMoji Color (subset)
:class-container: sdd

.. literalinclude:: ../../LICENSE/LICENSE_OPENMOJI
:language: none

.. dropdown:: STIX
:class-container: sdd

Expand Down
33 changes: 33 additions & 0 deletions doc/release/next_whats_new/colour_font.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Support for (some) colour fonts
-------------------------------

Various colour fonts (e.g., emoji fonts) are now supported. Note, that certain
newer, more complex fonts are `not yet supported
<https://github.com/matplotlib/matplotlib/issues/31206>`__.


.. plot::

from pathlib import Path
from matplotlib.font_manager import FontProperties

zwj = '\U0000200D'
adult = '\U0001F9D1'
man = '\U0001F468'
woman = '\U0001F469'
science = '\U0001F52C'
technology = '\U0001F4BB'
skin_tones = ['', *(chr(0x1F3FB + i) for i in range(5))]

text = '\n'.join([
''.join(person + tone + zwj + occupation for tone in skin_tones)
for person in [adult, man, woman]
for occupation in [science, technology]
])

path = Path(plt.__file__).parent / 'tests/data/OpenMoji-color-glyf_colr_0-subset.ttf'

fig = plt.figure(figsize=(6.4, 4.8))
fig.text(0.5, 0.5, text,
font=FontProperties(fname=path), fontsize=48, linespacing=0.9,
horizontalalignment='center', verticalalignment='center')
30 changes: 20 additions & 10 deletions lib/matplotlib/backends/backend_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ def get_hinting_flag():
return mapping[mpl.rcParams['text.hinting']]


def _get_load_flags():
return get_hinting_flag() | LoadFlags.COLOR | LoadFlags.NO_SVG


class RendererAgg(RendererBase):
"""
The renderer handles all the drawing primitives using a graphics
Expand Down Expand Up @@ -176,7 +180,7 @@ def _draw_text_glyphs_and_boxes(self, gc, x, y, angle, glyphs, boxes):
# y is downwards.
cos = math.cos(math.radians(angle))
sin = math.sin(math.radians(angle))
load_flags = get_hinting_flag()
load_flags = _get_load_flags()
for font, size, glyph_index, slant, extend, dx, dy in glyphs: # dy is upwards.
font.set_size(size, self.dpi)
hf = font._hinting_factor
Expand All @@ -192,13 +196,19 @@ def _draw_text_glyphs_and_boxes(self, gc, x, y, angle, glyphs, boxes):
glyph_index, load_flags,
RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO)
buffer = bitmap.buffer
if not gc.get_antialiased():
buffer *= 0xff
# draw_text_image's y is downwards & the bitmap bottom side.
self._renderer.draw_text_image(
buffer,
bitmap.left, int(self.height) - bitmap.top + buffer.shape[0],
0, gc)
if buffer.ndim == 3:
self._renderer.draw_text_bgra_image(
gc,
bitmap.left, bitmap.top - buffer.shape[0],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is the y different here from the monochrome case?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It doesn't have to be done as part of this PR, but I guess the semantics of draw_text_image could be made consistent with draw_text_rgba_image by adjusting things around

int deltay = y - image.shape(0);

?

buffer)
else:
if not gc.get_antialiased():
buffer *= 0xff
# draw_text_image's y is downwards & the bitmap bottom side.
self._renderer.draw_text_image(
buffer,
bitmap.left, int(self.height) - bitmap.top + buffer.shape[0],
0, gc)

rgba = gc.get_rgb()
if len(rgba) == 3 or gc.get_forced_alpha():
Expand Down Expand Up @@ -240,7 +250,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
return self.draw_mathtext(gc, x, y, s, prop, angle)
font = self._prepare_font(prop)
items = font._layout(
s, flags=get_hinting_flag(),
s, flags=_get_load_flags(),
features=mtext.get_fontfeatures() if mtext is not None else None,
language=mtext.get_language() if mtext is not None else None)
size = prop.get_size_in_points()
Expand All @@ -262,7 +272,7 @@ def get_text_width_height_descent(self, s, prop, ismath):
return parse.width, parse.height, parse.depth

font = self._prepare_font(prop)
font.set_text(s, 0.0, flags=get_hinting_flag())
font.set_text(s, 0.0, flags=_get_load_flags())
w, h = font.get_width_height() # width and height of unrotated string
d = font.get_descent()
w /= 64.0 # convert from subpixels
Expand Down
90 changes: 90 additions & 0 deletions lib/matplotlib/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,93 @@ def _gen_multi_font_text():
# The resulting string contains 491 unique characters. Some file formats use 8-bit
# tables, which the large number of characters exercises twice over.
return fonts, test_str


def _add_family_suffix(font, suffix):
"""
Add a suffix to all names in a font.

This code comes from a fontTools snippet:
https://github.com/fonttools/fonttools/blob/main/Snippets/rename-fonts.py
"""
WINDOWS_ENGLISH_IDS = 3, 1, 0x409
MAC_ROMAN_IDS = 1, 0, 0

FAMILY_RELATED_IDS = dict(LEGACY_FAMILY=1, TRUETYPE_UNIQUE_ID=3, FULL_NAME=4,
POSTSCRIPT_NAME=6, PREFERRED_FAMILY=16, WWS_FAMILY=21)

def get_current_family_name(table):
family_name_rec = None
for plat_id, enc_id, lang_id in (WINDOWS_ENGLISH_IDS, MAC_ROMAN_IDS):
for name_id in (FAMILY_RELATED_IDS['PREFERRED_FAMILY'],
FAMILY_RELATED_IDS['LEGACY_FAMILY']):
family_name_rec = table.getName(nameID=name_id, platformID=plat_id,
platEncID=enc_id, langID=lang_id)
if family_name_rec is not None:
return family_name_rec.toUnicode()
raise ValueError("family name not found; can't add suffix")

def insert_suffix(string, family_name, suffix):
# check whether family_name is a substring
start = string.find(family_name)
if start != -1:
# insert suffix after the family_name substring
end = start + len(family_name)
return string[:end] + suffix + string[end:]
else:
# it's not, we just append the suffix at the end
return string + suffix

def rename_record(name_record, family_name, suffix):
string = name_record.toUnicode()
new_string = insert_suffix(string, family_name, suffix)
name_record.string = new_string
return string, new_string

table = font['name']
family_name = get_current_family_name(table)
ps_family_name = family_name.replace(' ', '')
ps_suffix = suffix.replace(' ', '')
for rec in table.names:
name_id = rec.nameID
if name_id not in FAMILY_RELATED_IDS.values():
continue
if name_id == FAMILY_RELATED_IDS['POSTSCRIPT_NAME']:
old, new = rename_record(rec, ps_family_name, ps_suffix)
elif name_id == FAMILY_RELATED_IDS['TRUETYPE_UNIQUE_ID']:
# The Truetype Unique ID rec may contain either the PostScript
# Name or the Full Name string, so we try both
if ps_family_name in rec.toUnicode():
old, new = rename_record(rec, ps_family_name, ps_suffix)
else:
old, new = rename_record(rec, family_name, suffix)
else:
old, new = rename_record(rec, family_name, suffix)

return family_name


def _generate_font_subset(path, text):
"""
Generate a subset of a font for testing purposes.

The font name will be suffixed with ' MplSubset'.

Parameters
----------
path : str or bytes or os.PathLike
The path to the font to be subset. The new file will be saved in the same
location with a ``-subset`` suffix.
text : str
The text from which characters to be subset will be derived. Usually fonts do
not have a newline character, so any appearing in this text will be stripped
before subsetting.
"""
from fontTools import subset
options = subset.Options()
font = subset.load_font(path, options)
subsetter = subset.Subsetter(options=options)
subsetter.populate(text=text.replace('\n', ''))
subsetter.subset(font)
_add_family_suffix(font, ' MplSubset')
subset.save_font(font, path.with_stem(path.stem + '-subset'), options)
4 changes: 4 additions & 0 deletions lib/matplotlib/testing/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from collections.abc import Callable
import os
import subprocess
from typing import Any, IO, Literal, overload
from fontTools.ttLib import TTFont

def set_font_settings_for_testing() -> None: ...
def set_reproducibility_for_testing() -> None: ...
Expand Down Expand Up @@ -56,3 +58,5 @@ def ipython_in_subprocess(
) -> None: ...
def is_ci_environment() -> bool: ...
def _gen_multi_font_text() -> tuple[list[str], str]: ...
def _add_family_suffix(font: TTFont, suffix: str) -> None: ...
def _generate_font_subset(path: str | bytes | os.PathLike, text: str) -> None: ...
2 changes: 2 additions & 0 deletions lib/matplotlib/testing/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def pytest_configure(config):
("filterwarnings",
r"ignore:DynamicImporter.find_spec\(\) not found; "
r"falling back to find_module\(\):ImportWarning"),
("filterwarnings",
r"ignore:Glyph .* \([lp]\) missing from font\(s\) OpenMoji MplSubset\."),
]:
config.addinivalue_line(key, value)

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
35 changes: 35 additions & 0 deletions lib/matplotlib/tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import gc
import inspect
import io
from pathlib import Path
import warnings

import numpy as np
Expand Down Expand Up @@ -1317,3 +1318,37 @@ def test_text_language():
t.set_language((('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4)))
assert t.get_language() == (
('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4))


@image_comparison(['colour.png'], remove_text=False, style='mpl20')
def test_colour_fonts():
zwj = '\U0000200D'
adult = '\U0001F9D1'
man = '\U0001F468'
woman = '\U0001F469'
science = '\U0001F52C'
technology = '\U0001F4BB'
skin_tones = ['', *(chr(0x1F3FB + i) for i in range(5))]

text = '\n'.join([
''.join(person + tone + zwj + occupation for tone in skin_tones)
for person in [adult, man, woman]
for occupation in [science, technology]
])

# To generate the subsetted test file, save the latest
# OpenMoji-color-glyf_colr_0.ttf from
# https://github.com/hfg-gmuend/openmoji/tree/master/font to the data directory,
# set this condition to True, and run the test.
path = Path(__file__).parent / 'data/OpenMoji-color-glyf_colr_0.ttf'
if False:
from matplotlib.testing import _generate_font_subset
_generate_font_subset(path, text)
path = path.with_stem(path.stem + '-subset')
if not path.exists():
pytest.xfail(f'Test font {path} is not available')

fig = plt.figure()
fig.text(0.5, 0.5, text,
font=FontProperties(fname=path), fontsize=48, linespacing=0.9,
horizontalalignment='center', verticalalignment='center')
37 changes: 29 additions & 8 deletions src/_backend_agg.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <algorithm>
#include <functional>
#include <optional>
#include <type_traits>
#include <vector>

#include "agg_alpha_mask_u8.h"
Expand Down Expand Up @@ -152,11 +153,18 @@ class RendererAgg
template <class ImageArray>
void draw_text_image(GCAgg &gc, ImageArray &image, int x, int y, double angle);

template <class ImageArray>
template <class ImageArray,
// This defines the input pixel order only, but is a full blender because
// Agg requires it internally.
class ImagePixelFormat=pixfmt,
// This defines the actual blender, but must use RGBA order, to correspond
// with the rendering buffer.
class Blender=pixfmt>
void draw_image(GCAgg &gc,
double x,
double y,
ImageArray &image);
ImageArray &image,
bool flip_image = false);

template <class PathGenerator,
class TransformArray,
Expand Down Expand Up @@ -807,11 +815,12 @@ class span_conv_alpha
}
};

template <class ImageArray>
template <class ImageArray, class ImagePixelFormat, class Blender>
inline void RendererAgg::draw_image(GCAgg &gc,
double x,
double y,
ImageArray &image)
ImageArray &image,
bool flip_image)
{
double alpha = gc.alpha;

Expand All @@ -823,8 +832,9 @@ inline void RendererAgg::draw_image(GCAgg &gc,
agg::rendering_buffer buffer;
buffer.attach(image.mutable_data(0, 0, 0),
(unsigned)image.shape(1), (unsigned)image.shape(0),
-(int)image.shape(1) * 4);
pixfmt pixf(buffer);
(flip_image ? 4 : -4) * image.shape(1));
// NOTE: this pixel format only describes the pixel order, not the colour state.
ImagePixelFormat pixf(buffer);

if (has_clippath) {
agg::trans_affine mtx;
Expand All @@ -847,7 +857,7 @@ inline void RendererAgg::draw_image(GCAgg &gc,
inv_mtx.invert();

typedef agg::span_allocator<agg::rgba8> color_span_alloc_type;
typedef agg::image_accessor_clip<pixfmt> image_accessor_type;
typedef agg::image_accessor_clip<ImagePixelFormat> image_accessor_type;
typedef agg::span_interpolator_linear<> interpolator_type;
typedef agg::span_image_filter_rgba_nn<image_accessor_type, interpolator_type>
image_span_gen_type;
Expand All @@ -871,10 +881,21 @@ inline void RendererAgg::draw_image(GCAgg &gc,

theRasterizer.add_path(rect2);
agg::render_scanlines(theRasterizer, scanlineAlphaMask, ri);
} else if constexpr(!std::is_same_v<Blender, pixfmt>) {
static_assert(std::is_same_v<typename Blender::order_type, agg::order_rgba>,
"Blender order must be RGBA");
auto imgFmt = Blender{renderingBuffer};
auto rendererImage = agg::renderer_base{imgFmt};
rendererImage.reset_clipping(true);
set_clipbox(gc.cliprect, rendererImage);
rendererImage.blend_from(
pixf, nullptr, (int)x, (int)(height - (y + image.shape(0))),
(agg::int8u)(alpha * 255));
} else {
set_clipbox(gc.cliprect, rendererBase);
rendererBase.blend_from(
pixf, nullptr, (int)x, (int)(height - (y + image.shape(0))), (agg::int8u)(alpha * 255));
pixf, nullptr, (int)x, (int)(height - (y + image.shape(0))),
(agg::int8u)(alpha * 255));
}

rendererBase.reset_clipping(true);
Expand Down
Loading
Loading
X Tutup