diff --git a/pyface/font.py b/pyface/font.py new file mode 100644 index 000000000..09499428a --- /dev/null +++ b/pyface/font.py @@ -0,0 +1,406 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Toolkit-independent font utilities. + +Pyface fonts are intended to be generic, but able to be mapped fairly well +to most backend toolkit font descriptions. In most cases we can describe +fonts along the common dimensions that are used by CSS, Wx, and Qt. However +when it comes to actually working with a font, the toolkit needs to take the +description and produce something that is as close as possible to the +specification, but within the constraints of the toolkit, operating system +and available fonts on the machine where this is being executed. + +Because of this inherent ambiguity in font specification, this system tries to +be flexible in what it accepts as a font specification, rather than trying to +specify a unique canoncial form. + +Font Properties +--------------- + +The properties that fonts have are: + +Font Family + A list of font family names in order of preference, such as "Helvetica" + or "Comic Sans". In the case of a font that has been selected by the + toolkit this list will have one value which is the actual font family name. + + There are several generic font family names that can be used as fall-backs + in case all preferred fonts are unavailable. The allowed values are: + + "default" + The application's default system font. + + "fantasy" + A primarily decorative font, but with recognisable characters. + + "decorative" + A synonym for "fantasy". + + "serif" + A proportional serif font, such as Times New Roman or Garamond. + + "roman" + A synonym for "serif". + + "cursive" + A font which resembles hand-written cursive text, such as Zapf + Chancery. + + "script" + A synonym for "cursive". + + "sans-serif" + A proportional sans-serif font, such as Helvetica or Arial. + + "swiss" + A synonym for "sans-serif". + + "monospace" + A fixed-pitch sans-serif font, such as Source Code Pro or Roboto Mono. + Commonly used for display of code. + + "modern" + A synonym for "monospace". + + "typewriter" + A fixed-pitch serif font which resembles typewritten text, such as + Courier. Commonly used for display of code. + + "teletype" + A synonym for "typewriter". + + These special names will be converted into appropriate toolkit flags which + correspond to these generic font specifications. + +Weight + How thick or dark the font glyphs are. These can be given as a number + from 1 (lightest) to 999 (darkest), but are typically specified by a + multiple of 100 from 100 to 900, with a number of synonyms such as 'light' + and 'bold' available for those values. + +Stretch + The amount of horizontal compression or expansion to apply to the glyphs. + These can be given as a percentage between 50% and 200%, or by strings + such as 'condensed' and 'expanded' that correspond to those values. + +Style + This selects either 'oblique' or 'italic' variants typefaces of the given + font family. If neither is wanted, the value is 'normal'. + +Size + The overall size of the glyphs. This can be expressed either as the + numeric size in points, or as a string such as "small" or "large". + +Variants + A set of additional font style specifiers, such as "small-caps", + "strikethrough", "underline" or "overline", where supported by the + underlying toolkit. + +Font Specificiation Class +------------------------- + +The Pyface Font class is a HasStrictTraits class which specifies a requested +font. It has methods that convert the Font class to and from a toolkit Font +class. + +""" +from traits.api import ( + BaseCFloat, CList, CSet, Enum, HasStrictTraits, Map, Str, +) +from traits.trait_type import NoDefaultSpecified + + +#: Font weight synonyms. +#: These are alternate convenience names for font weights. +#: The intent is to allow a developer to use a common name (eg. "bold") instead +#: of having to remember the corresponding number (eg. 700). +#: These come from: +#: - the OpenType specification: https://docs.microsoft.com/en-us/typography/opentype/spec/os2#usweightclass +#: - QFont weights: https://doc.qt.io/qt-5/qfont.html#Weight-enum +#: - WxPython font weights: https://wxpython.org/Phoenix/docs/html/wx.FontWeight.enumeration.html +#: - CSS Common weight name mapping: https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping +#: - values used by Enable: https://github.com/enthought/enable/blob/78d2e494097fac71cc5c73efef5fb464963fb4db/kiva/fonttools/_constants.py#L90-L105 +#: See also: https://gist.github.com/lukaszgrolik/5849599 +WEIGHTS = {str(i): i for i in range(100, 1001, 100)} +WEIGHTS.update({ + 'thin': 100, + 'hairline': 100, + 'extra-light': 200, + 'ultra-light': 200, + 'ultralight': 200, + 'light': 300, + 'normal': 400, + 'regular': 400, + 'book': 400, + 'medium': 500, + 'roman': 500, + 'semi-bold': 600, + 'demi-bold': 600, + 'demi': 600, + 'bold': 700, + 'extra-bold': 800, + 'ultra-bold': 800, + 'extra bold': 800, + 'black': 900, + 'heavy': 900, + 'extra-heavy': 1000, +}) + +#: Font stretch synonyms. +#: These are alternate convenience names for font stretch/width values. +#: The intent is to allow a developer to use a common name (eg. "expanded") +#: instead of having to remember the corresponding number (eg. 125). +#: These come from: +#: - the OpenType specification: https://docs.microsoft.com/en-us/typography/opentype/spec/os2#uswidthclass +#: - QFont stetch: https://doc.qt.io/qt-5/qfont.html#Stretch-enum +#: - CSS font-stretch: https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch +#: - values used by Enable: https://github.com/enthought/enable/blob/78d2e494097fac71cc5c73efef5fb464963fb4db/kiva/fonttools/_constants.py#L78-L88 +STRETCHES = { + 'ultra-condensed': 50, + 'extra-condensed': 62.5, + 'condensed': 75, + 'semi-condensed': 87.5, + 'normal': 100, + 'semi-expanded': 112.5, + 'expanded': 125, + 'extra-expanded': 150, + 'ultra-expanded': 200, +} + +#: Font size synonyms. +#: These are alternate convenience names for font size values. +#: The intent is to allow a developer to use a common name (eg. "small") +#: instead of having to remember the corresponding number (eg. 10). +#: These come from CSS font-size: https://developer.mozilla.org/en-US/docs/Web/CSS/font-size +SIZES = { + 'xx-small': 7.0, + 'x-small': 9.0, + 'small': 10.0, + 'medium': 12.0, + 'large': 14.0, + 'x-large': 18.0, + 'xx-large': 20.0, +} + +STYLES = ('normal', 'italic', 'oblique') + +#: Font variants. Currently only small caps variants are exposed in Qt, and +#: nothing in Wx. In the future this could include things like swashes, +#: numeric variants, and so on, as exposed in the toolkit. +VARIANTS = ['small-caps'] + +#: Additional markings on or around the glyphs of the font that are not part +#: of the glyphs themselves. Currently Qt and Wx support underline and +#: strikethrough, and Qt supports overline. In the future overlines and other +#: decorations may be supported, as exposed in the toolkit. +DECORATIONS = ['underline', 'strikethrough', 'overline'] + +#: A trait for font families. +FontFamily = CList(Str, ['default']) + +#: A trait for font weights. +FontWeight = Map(WEIGHTS, default_value='normal') + +#: A trait for font styles. +FontStyle = Enum(STYLES) + +#: A trait for font variant properties. +FontVariants = CSet(Enum(VARIANTS)) + +#: A trait for font decorator properties. +FontDecorations = CSet(Enum(DECORATIONS)) + + +class FontStretch(BaseCFloat): + """ Trait type for font stretches. + + The is a CFloat trait which holds floating point values between 50 and 200, + inclusive. In addition to values which can be converted to floats, this + trait also accepts named synonyms for sizes which are converted to the + associated commonly accepted weights: + + - 'ultra-condensed': 50 + - 'extra-condensed': 62.5 + - 'condensed': 75 + - 'semi-condensed': 87.5 + - 'normal': 100 + - 'semi-expanded': 112.5 + - 'expanded': 125 + - 'extra-expanded': 150 + - 'ultra-expanded': 200 + """ + + #: The default value for the trait. + default_value = 100.0 + + def __init__(self, default_value=NoDefaultSpecified, **metadata): + if default_value != NoDefaultSpecified: + default_value = self.validate(None, None, default_value) + super().__init__(default_value, **metadata) + + def validate(self, object, name, value): + if isinstance(value, str) and value.endswith('%'): + value = value[:-1] + value = STRETCHES.get(value, value) + value = super().validate(object, name, value) + if not 50 <= value <= 200: + self.error(object, name, value) + return value + + def info(self): + info = ( + "a float from 50 to 200, " + "a value that can convert to a float from 50 to 200, " + ) + info += ', '.join(repr(key) for key in SIZES) + info += ( + " or a string with a float value from 50 to 200 followed by '%'" + ) + return info + + +class FontSize(BaseCFloat): + """ Trait type for font sizes. + + The is a CFloat trait which also allows values which are keys of the + size dictionary, and also ignores trailing 'pt' ot 'px' annotation in + string values. The value stored is a float. + """ + + #: The default value for the trait. + default_value = 12.0 + + def __init__(self, default_value=NoDefaultSpecified, **metadata): + if default_value != NoDefaultSpecified: + default_value = self.validate(None, None, default_value) + super().__init__(default_value, **metadata) + + def validate(self, object, name, value): + if ( + isinstance(value, str) + and (value.endswith('pt') or value.endswith('px')) + ): + value = value[:-2] + value = SIZES.get(value, value) + value = super().validate(object, name, value) + if value <= 0: + self.error(object, name, value) + return value + + def info(self): + info = ( + "a positive float, a value that can convert to a positive float, " + ) + info += ', '.join(repr(key) for key in SIZES) + info += ( + " or a string with a positive float value followed by 'pt' or 'px'" + ) + return info + + +class Font(HasStrictTraits): + """A toolkit-independent font specification. + + This class represents a *request* for a font with certain characteristics, + not a concrete font that can be used for drawing. Font objects returned + from the toolkit may or may not match what was requested, depending on the + capabilities of the toolkit, OS, and the fonts installed on a particular + computer. + """ + + #: The preferred font families. + family = FontFamily() + + #: The weight of the font. + weight = FontWeight() + + #: How much the font is expanded or compressed. + stretch = FontStretch() + + #: The style of the font. + style = FontStyle() + + #: The size of the font. + size = FontSize() + + #: The font variants. + variants = FontVariants() + + #: The font decorations. + decorations = FontDecorations() + + @classmethod + def from_toolkit(cls, toolkit_font): + """ Create a Font from a toolkit font object. + + Parameters + ---------- + toolkit_font : any + A toolkit font to be converted to a corresponding class instance, + within the limitations of the options supported by the class. + """ + from pyface.toolkit import toolkit_object + toolkit_font_to_properties = toolkit_object( + 'font:toolkit_font_to_properties') + + return cls(**toolkit_font_to_properties(toolkit_font)) + + def to_toolkit(self): + """ Create a toolkit font object from the Font instance. + + Returns + ------- + toolkit_font : any + A toolkit font which matches the property of the font as + closely as possible given the constraints of the toolkit. + """ + from pyface.toolkit import toolkit_object + font_to_toolkit_font = toolkit_object('font:font_to_toolkit_font') + + return font_to_toolkit_font(self) + + def __str__(self): + """ Produce a CSS-style representation of the font. """ + terms = [] + if self.style != 'normal': + terms.append(self.style) + terms.extend( + variant for variant in VARIANTS + if variant in self.variants + ) + terms.extend( + decoration for decoration in DECORATIONS + if decoration in self.decorations + ) + if self.weight != 'normal': + terms.append(self.weight) + if self.stretch != 100: + terms.append("{:g}%".format(self.stretch)) + size = self.size + # if size is an integer we want "12pt" not "12.pt" + if int(size) == size: + size = int(size) + terms.append("{}pt".format(size)) + terms.append( + ', '.join( + repr(family) if ' ' in family else family + for family in self.family + ) + ) + return ' '.join(terms) + + def __repr__(self): + traits = self.trait_get(self.editable_traits()) + trait_args = ', '.join( + "{}={!r}".format(name, value) + for name, value in traits.items() + ) + return "{}({})".format(self.__class__.__name__, trait_args) diff --git a/pyface/qt/QtGui.py b/pyface/qt/QtGui.py index 191d800c8..1992e9878 100644 --- a/pyface/qt/QtGui.py +++ b/pyface/qt/QtGui.py @@ -13,6 +13,12 @@ from PyQt4.Qt import QKeySequence, QTextCursor from PyQt4.QtGui import * + # forward-compatible font weights + # see https://doc.qt.io/qt-5/qfont.html#Weight-enum + QFont.ExtraLight = 12 + QFont.Medium = 57 + QFont.ExtraBold = 81 + elif qt_api == "pyqt5": from PyQt5.QtGui import * from PyQt5.QtWidgets import * diff --git a/pyface/tests/test_font.py b/pyface/tests/test_font.py new file mode 100644 index 000000000..b6f85e5b1 --- /dev/null +++ b/pyface/tests/test_font.py @@ -0,0 +1,295 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import unittest + +from traits.api import HasStrictTraits, TraitError + +from pyface.font import Font, FontSize, FontStretch, SIZES, STRETCHES +from pyface.toolkit import toolkit_object + + +class FontSizeDummy(HasStrictTraits): + + size = FontSize() + size_default_1 = FontSize(14.0) + size_default_2 = FontSize("14.0") + size_default_3 = FontSize("14.0pt") + size_default_4 = FontSize("large") + + +class TestFontSizeTrait(unittest.TestCase): + + def test_font_size_trait_defaults(self): + dummy = FontSizeDummy() + + self.assertEqual(dummy.size, 12.0) + self.assertEqual(dummy.size_default_1, 14.0) + self.assertEqual(dummy.size_default_2, 14.0) + self.assertEqual(dummy.size_default_3, 14.0) + self.assertEqual(dummy.size_default_4, 14.0) + + def test_font_sizes(self): + dummy = FontSizeDummy() + for size, value in SIZES.items(): + with self.subTest(size=size): + dummy.size = size + self.assertEqual(dummy.size, value) + + def test_font_size_trait_invalid_default(self): + for size in ["badvalue", -1.0, "-1.0", "0pt"]: + with self.subTest(size=size): + with self.assertRaises(TraitError): + FontSize(size) + + def test_font_size_trait_validate(self): + dummy = FontSizeDummy() + for size, expected in [ + (14.0, 14.0), + ("15.0", 15.0), + ("16pt", 16.0), + ("17.0px", 17.0), + ]: + with self.subTest(size=size): + dummy.size = size + self.assertEqual(dummy.size, expected) + + def test_font_size_trait_invalid_validate(self): + dummy = FontSizeDummy() + for size in ["badvalue", -1.0, "-1.0", "0pt"]: + with self.subTest(size=size): + with self.assertRaises(TraitError): + dummy.size = size + + +class FontStretchDummy(HasStrictTraits): + + stretch = FontStretch() + stretch_default_1 = FontStretch(150) + stretch_default_2 = FontStretch("150.0") + stretch_default_3 = FontStretch("150.0%") + stretch_default_4 = FontStretch("expanded") + + +class TestFontStretchTrait(unittest.TestCase): + + def test_font_stretch_trait_defaults(self): + dummy = FontStretchDummy() + + self.assertEqual(dummy.stretch, 100.0) + self.assertEqual(dummy.stretch_default_1, 150.0) + self.assertEqual(dummy.stretch_default_2, 150.0) + self.assertEqual(dummy.stretch_default_3, 150.0) + self.assertEqual(dummy.stretch_default_4, 125.0) + + def test_font_stretches(self): + dummy = FontStretchDummy() + for stretch, value in STRETCHES.items(): + with self.subTest(stretch=stretch): + dummy.stretch = stretch + self.assertEqual(dummy.stretch, value) + + def test_font_stretch_trait_invalid_default(self): + for stretch in ["badvalue", 49.5, "49.5", "49.5%", 200.1]: + with self.subTest(stretch=stretch): + with self.assertRaises(TraitError): + FontStretch(stretch) + + def test_font_stretch_trait_validate(self): + dummy = FontStretchDummy() + + for stretch, expected in [ + (150.0, 150.0), + ("125", 125.0), + ("50%", 50.0), + ("ultra-expanded", 200.0) + ]: + with self.subTest(stretch=stretch): + dummy.stretch = stretch + self.assertEqual(dummy.stretch, expected) + + def test_font_stretch_trait_invalid_validate(self): + dummy = FontStretchDummy() + for stretch in ["badvalue", 49.5, "200.1", "49.9%"]: + with self.subTest(stretch=stretch): + with self.assertRaises(TraitError): + dummy.stretch = stretch + + +class TestFont(unittest.TestCase): + + def test_default(self): + font = Font() + + self.assertEqual(font.family, ['default']) + self.assertEqual(font.size, 12.0) + self.assertEqual(font.weight, 'normal') + self.assertEqual(font.stretch, 100) + self.assertEqual(font.style, 'normal') + self.assertEqual(font.variants, set()) + + def test_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demi-bold', + stretch='condensed', + style='italic', + variants={'small-caps'}, + decorations={'underline'}, + ) + + self.assertEqual(font.family, ['Helvetica', 'sans-serif']) + self.assertEqual(font.size, 14.0) + self.assertEqual(font.weight, 'demi-bold') + self.assertEqual(font.weight_, 600) + self.assertEqual(font.stretch, 75) + self.assertEqual(font.style, 'italic') + self.assertEqual(font.variants, {'small-caps'}) + self.assertEqual(font.decorations, {'underline'}) + + def test_family_sequence(self): + font = Font(family=('Helvetica', 'sans-serif')) + self.assertEqual(font.family, ['Helvetica', 'sans-serif']) + + def test_variants_frozenset(self): + font = Font(variants=frozenset({'small-caps'})) + self.assertEqual(font.variants, {'small-caps'}) + + def test_decorations_frozenset(self): + font = Font(decorations=frozenset({'underline'})) + self.assertEqual(font.decorations, {'underline'}) + + def test_str(self): + font = Font() + + description = str(font) + + self.assertEqual(description, "12pt default") + + def test_str_typical(self): + font = Font( + family=['Comic Sans', 'decorative'], + size='large', + weight='demi-bold', + stretch='condensed', + style='italic', + variants={'small-caps'}, + decorations={'underline'}, + ) + + description = str(font) + + self.assertEqual( + description, + "italic small-caps underline demi-bold 75% 14pt " + "'Comic Sans', decorative" + ) + + def test_repr(self): + font = Font() + + text = repr(font) + + # this is little more than a smoke check, but good enough + self.assertTrue(text.startswith('Font(')) + + def test_repr_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demi-bold', + stretch='condensed', + style='italic', + variants={'small-caps'}, + decorations={'underline'}, + ) + + text = repr(font) + + # this is little more than a smoke check, but good enough + self.assertTrue(text.startswith('Font(')) + + def test_to_toolkit(self): + font = Font() + + # smoke test + toolkit_font = font.to_toolkit() + + def test_to_toolkit_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demi-bold', + stretch='condensed', + style='italic', + variants={'small-caps'}, + decorations={'underline', 'strikethrough', 'overline'}, + ) + + # smoke test + toolkit_font = font.to_toolkit() + + def test_toolkit_default_roundtrip(self): + font = Font() + + # smoke test + result = Font.from_toolkit(font.to_toolkit()) + + # defaults should round-trip + self.assertTrue(result.family[-1], 'default') + self.assertEqual(result.size, font.size) + self.assertEqual(result.weight, font.weight) + self.assertEqual(result.stretch, font.stretch) + self.assertEqual(result.variants, font.variants) + self.assertEqual(result.decorations, font.decorations) + + def test_from_toolkit_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='bold', + stretch='condensed', + style='italic', + variants={'small-caps'}, + decorations={'underline', 'strikethrough', 'overline'}, + ) + + # smoke test + result = Font.from_toolkit(font.to_toolkit()) + + # we expect some things should round-trip no matter what system + self.assertEqual(result.size, font.size) + self.assertEqual(result.weight, font.weight) + self.assertEqual(result.style, font.style) + + def test_toolkit_font_to_properties(self): + toolkit_font_to_properties = toolkit_object( + 'font:toolkit_font_to_properties') + + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demi-bold', + stretch='condensed', + style='italic', + variants={'small-caps'}, + decorations={'underline', 'strikethrough', 'overline'}, + ) + + properties = toolkit_font_to_properties(font.to_toolkit()) + + self.assertEqual( + set(properties.keys()), + { + 'family', 'size', 'stretch', 'weight', 'style', 'variants', + 'decorations' + } + ) diff --git a/pyface/ui/qt4/font.py b/pyface/ui/qt4/font.py new file mode 100644 index 000000000..782afc46b --- /dev/null +++ b/pyface/ui/qt4/font.py @@ -0,0 +1,202 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" +Font conversion utilities + +This module provides facilities for converting between pyface Font objects +and Qt QFont objects, trying to keep as much similarity as possible between +them. +""" + +from pyface.qt.QtGui import QFont + + +qt_family_to_generic_family = { + QFont.AnyStyle: 'default', + QFont.System: 'default', + QFont.Decorative: 'fantasy', + QFont.Serif: 'serif', + QFont.Cursive: 'cursive', + QFont.SansSerif: 'sans-serif', + QFont.Monospace: 'monospace', + QFont.TypeWriter: 'typewriter', +} +generic_family_to_qt_family = { + 'default': QFont.System, + 'fantasy': QFont.Decorative, + 'decorative': QFont.Decorative, + 'serif': QFont.Serif, + 'roman': QFont.Serif, + 'cursive': QFont.Cursive, + 'script': QFont.Cursive, + 'sans-serif': QFont.SansSerif, + 'swiss': QFont.SansSerif, + 'monospace': QFont.Monospace, + 'modern': QFont.Monospace, + 'typewriter': QFont.TypeWriter, + 'teletype': QFont.TypeWriter, +} + +weight_to_qt_weight = { + 100: QFont.Thin, + 200: QFont.ExtraLight, + 300: QFont.Light, + 400: QFont.Normal, + 500: QFont.Medium, + 600: QFont.DemiBold, + 700: QFont.Bold, + 800: QFont.ExtraBold, + 900: QFont.Black, + 1000: 99, +} +qt_weight_to_weight = { + QFont.Thin: 'thin', + QFont.ExtraLight: 'extra-light', + QFont.Light: 'light', + QFont.Normal: 'normal', + QFont.Medium: 'medium', + QFont.DemiBold: 'demi-bold', + QFont.Bold: 'bold', + QFont.ExtraBold: 'extra-bold', + QFont.Black: 'black', + 99: 'extra-heavy', +} + +style_to_qt_style = { + 'normal': QFont.StyleNormal, + 'oblique': QFont.StyleOblique, + 'italic': QFont.StyleItalic, +} +qt_style_to_style = {value: key for key, value in style_to_qt_style.items()} + + +def font_to_toolkit_font(font): + """ Convert a Pyface font to a Qfont. + + Parameters + ---------- + font : pyface.font.Font + The Pyface font to convert. + + Returns + ------- + qt_font : QFont + The best matching Qt font. + """ + qt_font = QFont() + families = [] + default_family = None + + for family in font.family: + if family not in generic_family_to_qt_family: + families.append(family) + elif default_family is None: + default_family = family + + if families and hasattr(qt_font, 'setFamilies'): + # Qt 5.13 and later + qt_font.setFamilies(families) + elif families: + qt_font.setFamily(families[0]) + # Note: possibily could use substitutions here, + # but not sure if global (which would be bad, so we don't) + + if default_family is not None: + qt_font.setStyleHint(generic_family_to_qt_family[default_family]) + + qt_font.setPointSizeF(font.size) + qt_font.setWeight(weight_to_qt_weight[font.weight_]) + qt_font.setStretch(font.stretch) + qt_font.setStyle(style_to_qt_style[font.style]) + qt_font.setUnderline('underline' in font.decorations) + qt_font.setStrikeOut('strikethrough' in font.decorations) + qt_font.setOverline('overline' in font.decorations) + if 'small-caps' in font.variants: + qt_font.setCapitalization(QFont.SmallCaps) + return qt_font + + +def toolkit_font_to_properties(toolkit_font): + """ Convert a QFont to a dictionary of font properties. + + Parameters + ---------- + toolkit_font : QFont + The Qt QFont to convert. + + Returns + ------- + properties : dict + Font properties suitable for use in creating a Pyface Font. + """ + family = [] + + if hasattr(toolkit_font, 'families'): + # Qt 5.13 and later + family = list(toolkit_font.families()) + elif toolkit_font.family(): + family.append(toolkit_font.family()) + if toolkit_font.defaultFamily(): + family.append(toolkit_font.defaultFamily()) + family.append(qt_family_to_generic_family[toolkit_font.styleHint()]) + + size = toolkit_font.pointSizeF() + style = qt_style_to_style[toolkit_font.style()] + weight = map_to_nearest(toolkit_font.weight(), qt_weight_to_weight) + stretch = toolkit_font.stretch() + variants = set() + if toolkit_font.capitalization() == QFont.SmallCaps: + variants.add('small-caps') + decorations = set() + if toolkit_font.underline(): + decorations.add('underline') + if toolkit_font.strikeOut(): + decorations.add('strikethrough') + if toolkit_font.overline(): + decorations.add('overline') + + return { + 'family': family, + 'size': size, + 'weight': weight, + 'stretch': stretch, + 'style': style, + 'variants': variants, + 'decorations': decorations, + } + + +def map_to_nearest(target, mapping): + """ Given mapping with keys from 0 and 99, return closest value. + + Parameters + ---------- + target : int + The value to map. + mapping : dict + A dictionary with integer keys ranging from 0 to 99. + + Returns + ------- + value : any + The value corresponding to the nearest key. In the case of a tie, + the first value is returned. + """ + if target in mapping: + return mapping[target] + + distance = 100 + nearest = None + for key in mapping: + if abs(target - key) < distance: + distance = abs(target - key) + nearest = key + return mapping[nearest] diff --git a/pyface/ui/wx/font.py b/pyface/ui/wx/font.py new file mode 100644 index 000000000..296d929dc --- /dev/null +++ b/pyface/ui/wx/font.py @@ -0,0 +1,185 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" +Font conversion utilities + +This module provides facilities for converting between pyface Font objects +and Wx Font objects, trying to keep as much similarity as possible between +them. +""" + +import wx + +from pyface.font import Font + + +# font weight and size features changed in wxPython 4.1/wxWidgets 3.1 +wx_python_4_1 = (wx.VERSION >= (4, 1)) + + +wx_family_to_generic_family = { + wx.FONTFAMILY_DEFAULT: 'default', + wx.FONTFAMILY_DECORATIVE: 'fantasy', + wx.FONTFAMILY_ROMAN: 'serif', + wx.FONTFAMILY_SCRIPT: 'cursive', + wx.FONTFAMILY_SWISS: 'sans-serif', + wx.FONTFAMILY_MODERN: 'monospace', + wx.FONTFAMILY_TELETYPE: 'typewriter', +} +generic_family_to_wx_family = { + 'default': wx.FONTFAMILY_DEFAULT, + 'fantasy': wx.FONTFAMILY_DECORATIVE, + 'decorative': wx.FONTFAMILY_DECORATIVE, + 'serif': wx.FONTFAMILY_ROMAN, + 'roman': wx.FONTFAMILY_ROMAN, + 'cursive': wx.FONTFAMILY_SCRIPT, + 'script': wx.FONTFAMILY_SCRIPT, + 'sans-serif': wx.FONTFAMILY_SWISS, + 'swiss': wx.FONTFAMILY_SWISS, + 'monospace': wx.FONTFAMILY_MODERN, + 'modern': wx.FONTFAMILY_MODERN, + 'typewriter': wx.FONTFAMILY_TELETYPE, + 'teletype': wx.FONTFAMILY_TELETYPE, +} + +if wx_python_4_1: + weight_to_wx_weight = { + 100: wx.FONTWEIGHT_THIN, + 200: wx.FONTWEIGHT_EXTRALIGHT, + 300: wx.FONTWEIGHT_LIGHT, + 400: wx.FONTWEIGHT_NORMAL, + 500: wx.FONTWEIGHT_MEDIUM, + 600: wx.FONTWEIGHT_SEMIBOLD, + 700: wx.FONTWEIGHT_BOLD, + 800: wx.FONTWEIGHT_EXTRABOLD, + 900: wx.FONTWEIGHT_HEAVY, + 1000: wx.FONTWEIGHT_EXTRAHEAVY, + } + wx_weight_to_weight = { + wx.FONTWEIGHT_THIN: 'thin', + wx.FONTWEIGHT_EXTRALIGHT: 'extra-light', + wx.FONTWEIGHT_LIGHT: 'light', + wx.FONTWEIGHT_NORMAL: 'normal', + wx.FONTWEIGHT_MEDIUM: 'medium', + wx.FONTWEIGHT_SEMIBOLD: 'semibold', + wx.FONTWEIGHT_BOLD: 'bold', + wx.FONTWEIGHT_EXTRABOLD: 'extra-bold', + wx.FONTWEIGHT_HEAVY: 'heavy', + wx.FONTWEIGHT_EXTRAHEAVY: 'extra-heavy', + wx.FONTWEIGHT_MAX: 'extra-heavy', + } +else: + weight_to_wx_weight = { + 100: wx.FONTWEIGHT_LIGHT, + 200: wx.FONTWEIGHT_LIGHT, + 300: wx.FONTWEIGHT_LIGHT, + 400: wx.FONTWEIGHT_NORMAL, + 500: wx.FONTWEIGHT_NORMAL, + 600: wx.FONTWEIGHT_BOLD, + 700: wx.FONTWEIGHT_BOLD, + 800: wx.FONTWEIGHT_BOLD, + 900: wx.FONTWEIGHT_MAX, + 1000: wx.FONTWEIGHT_MAX, + } + wx_weight_to_weight = { + wx.FONTWEIGHT_LIGHT: 'light', + wx.FONTWEIGHT_NORMAL: 'normal', + wx.FONTWEIGHT_BOLD: 'bold', + wx.FONTWEIGHT_MAX: 'extra-heavy', + } + +style_to_wx_style = { + 'normal': wx.FONTSTYLE_NORMAL, + 'oblique': wx.FONTSTYLE_SLANT, + 'italic': wx.FONTSTYLE_ITALIC, +} +wx_style_to_style = {value: key for key, value in style_to_wx_style.items()} + + +def font_to_toolkit_font(font): + """ Convert a Pyface font to a wx.font Font. + + Wx fonts have no notion of stretch values or small-caps or overline + variants, so these are ignored when converting. + + Parameters + ---------- + font : pyface.font.Font + The Pyface font to convert. + + Returns + ------- + wx_font : wx.font.Font + The best matching wx font. + """ + size = font.size + for family in font.family: + if family in generic_family_to_wx_family: + default_family = generic_family_to_wx_family[family] + break + else: + default_family = wx.FONTFAMILY_DEFAULT + weight = weight_to_wx_weight[font.weight_] + style = style_to_wx_style[font.style] + underline = ('underline' in font.decorations) + + # get a default font candidate + wx_font = wx.Font(size, default_family, style, weight, underline) + for face in font.family: + # don't try to match generic family + if face in generic_family_to_wx_family: + break + wx_font = wx.Font( + size, default_family, style, weight, underline, face) + # we have a match, so stop + if wx_font.GetFaceName().lower() == face.lower(): + break + + wx_font.SetStrikethrough('strikethrough' in font.decorations) + return wx_font + + +def toolkit_font_to_properties(toolkit_font): + """ Convert a Wx Font to a dictionary of font properties. + + Parameters + ---------- + toolkit_font : wx.font.Font + The Wx font to convert. + + Returns + ------- + properties : dict + Font properties suitable for use in creating a Pyface Font. + """ + family = wx_family_to_generic_family[toolkit_font.GetFamily()] + face = toolkit_font.GetFaceName() + if wx_python_4_1: + size = toolkit_font.GetFractionalPointSize() + else: + size = toolkit_font.GetPointSize() + style = wx_style_to_style[toolkit_font.GetStyle()] + weight = wx_weight_to_weight[toolkit_font.GetWeight()] + decorations = set() + if toolkit_font.GetUnderlined(): + decorations.add('underline') + if toolkit_font.GetStrikethrough(): + decorations.add('strikethrough') + + return { + 'family': [face, family], + 'size': size, + 'weight': weight, + 'stretch': 'normal', + 'style': style, + 'variants': set(), + 'decorations': decorations, + }