diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd6ef88bf..16d790d02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: task: ['pycodestyle'] steps: - uses: actions/checkout@v1 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v1 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: | pip install --upgrade pip setuptools beefore @@ -25,31 +25,35 @@ jobs: beefore --username github-actions --repository ${{ github.repository }} --pull-request ${{ github.event.number }} --commit ${{ github.event.pull_request.head.sha }} ${{ matrix.task }} . smoke: - name: Smoke test + name: Smoke test (Linux) (3.5) needs: beefore runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Set up Python 3.7 + - name: Set up Python 3.5 uses: actions/setup-python@v1 with: - python-version: 3.7 + python-version: 3.5 - name: Install dependencies run: | - pip install --upgrade pip setuptools pytest-tldr + sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr pip install -e . + - name: Install fonts + run: | + xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py - name: Test run: | - python setup.py test + xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ -n auto python-versions: - name: Python compatibility test + name: Python compatibility test (Linux) needs: smoke runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: - python-version: [3.5, 3.6] + python-version: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} @@ -58,8 +62,54 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - pip install --upgrade pip setuptools + sudo apt-get install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 python3-dev libgirepository1.0-dev libcairo2-dev pkg-config + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr + pip install -e . + - name: Install fonts + run: | + xvfb-run -a -s '-screen 0 2048x1536x24' python tests/utils.py + - name: Test + run: | + xvfb-run -a -s '-screen 0 2048x1536x24' pytest tests/ -n auto + + windows: + name: Windows tests + needs: python-versions + runs-on: windows-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.5 + uses: actions/setup-python@v1 + with: + python-version: 3.5 + - name: Install dependencies + run: | + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr pip install -e . + - name: Install fonts + run: | + python tests/utils.py + - name: Test + run: | + pytest tests/ -n auto + + macOS: + name: macOS tests + needs: python-versions + runs-on: macos-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.5 + uses: actions/setup-python@v1 + with: + python-version: 3.5 + - name: Install dependencies + run: | + pip install --upgrade pip setuptools pytest pytest-xdist pytest-tldr + pip install -e . + - name: Install fonts + run: | + python tests/utils.py - name: Test run: | - python setup.py test + pytest tests/ -n auto diff --git a/colosseum/constants.py b/colosseum/constants.py index 431c9b6ae..7b3cfc799 100644 --- a/colosseum/constants.py +++ b/colosseum/constants.py @@ -1,6 +1,8 @@ -from .validators import (ValidationError, is_border_spacing, is_color, +from .exceptions import ValidationError +from .validators import (is_border_spacing, is_color, is_font_family, is_integer, is_length, is_number, is_percentage, is_quote, is_rect) +from .wrappers import FontFamily class Choices: @@ -36,13 +38,13 @@ def validate(self, value): def __str__(self): choices = set([str(c).lower().replace('_', '-') for c in self.constants]) for validator in self.validators: - choices.add(validator.description) + choices.update(validator.description.split(', ')) if self.explicit_defaulting_constants: for item in self.explicit_defaulting_constants: choices.add(item) - return ", ".join(sorted(choices)) + return ", ".join(sorted(set(choices))) class OtherProperty: @@ -70,6 +72,11 @@ def value(self, context): HTML4 = 'html4' HTML5 = 'html5' +###################################################################### +# Other constants +###################################################################### +EMPTY = '' + ###################################################################### # Common constants ###################################################################### @@ -254,6 +261,8 @@ def value(self, context): # 10.8 Leading and half-leading ###################################################################### # line_height +LINE_HEIGHT_CHOICES = Choices(NORMAL, validators=[is_number, is_length, is_percentage], + explicit_defaulting_constants=[INHERIT]) # vertical_align ###################################################################### @@ -361,26 +370,133 @@ def value(self, context): ###################################################################### # font_family +SERIF = 'serif' +SANS_SERIF = 'sans-serif' +CURSIVE = 'cursive' +FANTASY = 'fantasy' +MONOSPACE = 'monospace' + +GENERIC_FAMILY_FONTS = [SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE] + +FONT_FAMILY_CHOICES = Choices( + validators=[is_font_family], + explicit_defaulting_constants=[INHERIT, INITIAL], +) + ###################################################################### # 15.4 Font Styling ###################################################################### # font_style +NORMAL = 'normal' +ITALIC = 'italic' +OBLIQUE = 'oblique' + +FONT_STYLE_CHOICES = Choices( + NORMAL, + ITALIC, + OBLIQUE, + explicit_defaulting_constants=[INHERIT], +) ###################################################################### # 15.5 Small-caps ###################################################################### # font_variant +SMALL_CAPS = 'small-caps' +FONT_VARIANT_CHOICES = Choices( + NORMAL, + SMALL_CAPS, + explicit_defaulting_constants=[INHERIT], +) ###################################################################### # 15.6 Font boldness ###################################################################### # font_weight +BOLD = 'bold' +BOLDER = 'bolder' +LIGHTER = 'lighter' +WEIGHT_100 = '100' +WEIGHT_200 = '200' +WEIGHT_300 = '300' +WEIGHT_400 = '400' +WEIGHT_500 = '500' +WEIGHT_600 = '600' +WEIGHT_700 = '700' +WEIGHT_800 = '800' +WEIGHT_900 = '900' + +FONT_WEIGHT_CHOICES = Choices( + NORMAL, + BOLD, + BOLDER, + LIGHTER, + WEIGHT_100, + WEIGHT_200, + WEIGHT_300, + WEIGHT_400, + WEIGHT_500, + WEIGHT_600, + WEIGHT_700, + WEIGHT_800, + WEIGHT_900, + explicit_defaulting_constants=[INHERIT], +) ###################################################################### # 15.7 Font size ###################################################################### # font_size +# +XX_SMALL = 'xx-small' +X_SMALL = 'x-small' +SMALL = 'small' +MEDIUM = 'medium' +LARGE = 'large' +X_LARGE = 'x-large' +XX_LARGE = 'xx-large' + +# +LARGER = 'larger' +SMALLER = 'smaller' + +FONT_SIZE_CHOICES = Choices( + XX_SMALL, + X_SMALL, + SMALL, + MEDIUM, + LARGE, + X_LARGE, + XX_LARGE, + LARGER, + SMALLER, + validators=[is_length, is_percentage], + explicit_defaulting_constants=[INHERIT], +) + +###################################################################### +# 15.8 Font shorthand +###################################################################### + +ICON = 'icon' +CAPTION = 'caption' +MENU = 'menu' +MESSAGE_BOX = 'message-box' +SMALL_CAPTION = 'small-caption' +STATUS_BAR = 'status-bar' + +SYSTEM_FONT_KEYWORDS = [ICON, CAPTION, MENU, MESSAGE_BOX, SMALL_CAPTION, STATUS_BAR] + +INITIAL_FONT_VALUES = { + 'font_style': NORMAL, + 'font_variant': NORMAL, + 'font_weight': NORMAL, + 'font_size': MEDIUM, + 'line_height': NORMAL, + 'font_family': FontFamily([INITIAL]), # TODO: Depends on user agent. What to use? +} + ###################################################################### # 16. Text ########################################################### ###################################################################### diff --git a/colosseum/declaration.py b/colosseum/declaration.py index 4b2515373..41fa48b8f 100644 --- a/colosseum/declaration.py +++ b/colosseum/declaration.py @@ -1,30 +1,44 @@ +import collections + from . import engine as css_engine from . import parser -from .constants import ( # noqa - ALIGN_CONTENT_CHOICES, ALIGN_ITEMS_CHOICES, ALIGN_SELF_CHOICES, AUTO, - BACKGROUND_COLOR_CHOICES, BORDER_COLLAPSE_CHOICES, BORDER_COLOR_CHOICES, - BORDER_SPACING_CHOICES, BORDER_STYLE_CHOICES, BORDER_WIDTH_CHOICES, - BOX_OFFSET_CHOICES, CAPTION_SIDE_CHOICES, CLEAR_CHOICES, CLIP_CHOICES, - COLOR_CHOICES, DIRECTION_CHOICES, DISPLAY_CHOICES, EMPTY_CELLS_CHOICES, - FLEX_BASIS_CHOICES, FLEX_DIRECTION_CHOICES, FLEX_GROW_CHOICES, - FLEX_SHRINK_CHOICES, FLEX_START, FLEX_WRAP_CHOICES, FLOAT_CHOICES, - GRID_AUTO_CHOICES, GRID_AUTO_FLOW_CHOICES, GRID_GAP_CHOICES, - GRID_PLACEMENT_CHOICES, GRID_TEMPLATE_AREA_CHOICES, GRID_TEMPLATE_CHOICES, - INITIAL, INLINE, INVERT, JUSTIFY_CONTENT_CHOICES, LETTER_SPACING_CHOICES, - LTR, MARGIN_CHOICES, MAX_SIZE_CHOICES, MEDIUM, MIN_SIZE_CHOICES, NORMAL, - NOWRAP, ORDER_CHOICES, ORPHANS_CHOICES, OUTLINE_COLOR_CHOICES, - OUTLINE_STYLE_CHOICES, OUTLINE_WIDTH_CHOICES, OVERFLOW_CHOICES, - PADDING_CHOICES, PAGE_BREAK_AFTER_CHOICES, PAGE_BREAK_BEFORE_CHOICES, - PAGE_BREAK_INSIDE_CHOICES, POSITION_CHOICES, QUOTES_CHOICES, ROW, - SEPARATE, SHOW, SIZE_CHOICES, STATIC, STRETCH, TABLE_LAYOUT_CHOICES, - TEXT_ALIGN_CHOICES, TEXT_DECORATION_CHOICES, TEXT_INDENT_CHOICES, - TEXT_TRANSFORM_CHOICES, TOP, TRANSPARENT, UNICODE_BIDI_CHOICES, - VISIBILITY_CHOICES, VISIBLE, WHITE_SPACE_CHOICES, WIDOWS_CHOICES, - WORD_SPACING_CHOICES, Z_INDEX_CHOICES, OtherProperty, - TextAlignInitialValue, default, -) +from .constants import (ALIGN_CONTENT_CHOICES, ALIGN_ITEMS_CHOICES, # noqa + ALIGN_SELF_CHOICES, AUTO, BACKGROUND_COLOR_CHOICES, + BORDER_COLLAPSE_CHOICES, BORDER_COLOR_CHOICES, + BORDER_SPACING_CHOICES, BORDER_STYLE_CHOICES, + BORDER_WIDTH_CHOICES, BOX_OFFSET_CHOICES, + CAPTION_SIDE_CHOICES, CLEAR_CHOICES, CLIP_CHOICES, + COLOR_CHOICES, DIRECTION_CHOICES, DISPLAY_CHOICES, + EMPTY, EMPTY_CELLS_CHOICES, FLEX_BASIS_CHOICES, + FLEX_DIRECTION_CHOICES, FLEX_GROW_CHOICES, + FLEX_SHRINK_CHOICES, FLEX_START, FLEX_WRAP_CHOICES, + FLOAT_CHOICES, FONT_FAMILY_CHOICES, FONT_SIZE_CHOICES, + FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, + FONT_WEIGHT_CHOICES, GRID_AUTO_CHOICES, + GRID_AUTO_FLOW_CHOICES, GRID_GAP_CHOICES, + GRID_PLACEMENT_CHOICES, GRID_TEMPLATE_AREA_CHOICES, + GRID_TEMPLATE_CHOICES, INITIAL, INITIAL_FONT_VALUES, + INLINE, INVERT, JUSTIFY_CONTENT_CHOICES, + LETTER_SPACING_CHOICES, LINE_HEIGHT_CHOICES, LTR, + MARGIN_CHOICES, MAX_SIZE_CHOICES, MEDIUM, + MIN_SIZE_CHOICES, NORMAL, NOWRAP, ORDER_CHOICES, + ORPHANS_CHOICES, OUTLINE_COLOR_CHOICES, + OUTLINE_STYLE_CHOICES, OUTLINE_WIDTH_CHOICES, + OVERFLOW_CHOICES, PADDING_CHOICES, + PAGE_BREAK_AFTER_CHOICES, PAGE_BREAK_BEFORE_CHOICES, + PAGE_BREAK_INSIDE_CHOICES, POSITION_CHOICES, + QUOTES_CHOICES, ROW, SEPARATE, SHOW, SIZE_CHOICES, + STATIC, STRETCH, TABLE_LAYOUT_CHOICES, + TEXT_ALIGN_CHOICES, TEXT_DECORATION_CHOICES, + TEXT_INDENT_CHOICES, TEXT_TRANSFORM_CHOICES, TOP, + TRANSPARENT, UNICODE_BIDI_CHOICES, VISIBILITY_CHOICES, + VISIBLE, WHITE_SPACE_CHOICES, WIDOWS_CHOICES, + WORD_SPACING_CHOICES, Z_INDEX_CHOICES, OtherProperty, + TextAlignInitialValue, default) from .exceptions import ValidationError -from .wrappers import Border, BorderBottom, BorderLeft, BorderRight, BorderTop, Outline +from .parser import parse_font +from .wrappers import (Border, BorderBottom, BorderLeft, BorderRight, + BorderTop, Outline) _CSS_PROPERTIES = set() @@ -75,30 +89,6 @@ def deleter(self): return property(getter, setter, deleter) -def unvalidated_property(name, choices, initial): - "Define a simple CSS property attribute." - initial = choices.validate(initial) - - def getter(self): - return getattr(self, '_%s' % name, initial) - - def setter(self, value): - if value != getattr(self, '_%s' % name, initial): - setattr(self, '_%s' % name, value) - self.dirty = True - - def deleter(self): - try: - delattr(self, '_%s' % name) - self.dirty = True - except AttributeError: - # Attribute doesn't exist - pass - - _CSS_PROPERTIES.add(name) - return property(getter, setter, deleter) - - def validated_property(name, choices, initial): "Define a simple CSS property attribute." try: @@ -203,6 +193,30 @@ def deleter(self): return property(getter, setter, deleter) +def unvalidated_property(name, choices, initial): + "Define a simple CSS property attribute." + initial = choices.validate(initial) + + def getter(self): + return getattr(self, '_%s' % name, initial) + + def setter(self, value): + if value != getattr(self, '_%s' % name, initial): + setattr(self, '_%s' % name, value) + self.dirty = True + + def deleter(self): + try: + delattr(self, '_%s' % name) + self.dirty = True + except AttributeError: + # Attribute doesn't exist + pass + + _CSS_PROPERTIES.add(name) + return property(getter, setter, deleter) + + class CSS: def __init__(self, **style): self._node = None @@ -304,7 +318,7 @@ def __init__(self, **style): max_height = validated_property('max_height', choices=MAX_SIZE_CHOICES, initial=None) # 10.8 Leading and half-leading - # line_height + line_height = validated_property('line_height', choices=LINE_HEIGHT_CHOICES, initial=NORMAL) # vertical_align # 11. Visual effects ################################################# @@ -358,22 +372,24 @@ def __init__(self, **style): # 15. Fonts ########################################################## # 15.3 Font family - # font_family + font_family = validated_list_property('font_family', choices=FONT_FAMILY_CHOICES, storage_class=FontFamily, + initial=[INITIAL], add_quotes=True) # 15.4 Font Styling - # font_style + font_style = validated_property('font_style', choices=FONT_STYLE_CHOICES, initial=NORMAL) # 15.5 Small-caps - # font_variant + font_variant = validated_property('font_variant', choices=FONT_VARIANT_CHOICES, initial=NORMAL) # 15.6 Font boldness - # font_weight + font_weight = validated_property('font_weight', choices=FONT_WEIGHT_CHOICES, initial=NORMAL) # 15.7 Font size - # font_size + font_size = validated_property('font_size', choices=FONT_SIZE_CHOICES, initial=MEDIUM) # 15.8 Shorthand font property - # font + font = validated_shorthand_property('font', initial=INITIAL_FONT_VALUES, parser=parse_font, + storage_class=FontShorthand) # 16. Text ########################################################### # 16.1 Indentation @@ -586,14 +602,13 @@ def keys(self): ###################################################################### def __str__(self): non_default = [] + for name in _CSS_PROPERTIES: - try: + if getattr(self, '_%s' % name, EMPTY) != EMPTY: non_default.append(( name.replace('_', '-'), - getattr(self, '_%s' % name) + getattr(self, name) )) - except AttributeError: - pass return "; ".join( "%s: %s" % (name, value) diff --git a/colosseum/fonts.py b/colosseum/fonts.py new file mode 100644 index 000000000..9abd6df5b --- /dev/null +++ b/colosseum/fonts.py @@ -0,0 +1,153 @@ +"""Font utilities.""" + +import os +import sys + +from .exceptions import ValidationError + + +class _FontDatabaseBase: + """ + Provide information about the fonts available in the underlying system. + """ + _FONTS_CACHE = {} + + @classmethod + def clear_cache(cls): + cls._FONTS_CACHE = {} + + @classmethod + def validate_font_family(cls, value): + """Validate a font family with the system found fonts.""" + if value in cls._FONTS_CACHE: + return value + else: + font_exists = cls.check_font_family(value) + if font_exists: + # TODO: to be filled with a cross-platform font properties instance + cls._FONTS_CACHE[value] = None + return value + + raise ValidationError('Font family "{value}" not found on system!'.format(value=value)) + + @staticmethod + def check_font_family(value): + raise NotImplementedError() + + @staticmethod + def fonts_path(system=False): + """Return the path for cross platform user fonts.""" + raise NotImplementedError('System not supported!') + + +class _FontDatabaseMac(_FontDatabaseBase): + + @staticmethod + def check_font_family(value): + from ctypes import cdll, util + from rubicon.objc import ObjCClass + appkit = cdll.LoadLibrary(util.find_library('AppKit')) # noqa + NSFont = ObjCClass('NSFont') + return bool(NSFont.fontWithName(value, size=0)) # size=0 returns defautl size + + @staticmethod + def fonts_path(system=False): + if system: + fonts_dir = os.path.expanduser('/Library/Fonts') + else: + fonts_dir = os.path.expanduser('~/Library/Fonts') + + return fonts_dir + + +class _FontDatabaseWin(_FontDatabaseBase): + + @staticmethod + def check_font_family(value): + import winreg # noqa + + font_name = value + ' (TrueType)' # TODO: check other options + font = False + key_path = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" + for base in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: + with winreg.OpenKey(base, key_path, 0, winreg.KEY_READ) as reg_key: + try: + # Query if it exists + font = winreg.QueryValueEx(reg_key, font_name) + return True + except FileNotFoundError: + pass + + return font + + @staticmethod + def fonts_path(system=False): + import winreg + if system: + fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%windir%'), 'Fonts') + else: + fonts_dir = os.path.join(winreg.ExpandEnvironmentStrings(r'%LocalAppData%'), + 'Microsoft', 'Windows', 'Fonts') + return fonts_dir + + +class _FontDatabaseLinux(_FontDatabaseBase): + _GTK_WINDOW = None + + @classmethod + def check_font_family(cls, value): + import gi # noqa + gi.require_version("Gtk", "3.0") + gi.require_version("Pango", "1.0") + from gi.repository import Gtk # noqa + from gi.repository import Pango # noqa + + class Window(Gtk.Window): + """Use Pango to get system fonts names.""" + + def get_font(self, value): + """Get font from the system.""" + context = self.create_pango_context() + font = context.load_font(Pango.FontDescription(value)) + + # Pango always loads something close to the requested so we need to check + # the actual loaded font is the requested one. + if font.describe().to_string().startswith(value): + return True # TODO: Wrap on a font cross platform wrapper + + return False + + if cls._GTK_WINDOW is None: + cls._GTK_WINDOW = Window() + + return cls._GTK_WINDOW.get_font(value) + + @staticmethod + def fonts_path(system=False): + if system: + fonts_dir = os.path.expanduser('/usr/local/share/fonts') + else: + fonts_dir = os.path.expanduser('~/.local/share/fonts/') + + return fonts_dir + + +def get_system_font(keyword): + """Return a font object from given system font keyword.""" + from .constants import INITIAL_FONT_VALUES, SYSTEM_FONT_KEYWORDS # noqa + + if keyword in SYSTEM_FONT_KEYWORDS: + # TODO: Get the system font that corresponds + return INITIAL_FONT_VALUES.copy() + + return None + + +if sys.platform == 'win32': + FontDatabase = _FontDatabaseWin +elif sys.platform == 'darwin': + FontDatabase = _FontDatabaseMac +elif sys.platform.startswith('linux'): + FontDatabase = _FontDatabaseLinux +else: + raise ImportError('System not supported!') diff --git a/colosseum/parser.py b/colosseum/parser.py index a2b734a6c..0834d2343 100644 --- a/colosseum/parser.py +++ b/colosseum/parser.py @@ -3,9 +3,10 @@ from .colors import NAMED_COLOR, hsl, rgb from .exceptions import ValidationError +from .fonts import get_system_font from .shapes import Rect from .units import Unit, px -from .wrappers import BorderSpacing, Quotes +from .wrappers import BorderSpacing, FontFamily, Quotes def units(value): @@ -183,6 +184,141 @@ def rect(value): raise ValueError('Unknown shape %s' % value) +############################################################################## +# Font handling +############################################################################## +def _parse_font_property_part(value, font_dict): + """ + Parse font shorthand property part for known properties. + + `value` corresponds to a piece (or part) of a font shorthand property that can + look like: + - ' / ... + - '/ ...' + - ' / ...' + - ... + + Each part can then correspond to one of these values: + , , , / + + The `font_dict` keeps track fo parts that have been already parse so that we + can check that a part is duplicated like: + - ' /' + """ + from .constants import (FONT_SIZE_CHOICES, FONT_STYLE_CHOICES, FONT_VARIANT_CHOICES, + FONT_WEIGHT_CHOICES, LINE_HEIGHT_CHOICES, NORMAL) + if value != NORMAL: + for property_name, choices in {'font_variant': FONT_VARIANT_CHOICES, + 'font_weight': FONT_WEIGHT_CHOICES, + 'font_style': FONT_STYLE_CHOICES}.items(): + try: + value = choices.validate(value) + except (ValidationError, ValueError): + continue + + # If a property has been already parsed, finding the same property is an error + if property_name in font_dict: + raise ValueError('Font value "{value}" includes several "{property_name}" values!' + ''.format(value=value, property_name=property_name)) + + font_dict[property_name] = value + + return font_dict, False + + if '/' in value: + # Maybe it is a font size with line height + font_dict['font_size'], font_dict['line_height'] = value.split('/') + FONT_SIZE_CHOICES.validate(font_dict['font_size']) + LINE_HEIGHT_CHOICES.validate(font_dict['line_height']) + return font_dict, True + else: + # Or just a font size + try: + FONT_SIZE_CHOICES.validate(value) + font_dict['font_size'] = value + return font_dict, True + except ValueError: + pass + + raise ValidationError('Font value "{value}" not valid!'.format(value=value)) + + return font_dict, False + + +def parse_font(string): + """ + Parse font string into a dictionary of font properties. + + The font CSS property is a shorthand for font-style, font-variant, font-weight, + font-size, line-height, and font-family. + + Alternatively, it sets an element's font to a system font. + + Reference: + - https://www.w3.org/TR/CSS21/fonts.html#font-shorthand + - https://developer.mozilla.org/en-US/docs/Web/CSS/font + """ + from .constants import INHERIT, INITIAL_FONT_VALUES, SYSTEM_FONT_KEYWORDS, FONT_FAMILY_CHOICES # noqa + + # Remove extra spaces + string = ' '.join(str(string).strip().split()) + parts = string.split(' ', 1) + if len(parts) == 1: + # If font is specified as a system keyword, it must be one of: + # caption, icon, menu, message-box, small-caption, status-bar + value = parts[0] + if value == INHERIT: + # TODO: To be completed by future work + pass + else: + if value not in SYSTEM_FONT_KEYWORDS: + error_msg = ('Font property value "{value}" ' + 'not a system font keyword!'.format(value=value)) + raise ValueError(error_msg) + font_dict = get_system_font(value) + else: + # If font is specified as a shorthand for several font-related properties, then: + # - It must include values for: + # and + # - It may optionally include values for: + # and + # - font-style, font-variant and font-weight must precede font-size + # - font-variant may only specify the values defined in CSS 2.1 + # - line-height must immediately follow font-size, preceded by "/", like this: "16px/3" + # - font-family must be the last value specified. + + # Need to check that some properties come after font-size + old_is_font_size = False + font_dict = {} + + # We iteratively split by the first left hand space found and try to validate if that part + # is a valid or or (which can come in any order) + # or / (which has to come after all the other properties) + for _ in range(5): + value = parts[0] + try: + font_dict, is_font_size = _parse_font_property_part(value, font_dict) + if is_font_size is False and old_is_font_size: + raise ValueError('Font property shorthand does not follow the correct order!' + ', and must come before ') + old_is_font_size = is_font_size + parts = parts[-1].split(' ', 1) + except ValidationError: + break + else: + # Font family can have a maximum of 4 parts before the font_family part. + # / + raise ValueError('Font property shorthand contains too many parts!') + + values = ' '.join(parts).split(',') + font_dict['font_family'] = FontFamily(FONT_FAMILY_CHOICES.validate(values)) + + full_font_dict = INITIAL_FONT_VALUES.copy() + full_font_dict.update(font_dict) + + return full_font_dict + + def quotes(value): """Parse content quotes. diff --git a/colosseum/validators.py b/colosseum/validators.py index 4ed0aebc2..744f0e45e 100644 --- a/colosseum/validators.py +++ b/colosseum/validators.py @@ -1,9 +1,14 @@ """ Validate values of different css properties. """ +from collections import Sequence +import ast +import re +from . import exceptions from . import parser from . import units +from . import fonts from .exceptions import ValidationError @@ -41,6 +46,7 @@ def validator(num_value): if min_value is None and max_value is None: return validator(value) else: + validator.description = '' return validator @@ -60,6 +66,7 @@ def validator(num_value): if min_value is None and max_value is None: return validator(value) else: + validator.description = '' return validator @@ -136,6 +143,62 @@ def is_rect(value): is_rect.description = '' +# https://www.w3.org/TR/2011/REC-CSS2-20110607/syndata.html#value-def-identifier +_CSS_IDENTIFIER_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9\-\_]+$') + + +def is_font_family(values): + """Validate that values are a valid list of font families.""" + from .constants import GENERIC_FAMILY_FONTS, INITIAL + FontDatabase = fonts.FontDatabase + + assert isinstance(values, Sequence) and not isinstance(values, str) + + # Remove extra outer spaces + values = [value.strip() for value in values] + + checked_values = [] + for value in values: + # Remove extra inner spaces + value = value.replace('" ', '"') + value = value.replace(' "', '"') + value = value.replace("' ", "'") + value = value.replace(" '", "'") + if (value.startswith('"') and value.endswith('"') + or value.startswith("'") and value.endswith("'")): + try: + no_quotes_val = ast.literal_eval(value) + except ValueError: + raise exceptions.ValidationError + + if not FontDatabase.validate_font_family(no_quotes_val): + raise exceptions.ValidationError('Font family "{font_value}"' + ' not found on system!'.format(font_value=no_quotes_val)) + checked_values.append(no_quotes_val) + elif value in GENERIC_FAMILY_FONTS: + checked_values.append(value) + elif value in INITIAL: + checked_values.append(value) + else: + error_msg = 'Font family "{font_value}" not found on system!'.format(font_value=value) + if _CSS_IDENTIFIER_RE.match(value): + if not FontDatabase.validate_font_family(value): + raise exceptions.ValidationError(error_msg) + checked_values.append(value) + else: + raise exceptions.ValidationError(error_msg) + + if len(checked_values) != len(values): + invalid = set(values) - set(checked_values) + error_msg = 'Invalid font string "{invalid}"'.format(invalid=invalid) + raise exceptions.ValidationError(error_msg) + + return checked_values + + +is_font_family.description = ', ' + + def is_quote(value): """Check if given value is of content quotes and return it.""" try: diff --git a/colosseum/wrappers.py b/colosseum/wrappers.py index c1688cb09..090294068 100644 --- a/colosseum/wrappers.py +++ b/colosseum/wrappers.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from collections.abc import Sequence class BorderSpacing: @@ -42,6 +43,158 @@ def vertical(self): return self._horizontal if self._vertical is None else self._vertical +class ImmutableList(Sequence): + """ + Immutable list to store list properties like outline and font family. + """ + + def __init__(self, iterable=()): + self._data = tuple(iterable) + + def _get_error_message(self, err): + return str(err).replace('tuple', self.__class__.__name__, 1) + + def __eq__(self, other): + return other.__class__ == self.__class__ and self._data == other._data + + def __getitem__(self, index): + try: + return self._data[index] + except Exception as err: + error_msg = self._get_error_message(err) + raise err.__class__(error_msg) + + def __len__(self): + return len(self._data) + + def __hash__(self): + return hash((self.__class__.__name__, self._data)) + + def __repr__(self): + class_name = self.__class__.__name__ + if len(self._data) > 1: + text = '{class_name}([{data}])'.format(data=str(self._data)[1:-1], class_name=class_name) + elif len(self._data) == 1: + text = '{class_name}([{data}])'.format(data=str(self._data)[1:-2], class_name=class_name) + else: + text = '{class_name}()'.format(class_name=class_name) + return text + + def __str__(self): + return repr(self) + + def copy(self): + return self.__class__(self._data) + + +class FontFamily(ImmutableList): + """Immutable list like wrapper to store font families.""" + + def __init__(self, iterable=()): + try: + super().__init__(iterable) + except Exception as err: + error_msg = self._get_error_message(err) + raise err.__class__(error_msg) + self._check_values(list(iterable)) + + def _check_values(self, values): + if not isinstance(values, list): + values = [values] + + for value in values: + if not isinstance(value, str): + raise TypeError('Invalid argument for font family') + + def __str__(self): + items = [] + for item in self._data: + if ' ' in item: + item = '"{item}"'.format(item=item) + items.append(item) + return ', '.join(items) + + +class Shorthand: + """ + Dictionary-like wrapper to hold shorthand data. + + This class is not iterable and should be subclassed + """ + VALID_KEYS = [] + + def __init__(self, **kwargs): + self._map = kwargs + + def __eq__(self, other): + return other.__class__ == self.__class__ and self._map == other._map + + def __setitem__(self, key, value): + if self.VALID_KEYS: + if key in self.VALID_KEYS: + self._map[key] = value + else: + raise KeyError('Valid keys are: {keys}'.format(keys=self.VALID_KEYS)) + else: + self._map[key] = value + + def __getitem__(self, key): + if self.VALID_KEYS: + if key in self.VALID_KEYS: + return self._map[key] + else: + return self._map[key] + + raise KeyError('Valid keys are: {keys}'.format(keys=self.VALID_KEYS)) + + def __repr__(self): + map_copy = self._map.copy() + items = [] + for key in self.VALID_KEYS: + items.append("{key}={value}".format(key=key, value=repr(map_copy[key]))) + + class_name = self.__class__.__name__ + string = "{class_name}({items})".format(class_name=class_name, items=', '.join(items)) + return string.format(**map_copy) + + def __len__(self): + return len(self._map) + + def __str__(self): + return repr(self) + + def __iter__(self): + return iter(self.VALID_KEYS) if self.VALID_KEYS else iter(self._map) + + def keys(self): + return self._map.keys() + + def items(self): + return self._map.items() + + def copy(self): + return self.__class__(**self._map) + + def to_dict(self): + return self._map.copy() + + +class FontShorthand(Shorthand): + """Dictionary-like wrapper to hold font shorthand property.""" + VALID_KEYS = ['font_style', 'font_variant', 'font_weight', 'font_size', 'line_height', 'font_family'] + + def __init__(self, font_style='normal', font_variant='normal', font_weight='normal', + font_size='medium', line_height='normal', font_family=FontFamily(['initial'])): + super().__init__( + font_style=font_style, font_variant=font_variant, font_weight=font_weight, + font_size=font_size, line_height=line_height, font_family=font_family, + ) + + def __str__(self): + string = '{font_style} {font_variant} {font_weight} {font_size}/{line_height} {font_family}' + return string.format(**self._map) + + class Quotes: """ Content opening and closing quotes wrapper. diff --git a/setup.py b/setup.py index 87d04ed80..fe86afb5c 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,10 @@ url='https://github.com/pybee/colosseum', packages=find_packages(exclude=['tests', 'utils']), python_requires='>=3.5', - install_requires=[], + install_requires=[ + 'pygobject>=3.14.0;sys_platform=="linux"', + 'rubicon-objc;sys_platform=="darwin"', + ], license='New BSD', classifiers=[ 'Development Status :: 4 - Beta', diff --git a/tests/data/fonts/Ahem.ttf b/tests/data/fonts/Ahem.ttf new file mode 100644 index 000000000..306f065ef Binary files /dev/null and b/tests/data/fonts/Ahem.ttf differ diff --git a/tests/data/fonts/Ahem_Ahem!.ttf b/tests/data/fonts/Ahem_Ahem!.ttf new file mode 100644 index 000000000..8e874110d Binary files /dev/null and b/tests/data/fonts/Ahem_Ahem!.ttf differ diff --git a/tests/data/fonts/Ahem_MissingItalicOblique.ttf b/tests/data/fonts/Ahem_MissingItalicOblique.ttf new file mode 100644 index 000000000..2c76d57cd Binary files /dev/null and b/tests/data/fonts/Ahem_MissingItalicOblique.ttf differ diff --git a/tests/data/fonts/Ahem_MissingNormal.ttf b/tests/data/fonts/Ahem_MissingNormal.ttf new file mode 100644 index 000000000..96cc52e70 Binary files /dev/null and b/tests/data/fonts/Ahem_MissingNormal.ttf differ diff --git a/tests/data/fonts/Ahem_SmallCaps.ttf b/tests/data/fonts/Ahem_SmallCaps.ttf new file mode 100644 index 000000000..5494aa3fb Binary files /dev/null and b/tests/data/fonts/Ahem_SmallCaps.ttf differ diff --git a/tests/data/fonts/Ahem_WhiteSpace.ttf b/tests/data/fonts/Ahem_WhiteSpace.ttf new file mode 100644 index 000000000..10c218a1e Binary files /dev/null and b/tests/data/fonts/Ahem_WhiteSpace.ttf differ diff --git a/tests/data/fonts/Ahem_cursive.ttf b/tests/data/fonts/Ahem_cursive.ttf new file mode 100644 index 000000000..090d04074 Binary files /dev/null and b/tests/data/fonts/Ahem_cursive.ttf differ diff --git a/tests/data/fonts/Ahem_default.ttf b/tests/data/fonts/Ahem_default.ttf new file mode 100644 index 000000000..6f77099bc Binary files /dev/null and b/tests/data/fonts/Ahem_default.ttf differ diff --git a/tests/data/fonts/Ahem_fantasy.ttf b/tests/data/fonts/Ahem_fantasy.ttf new file mode 100644 index 000000000..a357cb523 Binary files /dev/null and b/tests/data/fonts/Ahem_fantasy.ttf differ diff --git a/tests/data/fonts/Ahem_inherit.ttf b/tests/data/fonts/Ahem_inherit.ttf new file mode 100644 index 000000000..2357c694b Binary files /dev/null and b/tests/data/fonts/Ahem_inherit.ttf differ diff --git a/tests/data/fonts/Ahem_initial.ttf b/tests/data/fonts/Ahem_initial.ttf new file mode 100644 index 000000000..1fb73f05c Binary files /dev/null and b/tests/data/fonts/Ahem_initial.ttf differ diff --git a/tests/data/fonts/Ahem_monospace.ttf b/tests/data/fonts/Ahem_monospace.ttf new file mode 100644 index 000000000..aeacf2e5f Binary files /dev/null and b/tests/data/fonts/Ahem_monospace.ttf differ diff --git a/tests/data/fonts/Ahem_sans-serif.ttf b/tests/data/fonts/Ahem_sans-serif.ttf new file mode 100644 index 000000000..bc9de7820 Binary files /dev/null and b/tests/data/fonts/Ahem_sans-serif.ttf differ diff --git a/tests/data/fonts/Ahem_serif.ttf b/tests/data/fonts/Ahem_serif.ttf new file mode 100644 index 000000000..742c1646d Binary files /dev/null and b/tests/data/fonts/Ahem_serif.ttf differ diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 127447428..39e73e7b9 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -2,16 +2,17 @@ from colosseum import engine as css_engine from colosseum.colors import GOLDENROD, NAMED_COLOR, REBECCAPURPLE -from colosseum.constants import (AUTO, BLOCK, INHERIT, INITIAL, INLINE, LEFT, - REVERT, RIGHT, RTL, TABLE, UNSET, Choices, +from colosseum.constants import (AUTO, BLOCK, INHERIT, INITIAL, + INITIAL_FONT_VALUES, INLINE, LEFT, REVERT, + RIGHT, RTL, TABLE, UNSET, Choices, OtherProperty) from colosseum.declaration import CSS, validated_property from colosseum.units import percent, px from colosseum.validators import (is_color, is_integer, is_length, is_number, is_percentage) -from colosseum.wrappers import BorderSpacing, Quotes +from colosseum.wrappers import BorderSpacing, FontFamily, FontShorthand, Quotes -from .utils import TestNode +from .utils import ColosseumTestCase, TestNode class PropertyChoiceTests(TestCase): @@ -272,7 +273,7 @@ class MyObject: self.assertIs(obj.prop, AUTO) -class CssDeclarationTests(TestCase): +class CssDeclarationTests(ColosseumTestCase): def test_engine(self): node = TestNode(style=CSS()) self.assertEqual(node.style.engine(), css_engine) @@ -438,6 +439,37 @@ def test_property_with_choices(self): self.assertIs(node.style.display, INLINE) self.assertTrue(node.style.dirty) + def test_list_property(self): + node = TestNode(style=CSS()) + node.layout.dirty = None + + # Check initial value + self.assertEqual(node.style.font_family, FontFamily(['initial'])) + + # Check valid values + node.style.font_family = ['serif'] + node.style.font_family = ["Ahem", 'serif'] + + # This will coerce to a list, is this a valid behavior? + node.style.font_family = 'Ahem' + self.assertEqual(node.style.font_family, FontFamily(['Ahem'])) + node.style.font_family = ' Ahem , serif ' + self.assertEqual(node.style.font_family, FontFamily(['Ahem', 'serif'])) + + # Check valid value without extra quotes + node.style.font_family = ['White Space'] + + # Check extra quotes are removed + node.style.font_family = ['"White Space"'] + self.assertEqual(node.style.font_family, FontFamily(['White Space'])) + + # Check it raises + try: + node.style.font_family = ['123'] + self.fail('Should raise ValueError') + except ValueError: + pass + def test_property_border_spacing_valid_str_1_item_inherit(self): node = TestNode(style=CSS()) node.layout.dirty = None @@ -1216,3 +1248,128 @@ def test_dict(self): with self.assertRaises(KeyError): del node.style['no-such-property'] + + def test_font_shorthand_property(self): + node = TestNode(style=CSS()) + node.layout.dirty = None + + # Check initial value + self.assertTrue(isinstance(node.style.font, FontShorthand)) + self.assertEqual(node.style.font.to_dict(), INITIAL_FONT_VALUES) + + # Check Initial values + self.assertEqual(node.style.font_style, 'normal') + self.assertEqual(node.style.font_weight, 'normal') + self.assertEqual(node.style.font_variant, 'normal') + self.assertEqual(node.style.font_size, 'medium') + self.assertEqual(node.style.line_height, 'normal') + self.assertEqual(node.style.font_family, FontFamily(['initial'])) + + # Check individual properties update the unset shorthand + node.style.font_style = 'italic' + node.style.font_weight = 'bold' + node.style.font_variant = 'small-caps' + node.style.font_size = '10px' + node.style.line_height = '1.5' + node.style.font_family = ['Ahem', 'serif'] + expected_font = { + 'font_style': 'italic', + 'font_weight': 'bold', + 'font_variant': 'small-caps', + 'font_size': '10px', + 'line_height': '1.5', + 'font_family': FontFamily(['Ahem', 'serif']), + } + font = node.style.font.to_dict() + font['font_size'] = str(font['font_size']) + font['line_height'] = str(font['line_height']) + self.assertEqual(font, expected_font) + + # Check setting the shorthand resets values + node.style.font = '9px serif' + self.assertEqual(node.style.font_style, 'normal') + self.assertEqual(node.style.font_weight, 'normal') + self.assertEqual(node.style.font_variant, 'normal') + self.assertEqual(node.style.line_height, 'normal') + self.assertEqual(str(node.style.font_size), '9px') + self.assertEqual(node.style.font_family, FontFamily(['serif'])) + + # Check individual properties do not update the set shorthand + node.style.font = '9px "White Space", serif' + node.style.font_style = 'italic' + node.style.font_weight = 'bold' + node.style.font_variant = 'small-caps' + node.style.font_size = '10px' + node.style.line_height = '1.5' + expected_font = { + 'font_style': 'italic', + 'font_weight': 'bold', + 'font_variant': 'small-caps', + 'font_size': '10px', + 'line_height': '1.5', + 'font_family': FontFamily(['White Space', 'serif']), + } + font = node.style.font.to_dict() + font['font_size'] = str(font['font_size']) + font['line_height'] = str(font['line_height']) + self.assertEqual(font, expected_font) + + # Check string + self.assertEqual(str(node.style), ( + 'font: italic small-caps bold 10px/1.5 "White Space", serif; ' + 'font-family: "White Space", serif; ' + 'font-size: 10px; ' + 'font-style: italic; ' + 'font-variant: small-caps; ' + 'font-weight: bold; ' + 'line-height: 1.5' + )) + node.style.font = '9px "White Space", serif' + self.assertEqual(str(node.style), ( + 'font: normal normal normal 9px/normal "White Space", serif; ' + 'font-family: "White Space", serif; ' + 'font-size: 9px; ' + 'font-style: normal; ' + 'font-variant: normal; ' + 'font-weight: normal; ' + 'line-height: normal' + )) + + # Check invalid values + with self.assertRaises(ValueError): + node.style.font = 'ThisIsDefinitelyNotAFontName' + + def test_font_family_property(self): + node = TestNode(style=CSS()) + node.layout.dirty = None + + # Check type + self.assertTrue(isinstance(node.style.font_family, FontFamily)) + + # Check Initial values + self.assertEqual(node.style.font_family, FontFamily([INITIAL])) + + # Check lists as input + node.style.font_family = ['Ahem', 'White Space', 'serif'] + self.assertTrue(isinstance(node.style.font_family, FontFamily)) + self.assertEqual(node.style.font_family, FontFamily(['Ahem', 'White Space', 'serif'])) + + # Check strings as input + node.style.font_family = 'Ahem, "White Space", serif' + self.assertTrue(isinstance(node.style.font_family, FontFamily)) + self.assertEqual(node.style.font_family, FontFamily(['Ahem', 'White Space', 'serif'])) + + # Check string + self.assertEqual(str(node.style.font_family), 'Ahem, "White Space", serif') + self.assertEqual(str(node.style), 'font-family: Ahem, "White Space", serif') + + # # Check resets value + del node.style.font_family + self.assertEqual(node.style.font_family, FontFamily([INITIAL])) + + # Check invalid values + with self.assertRaises(ValueError): + node.style.font_family = 1 + + with self.assertRaises(ValueError): + node.style.font_family = {1} diff --git a/tests/test_fonts.py b/tests/test_fonts.py new file mode 100644 index 000000000..29e41da87 --- /dev/null +++ b/tests/test_fonts.py @@ -0,0 +1,34 @@ +from colosseum.constants import INITIAL_FONT_VALUES, SYSTEM_FONT_KEYWORDS +from colosseum.exceptions import ValidationError +from colosseum.fonts import FontDatabase, get_system_font + +from .utils import ColosseumTestCase + + +class ParseFontTests(ColosseumTestCase): + + def test_font_database(self): + # Check empty cache + FontDatabase.clear_cache() + self.assertEqual(FontDatabase._FONTS_CACHE, {}) # noqa + + # Check populated cache + FontDatabase.validate_font_family('Ahem') + FontDatabase.validate_font_family('White Space') + self.assertEqual(FontDatabase._FONTS_CACHE, {'Ahem': None, 'White Space': None}) # noqa + + # Check clear cache + FontDatabase.clear_cache() + self.assertEqual(FontDatabase._FONTS_CACHE, {}) # noq + + # Check returns a string + self.assertTrue(bool(FontDatabase.fonts_path(system=True))) + self.assertTrue(bool(FontDatabase.fonts_path(system=False))) + + # Check invalid + with self.assertRaises(ValidationError): + FontDatabase.validate_font_family('IAmDefinitelyNotAFontFamilyName') + + def test_get_system_font(self): + for keyword in SYSTEM_FONT_KEYWORDS: + self.assertEqual(get_system_font(keyword), INITIAL_FONT_VALUES) diff --git a/tests/test_parser.py b/tests/test_parser.py index 145c5c49e..bdb891b1d 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,13 +1,17 @@ from itertools import permutations from unittest import TestCase +import pytest + from colosseum import parser from colosseum.colors import hsl, rgb +from colosseum.constants import INITIAL_FONT_VALUES from colosseum.parser import (border, border_bottom, border_left, border_right, - border_top, color, outline) + border_top, color, outline, parse_font) from colosseum.shapes import Rect from colosseum.units import (ch, cm, em, ex, inch, mm, pc, percent, pt, px, vh, vmax, vmin, vw) +from colosseum.wrappers import FontFamily class ParseUnitTests(TestCase): @@ -292,6 +296,228 @@ def test_rect_invalid_case(self): parser.rect('RECT(1px, 3px, 2px, 4px)') +############################################################################## +# Font tests with pytest parametrization +############################################################################## + +# Constants +EMPTY = '' +INVALID = '' + + +def tuple_to_font_dict(tup, font_dict, remove_empty=False): + """Helper to convert a tuple to a font dict to check for valid outputs.""" + for idx, key in enumerate(('font_style', 'font_variant', 'font_weight', + 'font_size', 'line_height', 'font_family')): + value = tup[idx] + + if remove_empty: + if value is not EMPTY: + font_dict[key] = value + else: + font_dict[key] = value + + if key == 'font_family': + font_dict[key] = FontFamily(value) + + return font_dict + + +# Test helpers +def construct_font(font_dict, order=0): + """Construct font property string from a dictionary of font properties.""" + font_dict_copy = font_dict.copy() + for key in font_dict: + val = font_dict[key] + if val == EMPTY: + val = '' + + if key == 'line_height' and val != '': + val = '/' + val + + font_dict_copy[key] = val + + font_dict_copy['font_family'] = FontFamily(font_dict_copy['font_family']) + + strings = { + # Valid default order + 0: '{font_style} {font_variant} {font_weight} {font_size}{line_height} {font_family}', + + # Valid non default order + 1: '{font_style} {font_weight} {font_variant} {font_size}{line_height} {font_family}', + 2: '{font_weight} {font_variant} {font_style} {font_size}{line_height} {font_family}', + 3: '{font_weight} {font_style} {font_variant} {font_size}{line_height} {font_family}', + 4: '{font_variant} {font_weight} {font_style} {font_size}{line_height} {font_family}', + 5: '{font_variant} {font_style} {font_weight} {font_size}{line_height} {font_family}', + + # Invalid order + 10: '{font_size}{line_height} {font_style} {font_weight} {font_variant} {font_family}', + 11: '{font_weight} {font_size}{line_height} {font_variant} {font_style} {font_family}', + 12: '{font_weight} {font_style} {font_size}{line_height} {font_variant} {font_family}', + 13: '{font_style} {font_weight} {font_size}{line_height} {font_variant} {font_family}', + 14: '{font_weight} {font_variant} {font_size}{line_height} {font_style} {font_family}', + 15: '{font_variant} {font_weight} {font_size}{line_height} {font_style} {font_family}', + 16: '{font_style} {font_variant} {font_size}{line_height} {font_weight} {font_family}', + 17: '{font_variant} {font_style} {font_size}{line_height} {font_weight} {font_family}', + 18: '{font_variant} {font_style} {font_family} {font_size}{line_height} {font_weight}', + 19: '{font_family} {font_variant} {font_style} {font_size}{line_height} {font_weight}', + } + string = ' '.join(str(strings[order].format(**font_dict_copy)).strip().split()) + return string + + +def helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family): + font_with_empty_values = tuple_to_font_dict( + (font_style, font_variant, font_weight, font_size, line_height, font_family), + INITIAL_FONT_VALUES.copy(), + remove_empty=False) + + font_properties = set() + for order in range(6): + font_property = construct_font(font_with_empty_values, order) + if font_property not in font_properties: + font_properties.add(font_property) + with pytest.raises(Exception): + print(font_with_empty_values) + print('Font: ' + font_property) + parse_font(font_property) + + +# Tests +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_2_to_5_parts(font_style, font_variant, font_weight, font_size, line_height, + font_family): + font_with_empty_values = tuple_to_font_dict( + (font_style, font_variant, font_weight, font_size, line_height, font_family), + INITIAL_FONT_VALUES.copy(), + remove_empty=False) + + expected_output = tuple_to_font_dict( + (font_style, font_variant, font_weight, font_size, line_height, font_family), + INITIAL_FONT_VALUES.copy(), + remove_empty=True) + + # Valid + font_properties = set() + for order in range(6): + font_property = construct_font(font_with_empty_values, order) + if font_property not in font_properties: + font_properties.add(font_property) + font = parse_font(font_property) + print('\nfont: ', font_property) + print('parsed: ', sorted(font.items())) + print('expected: ', sorted(expected_output.items())) + assert font == expected_output + + # Invalid + font_properties_invalid = set() + for order in range(10, 20): + font_property = construct_font(font_with_empty_values, order) + if font_property not in font_properties: + font_properties_invalid.add(font_property) + print('\nfont: ', font_property) + with pytest.raises(Exception): + font = parse_font(font_property) + + +@pytest.mark.parametrize('font_style', [INVALID]) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_1(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [INVALID]) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_2(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [INVALID]) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_3(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', [INVALID]) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_4(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [INVALID]) +@pytest.mark.parametrize('font_family', [['Ahem'], ['Ahem', 'White Space']]) +def test_parse_font_shorthand_invalid_5(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_style', [EMPTY, 'normal', 'oblique']) +@pytest.mark.parametrize('font_variant', [EMPTY, 'normal', 'small-caps']) +@pytest.mark.parametrize('font_weight', [EMPTY, 'normal', 'bold', '500']) +@pytest.mark.parametrize('font_size', ['medium', '9px']) +@pytest.mark.parametrize('line_height', [EMPTY, 'normal', '2']) +@pytest.mark.parametrize('font_family', [[INVALID], ['Ahem', INVALID]]) +def test_parse_font_shorthand_invalid_6(font_style, font_variant, font_weight, font_size, line_height, font_family): + helper_test_font_invalid(font_style, font_variant, font_weight, font_size, line_height, font_family) + + +@pytest.mark.parametrize('font_property_string', [ + INVALID, + # Space between font-size and line-height + 'small-caps oblique normal 1.2em /3 Ahem', + 'small-caps oblique normal 1.2em/ 3 Ahem', + 'small-caps oblique normal 1.2em / 3 Ahem', + + # Too many parts + 'normal normal normal normal 12px/12px serif', + 'normal normal normal normal normal 12px/12px serif', + + # No quotes with spaces + 'small-caps oblique normal 1.2em/3 Ahem, White Space', + + # No commas + 'small-caps oblique normal 1.2em/3 Ahem "White Space"', + + # Repeated options + 'bold 500 oblique 9px/2 Ahem', + 'bigger smaller Ahem', + + # | inherit + 'Ahem', + '', + '20', + 20, +]) +def test_parse_font_shorthand_invalid_extras(font_property_string): + with pytest.raises(Exception): + print('Font: ' + font_property_string) + parse_font(font_property_string) + + class ParseQuotesTests(TestCase): # Valid cases diff --git a/tests/test_validators.py b/tests/test_validators.py index 19f5bceb9..14dcba11c 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,8 +1,9 @@ from unittest import TestCase +from colosseum.exceptions import ValidationError from colosseum.shapes import Rect from colosseum.units import px -from colosseum.validators import (ValidationError, is_border_spacing, +from colosseum.validators import (is_border_spacing, is_font_family, is_integer, is_number, is_quote, is_rect) from colosseum.wrappers import Quotes @@ -151,6 +152,32 @@ def test_rect_invalid(self): is_rect('1px, 3px 2px, 4px') +class FontTests(TestCase): + + def test_font_family_name_valid(self): + self.assertEqual(is_font_family(['Ahem', 'serif']), ['Ahem', 'serif']) + self.assertEqual(is_font_family([" Ahem ", " fantasy "]), ["Ahem", 'fantasy']) + self.assertEqual(is_font_family(["Ahem", "'White Space'"]), ["Ahem", 'White Space']) + self.assertEqual(is_font_family(["Ahem", '"White Space"']), ["Ahem", 'White Space']) + self.assertEqual(is_font_family([" Ahem ", " ' White Space ' "]), ["Ahem", 'White Space']) + self.assertEqual(is_font_family([" Ahem ", ' \" White Space \" ']), ["Ahem", 'White Space']) + + def test_font_family_name_invalid(self): + invalid_cases = [ + 'Red/Black, sans-serif', + '"Lucida" Grande, sans-serif', + 'Ahem!, sans-serif', + 'test@foo, sans-serif', + '#POUND, sans-serif', + 'Hawaii 5-0, sans-serif', + '123', + 'ThisIsDefintelyNotAFontFamily' + ] + for case in invalid_cases: + with self.assertRaises(ValidationError): + is_font_family([case]) + + class QuotesTests(TestCase): """ Comprehensive quotes tests are found in the parser tests. diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index fb6ab3cca..0fe7dd554 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -4,7 +4,8 @@ from colosseum.units import px from colosseum.wrappers import (Border, BorderBottom, BorderLeft, BorderRight, - BorderSpacing, BorderTop, Outline, Quotes, + BorderSpacing, BorderTop, FontFamily, + FontShorthand, ImmutableList, Outline, Quotes, Shorthand) @@ -57,6 +58,197 @@ def test_invalid_arg_number(self): BorderSpacing(1, 2, 3) +class ImmutableListTests(TestCase): + + def test_immutable_list_initial(self): + # Check initial + ilist = ImmutableList() + self.assertEqual(str(ilist), 'ImmutableList()') + self.assertEqual(repr(ilist), 'ImmutableList()') + self.assertEqual(len(ilist), 0) + + def test_immutable_list_creation(self): + # Check value + ilist = ImmutableList([1]) + self.assertEqual(str(ilist), "ImmutableList([1])") + self.assertEqual(repr(ilist), "ImmutableList([1])") + self.assertEqual(len(ilist), 1) + + # Check values + ilist = ImmutableList(['Ahem', 'White Space', 'serif']) + self.assertEqual(str(ilist), "ImmutableList(['Ahem', 'White Space', 'serif'])") + self.assertEqual(repr(ilist), "ImmutableList(['Ahem', 'White Space', 'serif'])") + self.assertEqual(len(ilist), 3) + + def test_immutable_list_get_item(self): + # Check get item + ilist = ImmutableList(['Ahem', 'White Space', 'serif']) + self.assertEqual(ilist[0], 'Ahem') + self.assertEqual(ilist[-1], 'serif') + + def test_immutable_list_set_item(self): + # Check immutable + ilist = ImmutableList() + with self.assertRaises(TypeError): + ilist[0] = 'initial' + + def test_immutable_list_equality(self): + # Check equality + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) + ilist3 = ImmutableList([2, 'Ahem']) + self.assertEqual(ilist1, ilist2) + self.assertNotEqual(ilist1, ilist3) + + def test_immutable_list_hash(self): + # Check hash + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) + + self.assertEqual(hash(ilist1), hash(ilist2)) + + def test_immutable_list_id(self): + # Check id + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) + self.assertNotEqual(id(ilist1), id(ilist2)) + self.assertNotEqual(id(ilist1), id(ilist1.copy())) + self.assertNotEqual(id(ilist2), id(ilist1.copy())) + + def test_immutable_list_copy(self): + # Check copy + ilist1 = ImmutableList(['Ahem', 2]) + ilist2 = ImmutableList(['Ahem', 2]) + + self.assertEqual(hash(ilist2), hash(ilist1.copy())) + self.assertEqual(ilist1, ilist1.copy()) + + +class FontFamilyTests(TestCase): + + def test_fontfamily_initial(self): + # Check initial + font = FontFamily() + self.assertEqual(str(font), '') + self.assertEqual(repr(font), 'FontFamily()') + self.assertEqual(len(font), 0) + + def test_fontfamily_values(self): + # Check value + font = FontFamily(['Ahem']) + self.assertEqual(str(font), 'Ahem') + self.assertEqual(repr(font), "FontFamily(['Ahem'])") + self.assertEqual(len(font), 1) + + # Check values + font = FontFamily(['Ahem', 'White Space', 'serif']) + self.assertEqual(str(font), 'Ahem, "White Space", serif') + self.assertEqual(repr(font), "FontFamily(['Ahem', 'White Space', 'serif'])") + self.assertEqual(len(font), 3) + + def test_fontfamily_get_item(self): + # Check get item + font = FontFamily(['Ahem', 'White Space', 'serif']) + self.assertEqual(font[0], 'Ahem') + self.assertEqual(font[-1], 'serif') + + def test_fontfamily_set_item(self): + # Check immutable + font = FontFamily(['Ahem', 'White Space', 'serif']) + with self.assertRaises(TypeError): + font[0] = 'initial' + + def test_fontfamily_equality(self): + # Check equality + font1 = FontFamily(['Ahem', 'serif']) + font2 = FontFamily(['Ahem', 'serif']) + font3 = FontFamily(['serif', 'Ahem']) + self.assertEqual(font1, font2) + self.assertNotEqual(font1, font3) + + def test_fontfamily_hash(self): + # Check hash + font1 = FontFamily(['Ahem', 'serif']) + font2 = FontFamily(['Ahem', 'serif']) + self.assertEqual(hash(font1), hash(font2)) + + def test_fontfamily_copy(self): + # Check copy + font1 = FontFamily(['Ahem', 'serif']) + font2 = FontFamily(['Ahem', 'serif']) + self.assertNotEqual(id(font1), id(font2)) + self.assertNotEqual(id(font1), id(font1.copy())) + self.assertNotEqual(id(font2), id(font2.copy())) + self.assertEqual(font1, font1.copy()) + + +class ShorthandTests(TestCase): + + def test_shorthand(self): + shorthand = Shorthand() + self.assertEqual(str(shorthand), 'Shorthand()') + self.assertEqual(repr(shorthand), ("Shorthand()")) + + +class FontShorthandTests(TestCase): + + def test_font_shorthand_initial(self): + # Check initial + font = FontShorthand() + self.assertEqual(str(font), 'normal normal normal medium/normal initial') + self.assertEqual( + repr(font), + ("FontShorthand(font_style='normal', font_variant='normal', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) + + def test_font_shorthand_set_weight(self): + font = FontShorthand(font_weight='bold') + self.assertEqual(str(font), 'normal normal bold medium/normal initial') + self.assertEqual( + repr(font), + ("FontShorthand(font_style='normal', font_variant='normal', font_weight='bold', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) + + def test_font_shorthand_set_variant(self): + font = FontShorthand(font_variant='small-caps') + self.assertEqual(str(font), 'normal small-caps normal medium/normal initial') + self.assertEqual( + repr(font), + ("FontShorthand(font_style='normal', font_variant='small-caps', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) + + def test_font_shorthand_set_style(self): + font = FontShorthand(font_style='oblique') + self.assertEqual(str(font), 'oblique normal normal medium/normal initial') + self.assertEqual( + repr(font), + ("FontShorthand(font_style='oblique', font_variant='normal', font_weight='normal', " + "font_size='medium', line_height='normal', font_family=FontFamily(['initial']))") + ) + + def test_font_shorthand_invalid_key(self): + # Check invalid key + font = FontShorthand() + with self.assertRaises(KeyError): + font['invalid-key'] = 2 + + def test_font_shorthand_copy(self): + # Copy + font = FontShorthand() + self.assertEqual(font, font.copy()) + self.assertNotEqual(id(font), id(font.copy())) + + def test_font_shorthand_iteration(self): + font = FontShorthand() + keys = [] + for prop in font: + keys.append(prop) + self.assertEqual(len(keys), 6) + + class QuotesTests(TestCase): # Valid cases diff --git a/tests/utils.py b/tests/utils.py index 071e2cbad..d4bfa5e15 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,11 +1,18 @@ import json import os +import shutil +import sys from unittest import TestCase, expectedFailure from colosseum.constants import BLOCK, HTML4, MEDIUM, THICK, THIN from colosseum.declaration import CSS from colosseum.dimensions import Box, Size from colosseum.engine import layout +from colosseum.exceptions import ValidationError +from colosseum.fonts import FontDatabase + +# Constants +HERE = os.path.abspath(os.path.dirname(__file__)) class Display: @@ -125,34 +132,99 @@ def clean_layout(layout): def output_layout(layout, depth=1): if 'tag' in layout: return (' ' * depth - + '* {tag}{n[content][size][0]}x{n[content][size][1]}' - ' @ ({n[content][position][0]}, {n[content][position][1]})' - '\n'.format( - n=layout, - tag=('<' + layout['tag'] + '> ') if 'tag' in layout else '', - # text=(": '" + layout['text'] + "'") if 'text' in layout else '' - ) - # + ' ' * depth - # + ' padding: {n[padding_box][size][0]}x{n[padding_box][size][1]}' - # ' @ ({n[padding_box][position][0]}, {n[padding_box][position][1]})' - # '\n'.format(n=layout) - # + ' ' * depth - # + ' border: {n[border_box][size][0]}x{n[border_box][size][1]}' - # ' @ ({n[border_box][position][0]}, {n[border_box][position][1]})' - # '\n'.format(n=layout) - + ''.join( - output_layout(child, depth=depth + 1) - for child in layout.get('children', []) - ) if layout else '' - + ('\n' if layout and layout.get('children', None) and depth > 1 else '') - ) + + '* {tag}{n[content][size][0]}x{n[content][size][1]}' + ' @ ({n[content][position][0]}, {n[content][position][1]})' + '\n'.format( + n=layout, + tag=('<' + layout['tag'] + '> ') if 'tag' in layout else '', + # text=(": '" + layout['text'] + "'") if 'text' in layout else '' + ) + # + ' ' * depth + # + ' padding: {n[padding_box][size][0]}x{n[padding_box][size][1]}' + # ' @ ({n[padding_box][position][0]}, {n[padding_box][position][1]})' + # '\n'.format(n=layout) + # + ' ' * depth + # + ' border: {n[border_box][size][0]}x{n[border_box][size][1]}' + # ' @ ({n[border_box][position][0]}, {n[border_box][position][1]})' + # '\n'.format(n=layout) + + ''.join( + output_layout(child, depth=depth + 1) + for child in layout.get('children', []) + ) if layout else '' + + ('\n' if layout and layout.get('children', None) and depth > 1 else '')) else: - return (' ' * depth - + "* '{text}'\n".format(text=layout['text'].strip()) - ) + return (' ' * depth + "* '{text}'\n".format(text=layout['text'].strip())) + + +def install_fonts(system=False): + """Install needed files for running tests.""" + fonts_folder = FontDatabase.fonts_path(system=system) + + if not os.path.isdir(fonts_folder): + os.makedirs(fonts_folder) + + fonts_data_path = os.path.join(HERE, 'data', 'fonts') + font_files = sorted([item for item in os.listdir(fonts_data_path) if item.endswith('.ttf')]) + for font_file in font_files: + font_file_data_path = os.path.join(fonts_data_path, font_file) + font_file_path = os.path.join(fonts_folder, font_file) + + if not os.path.isfile(font_file_path): + shutil.copyfile(font_file_data_path, font_file_path) + + if os.name == 'nt': + # Register font + import winreg # noqa + base_key = winreg.HKEY_LOCAL_MACHINE if system else winreg.HKEY_CURRENT_USER + key_path = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" + + if '_' in font_file: + font_name = font_file.split('_')[-1].split('.')[0] + else: + font_name = font_file.split('.')[0] + # This font has a space in its system name + if font_name == 'WhiteSpace': + font_name = 'White Space' -class LayoutTestCase(TestCase): + font_name = font_name + ' (TrueType)' + + with winreg.OpenKey(base_key, key_path, 0, winreg.KEY_ALL_ACCESS) as reg_key: + value = None + try: + # Query if it exists + value = winreg.QueryValueEx(reg_key, font_name) + except FileNotFoundError: + pass + + # If it does not exists, add value + if value != font_file_path: + winreg.SetValueEx(reg_key, font_name, 0, winreg.REG_SZ, font_file_path) + + +class ColosseumTestCase(TestCase): + """Install test fonts before running tests that use them.""" + _FONTS_ACTIVE = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self._FONTS_ACTIVE is False: + self.install_fonts() + + def install_fonts(self): + install_fonts() + + try: + FontDatabase.validate_font_family('Ahem') + FontDatabase.clear_cache() + except ValidationError: + raise Exception('\n\nTesting fonts (Ahem & Ahem Extra) are not active.\n' + '\nPlease run the test suite one more time.\n') + + ColosseumTestCase._FONTS_ACTIVE = True + + +class LayoutTestCase(ColosseumTestCase): def setUp(self): self.maxDiff = None self.display = Display(dpi=96, width=1024, height=768) @@ -395,3 +467,12 @@ def test_method(self): tests[test_name] = test_method return tests + + +if __name__ == '__main__': + # On CI we use system font locations except for linux containers + system = bool(os.environ.get('GITHUB_WORKSPACE', None)) + if sys.platform.startswith('linux'): + system = False + print('Copying test fonts to "{path}"...'.format(path=FontDatabase.fonts_path(system=system))) + install_fonts(system=system)