Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Toolkit-independent Font class #609

Merged
merged 12 commits into from
Oct 12, 2021
313 changes: 313 additions & 0 deletions pyface/font.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
# (C) Copyright 2005-2020 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.

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". There are several generic font family names that can
be used as fall-backs in case all preferred fonts are unavailable. 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.

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 as 'condensed' and 'expanded' that correspond to those values.
corranwebster marked this conversation as resolved.
Show resolved Hide resolved

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 traits for all of the font properties, plus additional utility
methods that produce modifed versions of the font.

It also has methods that convert the Font class to and from a toolkit Font
class.

"""
import re

from traits.api import (
BaseCFloat, CList, CSet, DefaultValue, Enum, HasStrictTraits, Map, Range,
Str, TraitError, TraitType
)
from traits.trait_type import NoDefaultSpecified

WEIGHTS = {str(i): i for i in range(100, 1001, 100)}

# Note: we don't support 'medium' as an alias for weight 500 because it
# conflicts with the usage of 'medium' as an alias for a 12pt font in the CSS
# specification for font attributes.
WEIGHTS.update({
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a resource which points out how/why this map is defined the way it is?

Copy link
Contributor

Choose a reason for hiding this comment

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

this and other maps which define similar associations

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These come from the CSS spec, Qt and Wx constant names, true/open type font conventions, and in some cases (originally) from "traditional" values from the typesetting world (eg. the font weights date back to the fonts used in phototypesetting systems).

The CSS spec is probably the simplest comprehensive reference.

Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like we have some extra weights here compared to the CSS spec: https://www.w3.org/TR/css-fonts-3/#propdef-font-weight

Copy link
Contributor

Choose a reason for hiding this comment

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

More specifically, these are the values from CSS: normal | bold | bolder | lighter | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
Where do the other values come from?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As mentioned above, this wasn't just from the CSS spec - in particular it should at least include the mappings that Enable has here https://github.com/enthought/enable/blob/db84efa4d031fd641ebe097301a56764634ce21d/kiva/fonttools/_constants.py#L90-L105
as well as the names that Qt uses for weight constants: https://doc.qt.io/qt-5/qfont.html#Weight-enum
and the names wx uses: https://wxpython.org/Phoenix/docs/html/wx.FontWeight.enumeration.html
and the OpenType font weights: https://docs.microsoft.com/en-us/typography/opentype/spec/os2#usweightclass

More info can be found here: https://bigelowandholmes.typepad.com/bigelow-holmes/2015/07/on-font-weight.html

This is all very fuzzy because the names predate computerized fonts and most font families don't supply all weights.

'thin': 100,
'extra-light': 200,
'ultra-light': 200,
'light': 300,
'normal': 400,
'regular': 400,
'book': 400,
'semibold': 600,
'demibold': 600,
'demi': 600,
'bold': 700,
'extra-bold': 800,
'ultra-bold': 800,
'black': 900,
'heavy': 900,
'extra-heavy': 1000,
})

STRETCHES = {
'ultra-condensed': 50,
'ultracondensed': 62.5,
'extra-condensed': 62.5,
'extracondensed': 62.5,
'condensed': 75,
'semi-condensed': 87.5,
'semicondensed': 87.5,
'normal': 100,
'semi-expanded': 112.5,
'semiexpanded': 112.5,
'expanded': 125,
'extra-expanded': 150,
'extraexpanded': 150,
'ultra-expanded': 200,
'ultraexpanded': 200,
}
Copy link
Contributor

@kitchoi kitchoi Jul 21, 2020

Choose a reason for hiding this comment

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

Same here, CSS has these values: normal | ultra-condensed | extra-condensed | condensed | semi-condensed | semi-expanded | expanded | extra-expanded | ultra-expanded

I don't think we need the extra ones here given we document the fact that these values follow CSS convention.


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')

VARIANTS = ['small-caps', '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))


class FontStretch(BaseCFloat):
""" Trait type for font stretches.

The is a CFloat trait which also allows values which are keys of the
stretch dictionary Values must be floats between 50 and 200, inclusive.
rahulporuri marked this conversation as resolved.
Show resolved Hide resolved
"""

#: 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]
corranwebster marked this conversation as resolved.
Show resolved Hide resolved
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. """

#: 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()

@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):
rahulporuri marked this conversation as resolved.
Show resolved Hide resolved
terms = []
if self.style != 'normal':
terms.append(self.style)
terms.extend(
variant for variant in VARIANTS
if variant in self.variants
)
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
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)

def __eq__(self, other):
if isinstance(other, Font):
return (
self.family == other.family
and self.weight == other.weight
and self.stretch == other.stretch
and self.style == other.style
and self.size == other.size
and self.variants == other.variants
)
else:
return NotImplemented
10 changes: 10 additions & 0 deletions pyface/qt/QtGui.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
from PyQt4.Qt import QKeySequence, QTextCursor
from PyQt4.QtGui import *

# forward-compatible font weights
QFont.ExtraLight = 12
QFont.Medium = 57
QFont.ExtraBold = 81
rahulporuri marked this conversation as resolved.
Show resolved Hide resolved

elif qt_api == "pyqt5":
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
Expand Down Expand Up @@ -48,3 +53,8 @@

else:
from PySide.QtGui import *

# forward-compatible font weights
QFont.ExtraLight = 12
QFont.Medium = 57
QFont.ExtraBold = 81
Loading