From 8a391a4a6335a88e8c8403231d5bd12a72cb577f Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 30 Nov 2021 12:15:54 +0000 Subject: [PATCH 1/6] Initial check-in of font trait and simple parser. --- pyface/font.py | 2 +- pyface/ui/wx/font.py | 3 -- pyface/ui_traits.py | 52 ++++++++++++++++++- pyface/util/font_parser.py | 103 +++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 pyface/util/font_parser.py diff --git a/pyface/font.py b/pyface/font.py index 09499428a..ee94b7c35 100644 --- a/pyface/font.py +++ b/pyface/font.py @@ -368,7 +368,7 @@ def to_toolkit(self): return font_to_toolkit_font(self) def __str__(self): - """ Produce a CSS-style representation of the font. """ + """ Produce a CSS2-style representation of the font. """ terms = [] if self.style != 'normal': terms.append(self.style) diff --git a/pyface/ui/wx/font.py b/pyface/ui/wx/font.py index 296d929dc..82bfd6ff2 100644 --- a/pyface/ui/wx/font.py +++ b/pyface/ui/wx/font.py @@ -18,9 +18,6 @@ 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)) diff --git a/pyface/ui_traits.py b/pyface/ui_traits.py index 2b136f320..a703818a2 100644 --- a/pyface/ui_traits.py +++ b/pyface/ui_traits.py @@ -29,8 +29,10 @@ from traits.trait_base import get_resource_path from pyface.color import Color +from pyface.font import Font from pyface.i_image import IImage from pyface.util.color_parser import ColorParseError +from pyface.util.font_parser import simple_parser, FontParseError logger = logging.getLogger(__name__) @@ -143,7 +145,7 @@ def create_editor(self): class PyfaceColor(TraitType): - """ A Trait which casts strings and tuples to a PyfaceColor value. + """ A Trait which casts strings and tuples to a Pyface Color value. """ #: The default value should be a tuple (factory, args, kwargs) @@ -185,6 +187,54 @@ def info(self): ) +# ------------------------------------------------------------------------------- +# Font +# ------------------------------------------------------------------------------- + + +class PyfaceFont(TraitType): + """ A Trait which casts strings to a Pyface Font value. + """ + + #: The default value should be a tuple (factory, args, kwargs) + default_value_type = DefaultValue.callable_and_args + + #: The parser to use when converting text to keyword args. This should + #: accept a string and return a dictionary of Font class trait values (ie. + #: "family", "size", "weight", etc.). + parser = simple_parser + + def __init__(self, value=None, *, parser=None, **metadata): + if parser is not None: + self.parser = parser + if value is not None: + font = self.validate(value) + default_value = ( + Font, + (), + font.trait_get(transient=lambda x: not x), + ) + else: + default_value = (Font, (), {}) + super().__init__(default_value, **metadata) + + def validate(self, object, name, value): + if isinstance(value, Font): + return value + if isinstance(value, str): + try: + return Font(**self.parser(value)) + except FontParseError: + self.error(object, name, value) + + self.error(object, name, value) + + def info(self): + return ( + "a Pyface Font, or a string describing a Pyface Font" + ) + + # ------------------------------------------------------------------------------- # Borders, Margins and Layout # ------------------------------------------------------------------------------- diff --git a/pyface/util/font_parser.py b/pyface/util/font_parser.py new file mode 100644 index 000000000..885562bbc --- /dev/null +++ b/pyface/util/font_parser.py @@ -0,0 +1,103 @@ +# (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! + + +GENERIC_FAMILIES = { + 'default', 'fantasy', 'decorative', 'serif', 'roman', 'cursive', 'script', + 'sans-serif', 'swiss', 'monospace', 'modern', 'typewriter', 'teletype' +} +WEIGHTS = {'thin', 'extra-light', 'light', 'regular', 'medium', 'demi-bold', + 'bold', 'extra-bold', 'heavy', 'extra-heavy'} +STRETCHES = {'ultra-condensed', 'extra-condensed', 'condensed', + 'semi-condensed', 'semi-expanded', 'expanded', 'extra-expanded', + 'ultra-expanded'} +STYLES = {'italic', 'oblique'} +VARIANTS = {'small-caps'} +DECORATIONS = {'underline', 'strikethrough', 'overline'} +NOISE = {'pt', 'point', 'px', 'family'} + + +class FontParseError(ValueError): + """An exception raised when font parsing fails.""" + pass + + +def simple_parser(description): + """An extremely simple font description parser. + + This is roughly compatible with the various ad-hoc parsers in TraitsUI + and Kiva, allowing for the slight differences between them and adding + support for additional options supported by Pyface fonts, such as stretch + and variants. + + Parameters + ---------- + description : str + The font description to be parsed. + + Returns + ------- + properties : dict + Font properties suitable for use in creating a Pyface Font. + + Notes + ----- + This is not a particularly good parser, as it will fail to properly + parse something like "10 pt times new roman" or "14 pt computer modern" + since they have generic font names as part of the font face name. + """ + face = [] + generic_family = "" + size = None + weight = "normal" + stretch = "normal" + style = "normal" + variants = set() + decorations = set() + for word in description.lower().split(): + if word in NOISE: + continue + elif word in GENERIC_FAMILIES: + generic_family = word + elif word in WEIGHTS: + weight = word + elif word in STRETCHES: + stretch = word + elif word in STYLES: + style = word + elif word in VARIANTS: + variants.add(word) + elif word in DECORATIONS: + decorations.add(word) + else: + if size is not None: + try: + size = int(word) + except ValueError: + pass + face.append(word) + + family = [] + if face: + family.append(" ".join(face)) + if generic_family: + family.append(generic_family) + if not family: + family = ["default"] + + return { + 'family': family, + 'size': size, + 'weight': weight, + 'stretch': stretch, + 'style': style, + 'variants': variants, + 'decorations': decorations, + } From 12bf8e2bb85cc6aefa2a10d151a9e7380145c8ea Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Mon, 20 Dec 2021 16:32:00 +0000 Subject: [PATCH 2/6] Tests for simple parser. --- pyface/util/font_parser.py | 3 +- pyface/util/tests/test_font_parser.py | 199 ++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 pyface/util/tests/test_font_parser.py diff --git a/pyface/util/font_parser.py b/pyface/util/font_parser.py index 885562bbc..261f0b481 100644 --- a/pyface/util/font_parser.py +++ b/pyface/util/font_parser.py @@ -77,9 +77,10 @@ def simple_parser(description): elif word in DECORATIONS: decorations.add(word) else: - if size is not None: + if size is None: try: size = int(word) + continue except ValueError: pass face.append(word) diff --git a/pyface/util/tests/test_font_parser.py b/pyface/util/tests/test_font_parser.py new file mode 100644 index 000000000..38af7d2a3 --- /dev/null +++ b/pyface/util/tests/test_font_parser.py @@ -0,0 +1,199 @@ +# (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! + +from itertools import chain, combinations +from unittest import TestCase + +from ..font_parser import ( + DECORATIONS, + GENERIC_FAMILIES, + NOISE, + STRETCHES, + STYLES, + VARIANTS, + WEIGHTS, + FontParseError, + simple_parser, +) + + +class TestSimpleParser(TestCase): + + def test_empty(self): + properties = simple_parser("") + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': None, + 'weight': "normal", + 'stretch': "normal", + 'style': "normal", + 'variants': set(), + 'decorations': set(), + }, + ) + + def test_typical(self): + properties = simple_parser( + "10 pt bold condensed italic underline Helvetica sans-serif") + self.assertEqual( + properties, + { + 'family': ["helvetica", "sans-serif"], + 'size': 10, + 'weight': "bold", + 'stretch': "condensed", + 'style': "italic", + 'variants': set(), + 'decorations': {"underline"}, + }, + ) + + def test_noise(self): + for noise in NOISE: + with self.subTest(noise=noise): + properties = simple_parser(noise) + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': None, + 'weight': "normal", + 'stretch': "normal", + 'style': "normal", + 'variants': set(), + 'decorations': set(), + }, + ) + + def test_generic_families(self): + for family in GENERIC_FAMILIES: + with self.subTest(family=family): + properties = simple_parser(family) + self.assertEqual( + properties, + { + 'family': [family], + 'size': None, + 'weight': "normal", + 'stretch': "normal", + 'style': "normal", + 'variants': set(), + 'decorations': set(), + }, + ) + + def test_size(self): + for size in [12, 24]: + with self.subTest(size=size): + properties = simple_parser(str(size)) + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': size, + 'weight': "normal", + 'stretch': "normal", + 'style': "normal", + 'variants': set(), + 'decorations': set(), + }, + ) + + def test_weight(self): + for weight in WEIGHTS: + with self.subTest(weight=weight): + properties = simple_parser(weight) + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': None, + 'weight': weight, + 'stretch': "normal", + 'style': "normal", + 'variants': set(), + 'decorations': set(), + }, + ) + + def test_stretch(self): + for stretch in STRETCHES: + with self.subTest(stretch=stretch): + properties = simple_parser(stretch) + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': None, + 'weight': "normal", + 'stretch': stretch, + 'style': "normal", + 'variants': set(), + 'decorations': set(), + }, + ) + + def test_style(self): + for style in STYLES: + with self.subTest(style=style): + properties = simple_parser(style) + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': None, + 'weight': "normal", + 'stretch': "normal", + 'style': style, + 'variants': set(), + 'decorations': set(), + }, + ) + + def test_variant(self): + for variant in VARIANTS: + with self.subTest(variant=variant): + properties = simple_parser(variant) + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': None, + 'weight': "normal", + 'stretch': "normal", + 'style': "normal", + 'variants': {variant}, + 'decorations': set(), + }, + ) + + def test_decorations(self): + # get powerset iterator of DECORATIONS + all_decorations = chain.from_iterable( + combinations(DECORATIONS, n) + for n in range(len(DECORATIONS) + 1) + ) + for decorations in all_decorations: + with self.subTest(decorations=decorations): + properties = simple_parser(" ".join(decorations)) + self.assertEqual( + properties, + { + 'family': ["default"], + 'size': None, + 'weight': "normal", + 'stretch': "normal", + 'style': "normal", + 'variants': set(), + 'decorations': set(decorations), + }, + ) From bd7f3dd94f3f7224ced590d096ac3b75c238e932 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Mon, 20 Dec 2021 17:23:09 +0000 Subject: [PATCH 3/6] Add tests for PyfaceFont trait; fix default size in parser. --- pyface/tests/test_ui_traits.py | 133 ++++++++++++++++++++++++++ pyface/ui_traits.py | 6 +- pyface/util/font_parser.py | 4 +- pyface/util/tests/test_font_parser.py | 20 ++-- 4 files changed, 149 insertions(+), 14 deletions(-) diff --git a/pyface/tests/test_ui_traits.py b/pyface/tests/test_ui_traits.py index 69d10e03c..4a0991ab1 100644 --- a/pyface/tests/test_ui_traits.py +++ b/pyface/tests/test_ui_traits.py @@ -12,6 +12,8 @@ import os import unittest +from pyface.util.font_parser import simple_parser + # importlib.resources is new in Python 3.7, and importlib.resources.files is # new in Python 3.9, so for Python < 3.9 we must rely on the 3rd party # importlib_resources package. @@ -30,6 +32,7 @@ from traits.testing.api import UnittestTools from ..color import Color +from ..font import Font from ..image_resource import ImageResource from ..ui_traits import ( Border, @@ -38,6 +41,7 @@ Image, Margin, PyfaceColor, + PyfaceFont, image_resource_cache, image_bitmap_cache, ) @@ -56,6 +60,11 @@ class ColorClass(HasTraits): color = PyfaceColor() +class FontClass(HasTraits): + + font = PyfaceFont() + + class HasMarginClass(HasTraits): margin = HasMargin @@ -354,6 +363,130 @@ def test_set_structured_dtype(self): self.assertEqual(color_class.color, color) +class TestPyfaceFont(unittest.TestCase): + + def test_init(self): + trait = PyfaceFont() + self.assertEqual(trait.default_value, (Font, (), {})) + self.assertEqual( + trait.default_value_type, + DefaultValue.callable_and_args, + ) + + def test_init_empty_string(self): + trait = PyfaceFont("") + self.assertEqual( + trait.default_value, + ( + Font, + (), + { + 'family': ["default"], + 'size': 12.0, + 'weight': "normal", + 'stretch': 100, + 'style': "normal", + 'variants': set(), + 'decorations': set(), + }, + ) + ) + + def test_init_empty_string(self): + trait = PyfaceFont( + "10 pt bold condensed italic underline Helvetica sans-serif") + self.assertEqual( + trait.default_value, + ( + Font, + (), + { + 'family': ["helvetica", "sans-serif"], + 'size': 10.0, + 'weight': "bold", + 'stretch': 75.0, + 'style': "italic", + 'variants': set(), + 'decorations': {"underline"}, + }, + ) + ) + + def test_init_font(self): + font = Font( + family=["helvetica", "sans-serif"], + size=10.0, + weight="bold", + stretch=75.0, + style="italic", + variants=set(), + decorations={"underline"}, + ) + trait = PyfaceFont(font) + self.assertEqual( + trait.default_value, + ( + Font, + (), + { + 'family': ["helvetica", "sans-serif"], + 'size': 10.0, + 'weight': "bold", + 'stretch': 75.0, + 'style': "italic", + 'variants': set(), + 'decorations': {"underline"}, + }, + ) + ) + + def test_set_empty_string(self): + font_class = FontClass() + font_class.font = "" + self.assertFontEqual(font_class.font, Font()) + + def test_set_typical_string(self): + font_class = FontClass() + font_class.font = "10 pt bold condensed italic underline Helvetica sans-serif" # noqa: E501 + self.assertFontEqual( + font_class.font, + Font( + family=["helvetica", "sans-serif"], + size=10.0, + weight="bold", + stretch=75.0, + style="italic", + variants=set(), + decorations={"underline"}, + ), + ) + + def test_set_font(self): + font_class = FontClass() + font = Font( + family=["helvetica", "sans-serif"], + size=10.0, + weight="bold", + stretch=75.0, + style="italic", + variants=set(), + decorations={"underline"}, + ) + font_class.font = font + self.assertIs(font_class.font, font) + + def test_set_failure(self): + font_class = FontClass() + + with self.assertRaises(TraitError): + font_class.font = None + + def assertFontEqual(self, font1, font2): + state1 = font1.trait_get(transient=lambda x: not x) + state2 = font2.trait_get(transient=lambda x: not x) + self.assertEqual(state1, state2) + + class TestHasMargin(unittest.TestCase, UnittestTools): def test_defaults(self): has_margin = HasMarginClass() diff --git a/pyface/ui_traits.py b/pyface/ui_traits.py index 01e6f534f..63aebd94a 100644 --- a/pyface/ui_traits.py +++ b/pyface/ui_traits.py @@ -203,13 +203,13 @@ class PyfaceFont(TraitType): #: The parser to use when converting text to keyword args. This should #: accept a string and return a dictionary of Font class trait values (ie. #: "family", "size", "weight", etc.). - parser = simple_parser + parser = None - def __init__(self, value=None, *, parser=None, **metadata): + def __init__(self, value=None, *, parser=simple_parser, **metadata): if parser is not None: self.parser = parser if value is not None: - font = self.validate(value) + font = self.validate(None, None, value) default_value = ( Font, (), diff --git a/pyface/util/font_parser.py b/pyface/util/font_parser.py index 261f0b481..c917b8a25 100644 --- a/pyface/util/font_parser.py +++ b/pyface/util/font_parser.py @@ -79,7 +79,7 @@ def simple_parser(description): else: if size is None: try: - size = int(word) + size = float(word) continue except ValueError: pass @@ -92,6 +92,8 @@ def simple_parser(description): family.append(generic_family) if not family: family = ["default"] + if size is None: + size = 12 return { 'family': family, diff --git a/pyface/util/tests/test_font_parser.py b/pyface/util/tests/test_font_parser.py index 38af7d2a3..1d2dd5d3a 100644 --- a/pyface/util/tests/test_font_parser.py +++ b/pyface/util/tests/test_font_parser.py @@ -32,7 +32,7 @@ def test_empty(self): properties, { 'family': ["default"], - 'size': None, + 'size': 12.0, 'weight': "normal", 'stretch': "normal", 'style': "normal", @@ -48,7 +48,7 @@ def test_typical(self): properties, { 'family': ["helvetica", "sans-serif"], - 'size': 10, + 'size': 10.0, 'weight': "bold", 'stretch': "condensed", 'style': "italic", @@ -65,7 +65,7 @@ def test_noise(self): properties, { 'family': ["default"], - 'size': None, + 'size': 12.0, 'weight': "normal", 'stretch': "normal", 'style': "normal", @@ -82,7 +82,7 @@ def test_generic_families(self): properties, { 'family': [family], - 'size': None, + 'size': 12.0, 'weight': "normal", 'stretch': "normal", 'style': "normal", @@ -92,7 +92,7 @@ def test_generic_families(self): ) def test_size(self): - for size in [12, 24]: + for size in [12, 24, 12.5]: with self.subTest(size=size): properties = simple_parser(str(size)) self.assertEqual( @@ -116,7 +116,7 @@ def test_weight(self): properties, { 'family': ["default"], - 'size': None, + 'size': 12.0, 'weight': weight, 'stretch': "normal", 'style': "normal", @@ -133,7 +133,7 @@ def test_stretch(self): properties, { 'family': ["default"], - 'size': None, + 'size': 12.0, 'weight': "normal", 'stretch': stretch, 'style': "normal", @@ -150,7 +150,7 @@ def test_style(self): properties, { 'family': ["default"], - 'size': None, + 'size': 12.0, 'weight': "normal", 'stretch': "normal", 'style': style, @@ -167,7 +167,7 @@ def test_variant(self): properties, { 'family': ["default"], - 'size': None, + 'size': 12.0, 'weight': "normal", 'stretch': "normal", 'style': "normal", @@ -189,7 +189,7 @@ def test_decorations(self): properties, { 'family': ["default"], - 'size': None, + 'size': 12.0, 'weight': "normal", 'stretch': "normal", 'style': "normal", From d1d297738110bde84336cdc1c6b36b66c9f09ceb Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 4 Jan 2022 16:58:54 +0000 Subject: [PATCH 4/6] Added detailed description of allowed terms to parser docstring. --- pyface/util/font_parser.py | 43 ++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/pyface/util/font_parser.py b/pyface/util/font_parser.py index c917b8a25..7a2d12176 100644 --- a/pyface/util/font_parser.py +++ b/pyface/util/font_parser.py @@ -32,10 +32,45 @@ class FontParseError(ValueError): def simple_parser(description): """An extremely simple font description parser. - This is roughly compatible with the various ad-hoc parsers in TraitsUI - and Kiva, allowing for the slight differences between them and adding - support for additional options supported by Pyface fonts, such as stretch - and variants. + The parser is simple, and works by splitting the description on whitespace + and examining each resulting token for understood terms: + + Size + The first numeric term is treated as the font size. + + Weight + The following weight terms are accepted: 'thin', 'extra-light', + 'light', 'regular', 'medium', 'demi-bold', 'bold', 'extra-bold', + 'heavy', 'extra-heavy'. + + Stretch + The following stretch terms are accepted: 'ultra-condensed', + 'extra-condensed', 'condensed', 'semi-condensed', 'semi-expanded', + 'expanded', 'extra-expanded', 'ultra-expanded'. + + Style + The following style terms are accepted: 'italic', 'oblique'. + + Variant + The following variant terms are accepted: 'small-caps'. + + Decorations + The following decoration terms are accepted: 'underline', + 'strikethrough', 'overline'. + + Generic Families + The following generic family terms are accepted: 'default', 'fantasy', + 'decorative', 'serif', 'roman', 'cursive', 'script', 'sans-serif', + 'swiss', 'monospace', 'modern', 'typewriter', 'teletype'. + + In addtion, the parser ignores the terms 'pt', 'point', 'px', and 'family'. + Any remaining terms are combined into the typeface name. There is no + expected order to the terms. + + This parser is roughly compatible with the various ad-hoc parsers in + TraitsUI and Kiva, allowing for the slight differences between them and + adding support for additional options supported by Pyface fonts, such as + stretch and variants. Parameters ---------- From 6b81950704dd9878a586c5b16ddc5e19e35d448b Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 6 Jan 2022 14:32:07 +0000 Subject: [PATCH 5/6] Fix test method name. --- pyface/tests/test_ui_traits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyface/tests/test_ui_traits.py b/pyface/tests/test_ui_traits.py index 4a0991ab1..b44c82f42 100644 --- a/pyface/tests/test_ui_traits.py +++ b/pyface/tests/test_ui_traits.py @@ -392,7 +392,7 @@ def test_init_empty_string(self): ) ) - def test_init_empty_string(self): + def test_init_typical_string(self): trait = PyfaceFont( "10 pt bold condensed italic underline Helvetica sans-serif") self.assertEqual( From 70520f27748f3cadc7175a514cfbb4fe0d7284e9 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 7 Jan 2022 10:56:33 +0000 Subject: [PATCH 6/6] Raise ValueError on bad default value. --- pyface/tests/test_ui_traits.py | 4 ++++ pyface/ui_traits.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pyface/tests/test_ui_traits.py b/pyface/tests/test_ui_traits.py index b44c82f42..278ef6000 100644 --- a/pyface/tests/test_ui_traits.py +++ b/pyface/tests/test_ui_traits.py @@ -440,6 +440,10 @@ def test_init_font(self): ) ) + def test_init_invalid(self): + with self.assertRaises(ValueError): + trait = PyfaceFont(0) + def test_set_empty_string(self): font_class = FontClass() font_class.font = "" diff --git a/pyface/ui_traits.py b/pyface/ui_traits.py index 63aebd94a..ebf10bb51 100644 --- a/pyface/ui_traits.py +++ b/pyface/ui_traits.py @@ -206,10 +206,15 @@ class PyfaceFont(TraitType): parser = None def __init__(self, value=None, *, parser=simple_parser, **metadata): - if parser is not None: - self.parser = parser + self.parser = parser if value is not None: - font = self.validate(None, None, value) + try: + font = self.validate(None, None, value) + except TraitError: + raise ValueError( + "expected " + self.info() + + f", but got {value!r}" + ) default_value = ( Font, (),