feat: add comprehensive GitHub workflow and development tools

This commit is contained in:
Stiftung Development
2025-09-06 18:31:54 +02:00
commit ab23d7187e
10224 changed files with 2075210 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,824 @@
"""Convert specified property values into computed values."""
from math import pi
from urllib.parse import unquote
from tinycss2.color3 import parse_color
from ..logger import LOGGER
from ..text.ffi import ffi, pango, units_to_double
from ..text.line_break import Layout, first_line_metrics
from ..urls import get_link_attribute
from .properties import INITIAL_VALUES, ZERO_PIXELS, Dimension
from .utils import ANGLE_TO_RADIANS, LENGTH_UNITS, LENGTHS_TO_PIXELS, safe_urljoin
# Value in pixels of font-size for <absolute-size> keywords: 12pt (16px) for
# medium, and scaling factors given in CSS3 for others:
# https://www.w3.org/TR/css-fonts-3/#font-size-prop
FONT_SIZE_KEYWORDS = {
# medium is 16px, others are a ratio of medium
name: INITIAL_VALUES['font_size'] * factor
for name, factor in (
('xx-small', 3 / 5),
('x-small', 3 / 4),
('small', 8 / 9),
('medium', 1),
('large', 6 / 5),
('x-large', 3 / 2),
('xx-large', 2),
)
}
# These are unspecified, other than 'thin' <= 'medium' <= 'thick'.
# Values are in pixels.
BORDER_WIDTH_KEYWORDS = {
'thin': 1,
'medium': 3,
'thick': 5,
}
assert INITIAL_VALUES['border_top_width'] == BORDER_WIDTH_KEYWORDS['medium']
# https://www.w3.org/TR/CSS21/fonts.html#propdef-font-weight
FONT_WEIGHT_RELATIVE = {
'bolder': {
100: 400,
200: 400,
300: 400,
400: 700,
500: 700,
600: 900,
700: 900,
800: 900,
900: 900,
},
'lighter': {
100: 100,
200: 100,
300: 100,
400: 100,
500: 100,
600: 400,
700: 400,
800: 700,
900: 700,
},
}
# https://www.w3.org/TR/css-page-3/#size
PAGE_SIZES = {
page_size: (Dimension(width, unit), Dimension(height, unit))
for page_size, width, height, unit in (
('a10', 26, 37, 'mm'),
('a9', 37, 52, 'mm'),
('a8', 52, 74, 'mm'),
('a7', 74, 105, 'mm'),
('a6', 105, 148, 'mm'),
('a5', 148, 210, 'mm'),
('a4', 210, 297, 'mm'),
('a3', 297, 420, 'mm'),
('a2', 420, 594, 'mm'),
('a1', 594, 841, 'mm'),
('a0', 841, 1189, 'mm'),
('b10', 31, 44, 'mm'),
('b9', 44, 62, 'mm'),
('b8', 62, 88, 'mm'),
('b7', 88, 125, 'mm'),
('b6', 125, 176, 'mm'),
('b5', 176, 250, 'mm'),
('b4', 250, 353, 'mm'),
('b3', 353, 500, 'mm'),
('b2', 500, 707, 'mm'),
('b1', 707, 1000, 'mm'),
('b0', 1000, 1414, 'mm'),
('c10', 28, 40, 'mm'),
('c9', 40, 57, 'mm'),
('c8', 57, 81, 'mm'),
('c7', 81, 114, 'mm'),
('c6', 114, 162, 'mm'),
('c5', 162, 229, 'mm'),
('c4', 229, 324, 'mm'),
('c3', 324, 458, 'mm'),
('c2', 458, 648, 'mm'),
('c1', 648, 917, 'mm'),
('c0', 917, 1297, 'mm'),
('jis-b10', 32, 45, 'mm'),
('jis-b9', 45, 64, 'mm'),
('jis-b8', 64, 91, 'mm'),
('jis-b7', 91, 128, 'mm'),
('jis-b6', 128, 182, 'mm'),
('jis-b5', 182, 257, 'mm'),
('jis-b4', 257, 364, 'mm'),
('jis-b3', 364, 515, 'mm'),
('jis-b2', 515, 728, 'mm'),
('jis-b1', 728, 1030, 'mm'),
('jis-b0', 1030, 1456, 'mm'),
('letter', 8.5, 11, 'in'),
('legal', 8.5, 14, 'in'),
('ledger', 11, 17, 'in'),
)
}
# In "portrait" orientation.
assert all(width.value < height.value for width, height in PAGE_SIZES.values())
INITIAL_PAGE_SIZE = PAGE_SIZES['a4']
INITIAL_VALUES['size'] = tuple(
size.value * LENGTHS_TO_PIXELS[size.unit] for size in INITIAL_PAGE_SIZE)
# Maps property names to functions returning the computed values
COMPUTER_FUNCTIONS = {}
def _font_style_cache_key(style, include_size=False):
key = str((
style['font_family'],
style['font_style'],
style['font_stretch'],
style['font_weight'],
style['font_variant_ligatures'],
style['font_variant_position'],
style['font_variant_caps'],
style['font_variant_numeric'],
style['font_variant_alternates'],
style['font_variant_east_asian'],
style['font_feature_settings'],
style['font_variation_settings'],
style['font_language_override'],
style['lang'],
))
if include_size:
key += str(style['font_size']) + str(style['line_height'])
return key
def register_computer(name):
"""Decorator registering a property ``name`` for a function."""
name = name.replace('-', '_')
def decorator(function):
"""Register the property ``name`` for ``function``."""
COMPUTER_FUNCTIONS[name] = function
return function
return decorator
def compute_attr(style, values):
# TODO: use real token parsing instead of casting with Python types
func_name, value = values
assert func_name == 'attr()'
attr_name, type_or_unit, fallback = value
try:
attr_value = style.element.get(attr_name, fallback)
if type_or_unit == 'string':
pass # Keep the string
elif type_or_unit == 'url':
if attr_value.startswith('#'):
attr_value = ('internal', unquote(attr_value[1:]))
else:
attr_value = (
'external', safe_urljoin(style.base_url, attr_value))
elif type_or_unit == 'color':
attr_value = parse_color(attr_value.strip())
elif type_or_unit == 'integer':
attr_value = int(attr_value.strip())
elif type_or_unit == 'number':
attr_value = float(attr_value.strip())
elif type_or_unit == '%':
attr_value = Dimension(float(attr_value.strip()), '%')
type_or_unit = 'length'
elif type_or_unit in LENGTH_UNITS:
attr_value = Dimension(float(attr_value.strip()), type_or_unit)
type_or_unit = 'length'
elif type_or_unit in ANGLE_TO_RADIANS:
attr_value = Dimension(float(attr_value.strip()), type_or_unit)
type_or_unit = 'angle'
except Exception:
return
return (type_or_unit, attr_value)
@register_computer('background-image')
def background_image(style, name, values):
"""Compute lenghts in gradient background-image."""
for type_, value in values:
if type_ in ('linear-gradient', 'radial-gradient'):
value.stop_positions = tuple(
length(style, name, pos) if pos is not None else None
for pos in value.stop_positions)
if type_ == 'radial-gradient':
value.center, = compute_position(
style, name, (value.center,))
if value.size_type == 'explicit':
value.size = length_or_percentage_tuple(
style, name, value.size)
return values
@register_computer('background-position')
@register_computer('object-position')
def compute_position(style, name, values):
"""Compute lengths in background-position."""
return tuple(
(origin_x, length(style, name, pos_x),
origin_y, length(style, name, pos_y))
for origin_x, pos_x, origin_y, pos_y in values)
@register_computer('transform-origin')
def length_or_percentage_tuple(style, name, values):
"""Compute the lists of lengths that can be percentages."""
return tuple(length(style, name, value) for value in values)
@register_computer('border-spacing')
@register_computer('size')
@register_computer('clip')
def length_tuple(style, name, values):
"""Compute the properties with a list of lengths."""
return tuple(
length(style, name, value, pixels_only=True) for value in values)
@register_computer('break-after')
@register_computer('break-before')
def break_before_after(style, name, value):
"""Compute the ``break-before`` and ``break-after`` properties."""
return 'page' if value == 'always' else value
@register_computer('top')
@register_computer('right')
@register_computer('left')
@register_computer('bottom')
@register_computer('margin-top')
@register_computer('margin-right')
@register_computer('margin-bottom')
@register_computer('margin-left')
@register_computer('height')
@register_computer('width')
@register_computer('min-width')
@register_computer('min-height')
@register_computer('max-width')
@register_computer('max-height')
@register_computer('padding-top')
@register_computer('padding-right')
@register_computer('padding-bottom')
@register_computer('padding-left')
@register_computer('text-indent')
@register_computer('hyphenate-limit-zone')
@register_computer('flex-basis')
def length(style, name, value, font_size=None, pixels_only=False):
"""Compute a length ``value``."""
if value in ('auto', 'content'):
return value
if value.value == 0:
return 0 if pixels_only else ZERO_PIXELS
unit = value.unit
if unit == 'px':
return value.value if pixels_only else value
elif unit in LENGTHS_TO_PIXELS:
# Convert absolute lengths to pixels
result = value.value * LENGTHS_TO_PIXELS[unit]
elif unit in ('em', 'ex', 'ch', 'rem'):
if font_size is None:
font_size = style['font_size']
if unit == 'ex':
# TODO: use context to use @font-face fonts
ratio = character_ratio(style, 'x')
result = value.value * font_size * ratio
elif unit == 'ch':
ratio = character_ratio(style, '0')
result = value.value * font_size * ratio
elif unit == 'em':
result = value.value * font_size
elif unit == 'rem':
result = value.value * style.root_style['font_size']
else:
# A percentage or 'auto': no conversion needed.
return value
return result if pixels_only else Dimension(result, 'px')
@register_computer('bleed-left')
@register_computer('bleed-right')
@register_computer('bleed-top')
@register_computer('bleed-bottom')
def bleed(style, name, value):
if value == 'auto':
return Dimension(8 if 'crop' in style['marks'] else 0, 'px')
else:
return length(style, name, value)
@register_computer('letter-spacing')
def pixel_length(style, name, value):
if value == 'normal':
return value
else:
return length(style, name, value, pixels_only=True)
@register_computer('background-size')
def background_size(style, name, values):
"""Compute the ``background-size`` properties."""
return tuple(
value if value in ('contain', 'cover') else
length_or_percentage_tuple(style, name, value)
for value in values)
@register_computer('image-orientation')
def image_orientation(style, name, values):
"""Compute the ``image-orientation`` properties."""
if values in ('none', 'from-image'):
return values
angle, flip = values
return (int(round(angle / pi * 2)) % 4 * 90, flip)
@register_computer('border-top-width')
@register_computer('border-right-width')
@register_computer('border-left-width')
@register_computer('border-bottom-width')
@register_computer('column-rule-width')
@register_computer('outline-width')
def border_width(style, name, value):
"""Compute the ``border-*-width`` properties."""
border_style = style[name.replace('width', 'style')]
if border_style in ('none', 'hidden'):
return 0
if value in BORDER_WIDTH_KEYWORDS:
return BORDER_WIDTH_KEYWORDS[value]
if isinstance(value, int):
# The initial value can get here, but length() would fail as
# it does not have a 'unit' attribute.
return value
return length(style, name, value, pixels_only=True)
@register_computer('border-image-slice')
def border_image_slice(style, name, values):
"""Compute the ``border-image-slice`` property."""
computed_values = []
fill = None
for value in values:
if value == 'fill':
fill = value
else:
number, unit = value
if unit is None:
computed_values.append(number)
else:
computed_values.append(Dimension(number, '%'))
if len(computed_values) == 1:
computed_values *= 4
elif len(computed_values) == 2:
computed_values *= 2
elif len(computed_values) == 3:
computed_values.append(computed_values[1])
return (*computed_values, fill)
@register_computer('border-image-width')
def border_image_width(style, name, values):
"""Compute the ``border-image-width`` property."""
computed_values = []
for value in values:
if value == 'auto':
computed_values.append(value)
else:
number, unit = value
computed_values.append(number if unit is None else value)
if len(computed_values) == 1:
computed_values *= 4
elif len(computed_values) == 2:
computed_values *= 2
elif len(computed_values) == 3:
computed_values.append(computed_values[1])
return tuple(computed_values)
@register_computer('border-image-outset')
def border_image_outset(style, name, values):
"""Compute the ``border-image-outset`` property."""
computed_values = [
value if isinstance(value, (int, float)) else length(style, name, value)
for value in values]
if len(computed_values) == 1:
computed_values *= 4
elif len(computed_values) == 2:
computed_values *= 2
elif len(computed_values) == 3:
computed_values.append(computed_values[1])
return tuple(computed_values)
@register_computer('border-image-repeat')
def border_image_repeat(style, name, values):
"""Compute the ``border-image-repeat`` property."""
return (values * 2) if len(values) == 1 else values
@register_computer('column-width')
def column_width(style, name, value):
"""Compute the ``column-width`` property."""
return length(style, name, value, pixels_only=True)
@register_computer('border-top-left-radius')
@register_computer('border-top-right-radius')
@register_computer('border-bottom-left-radius')
@register_computer('border-bottom-right-radius')
def border_radius(style, name, values):
"""Compute the ``border-*-radius`` properties."""
return tuple(length(style, name, value) for value in values)
@register_computer('column-gap')
@register_computer('row-gap')
def gap(style, name, value):
"""Compute the ``*-gap`` properties."""
if value == 'normal':
return value
return length(style, name, value)
def _content_list(style, values):
computed_values = []
for value in values:
if value[0] in ('string', 'content', 'url', 'quote', 'leader()'):
computed_value = value
elif value[0] == 'attr()':
assert value[1][1] == 'string'
computed_value = compute_attr(style, value)
elif value[0] in (
'counter()', 'counters()', 'content()', 'element()',
'string()'):
# Other values need layout context, their computed value cannot be
# better than their specified value yet.
# See build.compute_content_list.
computed_value = value
elif value[0] in (
'target-counter()', 'target-counters()', 'target-text()'):
anchor_token = value[1][0]
if anchor_token[0] == 'attr()':
attr = compute_attr(style, anchor_token)
if attr is None:
computed_value = None
else:
computed_value = (value[0], ((attr,) + value[1][1:]))
else:
computed_value = value
if computed_value is None:
LOGGER.warning('Unable to compute %r value for content: %r' % (
style.element, ', '.join(str(item) for item in value)))
else:
computed_values.append(computed_value)
return tuple(computed_values)
@register_computer('bookmark-label')
def bookmark_label(style, name, values):
"""Compute the ``bookmark-label`` property."""
return _content_list(style, values)
@register_computer('string-set')
def string_set(style, name, values):
"""Compute the ``string-set`` property."""
# Spec asks for strings after custom keywords, but we allow content-lists
return tuple(
(string_set[0], _content_list(style, string_set[1]))
for string_set in values)
@register_computer('content')
def content(style, name, values):
"""Compute the ``content`` property."""
if len(values) == 1:
value, = values
if value == 'normal':
return 'inhibit' if style.pseudo_type else 'contents'
elif value == 'none':
return 'inhibit'
return _content_list(style, values)
@register_computer('display')
def display(style, name, value):
"""Compute the ``display`` property."""
# See https://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo.
float_ = style.specified['float']
position = style.specified['position']
if position in ('absolute', 'fixed') or float_ != 'none' or (
style.is_root_element):
if value == ('inline-table',):
return ('block', 'table')
elif len(value) == 1 and value[0].startswith('table-'):
return ('block', 'flow')
elif value[0] == 'inline':
if 'list-item' in value:
return ('block', 'flow', 'list-item')
else:
return ('block', 'flow')
return value
@register_computer('float')
def compute_float(style, name, value):
"""Compute the ``float`` property."""
# See https://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo.
position = style.specified['position']
if position in ('absolute', 'fixed') or position[0] == 'running()':
return 'none'
else:
return value
@register_computer('font-size')
def font_size(style, name, value):
"""Compute the ``font-size`` property."""
if value in FONT_SIZE_KEYWORDS:
return FONT_SIZE_KEYWORDS[value]
keyword_values = list(FONT_SIZE_KEYWORDS.values())
if style.parent_style is None:
parent_font_size = INITIAL_VALUES['font_size']
else:
parent_font_size = style.parent_style['font_size']
if value == 'larger':
for i, keyword_value in enumerate(keyword_values):
if keyword_value > parent_font_size:
return keyword_values[i]
else:
return parent_font_size * 1.2
elif value == 'smaller':
for i, keyword_value in enumerate(keyword_values[::-1]):
if keyword_value < parent_font_size:
return keyword_values[-i - 1]
else:
return parent_font_size * 0.8
elif value.unit == '%':
return value.value * parent_font_size / 100
else:
return length(
style, name, value, pixels_only=True,
font_size=parent_font_size)
@register_computer('font-weight')
def font_weight(style, name, value):
"""Compute the ``font-weight`` property."""
if value == 'normal':
return 400
elif value == 'bold':
return 700
elif value in ('bolder', 'lighter'):
if style.parent_style is None:
parent_value = INITIAL_VALUES['font_weight']
else:
parent_value = style.parent_style['font_weight']
return FONT_WEIGHT_RELATIVE[value][parent_value]
else:
return value
def _compute_track_breadth(style, name, value):
"""Compute track breadth."""
if value in ('auto', 'min-content', 'max-content'):
return value
elif isinstance(value, Dimension):
if value.unit == 'fr':
return value
else:
return length(style, name, value)
def _track_size(style, name, values):
"""Compute track size."""
return_values = []
for i, value in enumerate(values):
if i % 2 == 0:
# line name
return_values.append(value)
else:
# track section
track_breadth = _compute_track_breadth(style, name, value)
if track_breadth:
return_values.append(track_breadth)
elif value[0] == 'minmax()':
return_values.append((
'minmax()',
_compute_track_breadth(style, name, value[1]),
_compute_track_breadth(style, name, value[2])))
elif value[0] == 'fit-content()':
return_values.append((
'fit-content()', length(style, name, value[1])))
elif value[0] == 'repeat()':
return_values.append((
'repeat()', value[1], _track_size(style, name, value[2])))
return tuple(return_values)
@register_computer('grid-template-columns')
@register_computer('grid-template-rows')
def grid_template(style, name, values):
"""Compute the ``grid-template-*`` properties."""
if values == 'none' or values[0] == 'subgrid':
return values
else:
return _track_size(style, name, values)
@register_computer('grid-auto-columns')
@register_computer('grid-auto-rows')
def grid_auto(style, name, values):
"""Compute the ``grid-auto-*`` properties."""
return_values = []
for value in values:
track_breadth = _compute_track_breadth(style, name, value)
if track_breadth:
return_values.append(track_breadth)
elif value[0] == 'minmax()':
return_values.append((
'minmax()', grid_auto(style, name, [value[1]])[0],
grid_auto(style, name, [value[2]])[0]))
elif value[0] == 'fit-content()':
return_values.append((
'fit-content()', grid_auto(style, name, [value[1]])[0]))
return tuple(return_values)
@register_computer('line-height')
def line_height(style, name, value):
"""Compute the ``line-height`` property."""
if value == 'normal':
return value
elif not value.unit:
return ('NUMBER', value.value)
elif value.unit == '%':
factor = value.value / 100
font_size_value = style['font_size']
pixels = factor * font_size_value
else:
pixels = length(style, name, value, pixels_only=True)
return ('PIXELS', pixels)
@register_computer('anchor')
def anchor(style, name, values):
"""Compute the ``anchor`` property."""
if values != 'none':
_, key = values
anchor_name = style.element.get(key) or None
return anchor_name
@register_computer('link')
def link(style, name, values):
"""Compute the ``link`` property."""
if values == 'none':
return None
else:
type_, value = values
if type_ == 'attr()':
return get_link_attribute(style.element, value, style.base_url)
else:
return values
@register_computer('lang')
def lang(style, name, values):
"""Compute the ``lang`` property."""
if values == 'none':
return None
else:
name, key = values
if name == 'attr()':
return style.element.get(key) or None
elif name == 'string':
return key
@register_computer('tab-size')
def tab_size(style, name, value):
"""Compute the ``tab-size`` property."""
return value if isinstance(value, int) else length(style, name, value)
@register_computer('transform')
def transform(style, name, value):
"""Compute the ``transform`` property."""
result = []
for function, args in value:
if function == 'translate':
args = length_or_percentage_tuple(style, name, args)
result.append((function, args))
return tuple(result)
@register_computer('vertical-align')
def vertical_align(style, name, value):
"""Compute the ``vertical-align`` property."""
# Use +/- half an em for super and sub, same as Pango.
# (See the SUPERSUB_RISE constant in pango-markup.c)
if value in (
'baseline', 'middle', 'text-top', 'text-bottom', 'top', 'bottom'):
return value
elif value == 'super':
return style['font_size'] * 0.5
elif value == 'sub':
return style['font_size'] * -0.5
elif value.unit == '%':
height, _ = strut_layout(style)
return height * value.value / 100
else:
return length(style, name, value, pixels_only=True)
@register_computer('word-spacing')
def word_spacing(style, name, value):
"""Compute the ``word-spacing`` property."""
if value == 'normal':
return 0
else:
return length(style, name, value, pixels_only=True)
def strut_layout(style, context=None):
"""Return a tuple of the used value of ``line-height`` and the baseline.
The baseline is given from the top edge of line height.
"""
if style['font_size'] == 0:
return 0, 0
if context:
key = _font_style_cache_key(style, include_size=True)
if key in context.strut_layouts:
return context.strut_layouts[key]
layout = Layout(context, style)
layout.set_text(' ')
line, _ = layout.get_first_line()
_, _, _, _, text_height, baseline = first_line_metrics(
line, '', layout, resume_at=None, space_collapse=False, style=style)
if style['line_height'] == 'normal':
result = text_height, baseline
if context:
context.strut_layouts[key] = result
return result
type_, line_height = style['line_height']
if type_ == 'NUMBER':
line_height *= style['font_size']
result = line_height, baseline + (line_height - text_height) / 2
if context:
context.strut_layouts[key] = result
return result
def character_ratio(style, character):
"""Return the ratio of 1ex/font_size or 1ch/font_size."""
# TODO: use context to use @font-face fonts
assert character in ('x', '0')
cache = style.cache[f'ratio_{"ex" if character == "x" else "ch"}']
cache_key = _font_style_cache_key(style)
if cache_key in cache:
return cache[cache_key]
# Avoid recursion for letter-spacing and word-spacing properties
style = style.copy()
style['letter_spacing'] = 'normal'
style['word_spacing'] = 0
# Random big value
style['font_size'] = 1000
layout = Layout(context=None, style=style)
layout.set_text(character)
line, _ = layout.get_first_line()
ink_extents = ffi.new('PangoRectangle *')
logical_extents = ffi.new('PangoRectangle *')
pango.pango_layout_line_get_extents(line, ink_extents, logical_extents)
if character == 'x':
measure = -units_to_double(ink_extents.y)
else:
measure = units_to_double(logical_extents.width)
ffi.release(ink_extents)
ffi.release(logical_extents)
# Zero means some kind of failure, fallback is 0.5.
# We round to try keeping exact values that were altered by Pango.
ratio = round(measure / style['font_size'], 5) or 0.5
cache[cache_key] = ratio
return ratio

View File

@@ -0,0 +1,296 @@
"""Implement counter styles.
These are defined in CSS Counter Styles Level 3:
https://www.w3.org/TR/css-counter-styles-3/#counter-style-system
"""
from math import inf
from .utils import remove_whitespace
def symbol(string_or_url):
"""Create a string from a symbol."""
# TODO: this function should handle images too, and return something else
# than strings.
type_, value = string_or_url
if type_ == 'string':
return value
return ''
def parse_counter_style_name(tokens, counter_style):
tokens = remove_whitespace(tokens)
if len(tokens) == 1:
token, = tokens
if token.type == 'ident':
if token.lower_value in ('decimal', 'disc'):
if token.lower_value not in counter_style:
return token.value
elif token.lower_value != 'none':
return token.value
class CounterStyle(dict):
"""Counter styles dictionary.
Keep a list of counter styles defined by ``@counter-style`` rules, indexed
by their names.
See https://www.w3.org/TR/css-counter-styles-3/.
"""
def resolve_counter(self, counter_name, previous_types=None):
if counter_name[0] in ('symbols()', 'string'):
counter_type, arguments = counter_name
if counter_type == 'string':
system = (None, 'cyclic', None)
symbols = (('string', arguments),)
suffix = ('string', '')
elif counter_type == 'symbols()':
system = (
None, arguments[0], 1 if arguments[0] == 'fixed' else None)
symbols = tuple(
('string', argument) for argument in arguments[1:])
suffix = ('string', ' ')
return {
'system': system,
'negative': (('string', '-'), ('string', '')),
'prefix': ('string', ''),
'suffix': suffix,
'range': 'auto',
'pad': (0, ''),
'fallback': 'decimal',
'symbols': symbols,
'additive_symbols': (),
}
elif counter_name in self:
# Avoid circular fallbacks
if previous_types is None:
previous_types = []
elif counter_name in previous_types:
return
previous_types.append(counter_name)
counter = self[counter_name].copy()
if counter['system']:
extends, system, _ = counter['system']
else:
extends, system = None, 'symbolic'
# Handle extends
while extends:
if system in self:
extended_counter = self[system]
counter['system'] = extended_counter['system']
previous_types.append(system)
if counter['system']:
extends, system, _ = counter['system']
else:
extends, system = None, 'symbolic'
if extends and system in previous_types:
extends, system = 'extends', 'decimal'
continue
for name, value in extended_counter.items():
if counter[name] is None and value is not None:
counter[name] = value
else:
return counter
return counter
def render_value(self, counter_value, counter_name=None, counter=None,
previous_types=None):
"""Generate the counter representation.
See https://www.w3.org/TR/css-counter-styles-3/#generate-a-counter
"""
assert counter or counter_name
counter = counter or self.resolve_counter(counter_name, previous_types)
if counter is None:
if 'decimal' in self:
return self.render_value(counter_value, 'decimal')
else:
# Could happen if the UA stylesheet is not used
return ''
if counter['system']:
extends, system, fixed_number = counter['system']
else:
extends, system, fixed_number = None, 'symbolic', None
# Avoid circular fallbacks
if previous_types is None:
previous_types = []
elif system in previous_types:
return self.render_value(counter_value, 'decimal')
previous_types.append(counter_name)
# Handle extends
while extends:
if system in self:
extended_counter = self[system]
counter['system'] = extended_counter['system']
if counter['system']:
extends, system, fixed_number = counter['system']
else:
extends, system, fixed_number = None, 'symbolic', None
if system in previous_types:
return self.render_value(counter_value, 'decimal')
previous_types.append(system)
for name, value in extended_counter.items():
if counter[name] is None and value is not None:
counter[name] = value
else:
return self.render_value(counter_value, 'decimal')
# Step 2
if counter['range'] in ('auto', None):
min_range, max_range = -inf, inf
if system in ('alphabetic', 'symbolic'):
min_range = 1
elif system == 'additive':
min_range = 0
counter_ranges = ((min_range, max_range),)
else:
counter_ranges = counter['range']
for min_range, max_range in counter_ranges:
if min_range <= counter_value <= max_range:
break
else:
return self.render_value(
counter_value, counter['fallback'] or 'decimal',
previous_types=previous_types)
# Step 3
initial = None
is_negative = counter_value < 0
if is_negative:
negative_prefix, negative_suffix = (
symbol(character) for character
in counter['negative'] or (('string', '-'), ('string', '')))
use_negative = (
system in
('symbolic', 'alphabetic', 'numeric', 'additive'))
if use_negative:
counter_value = abs(counter_value)
# TODO: instead of using the decimal fallback when we have the wrong
# number of symbols, we should discard the whole counter. The problem
# only happens when extending from another style, it is easily refused
# during validation otherwise.
if system == 'cyclic':
length = len(counter['symbols'])
if length < 1:
return self.render_value(counter_value, 'decimal')
index = (counter_value - 1) % length
initial = symbol(counter['symbols'][index])
elif system == 'fixed':
length = len(counter['symbols'])
if length < 1:
return self.render_value(counter_value, 'decimal')
index = counter_value - fixed_number
if 0 <= index < length:
initial = symbol(counter['symbols'][index])
else:
return self.render_value(
counter_value, counter['fallback'] or 'decimal',
previous_types=previous_types)
elif system == 'symbolic':
length = len(counter['symbols'])
if length < 1:
return self.render_value(counter_value, 'decimal')
index = (counter_value - 1) % length
repeat = (counter_value - 1) // length + 1
initial = symbol(counter['symbols'][index]) * repeat
elif system == 'alphabetic':
length = len(counter['symbols'])
if length < 2:
return self.render_value(counter_value, 'decimal')
reversed_parts = []
while counter_value != 0:
counter_value -= 1
reversed_parts.append(symbol(
counter['symbols'][counter_value % length]))
counter_value //= length
initial = ''.join(reversed(reversed_parts))
elif system == 'numeric':
if counter_value == 0:
initial = symbol(counter['symbols'][0])
else:
reversed_parts = []
length = len(counter['symbols'])
if length < 2:
return self.render_value(counter_value, 'decimal')
counter_value = abs(counter_value)
while counter_value != 0:
reversed_parts.append(symbol(
counter['symbols'][counter_value % length]))
counter_value //= length
initial = ''.join(reversed(reversed_parts))
elif system == 'additive':
if counter_value == 0:
for weight, symbol_string in counter['additive_symbols']:
if weight == 0:
initial = symbol(symbol_string)
else:
parts = []
if len(counter['additive_symbols']) < 1:
return self.render_value(counter_value, 'decimal')
for weight, symbol_string in counter['additive_symbols']:
repetitions = counter_value // weight
parts.extend([symbol(symbol_string)] * repetitions)
counter_value -= weight * repetitions
if counter_value == 0:
initial = ''.join(parts)
break
if initial is None:
return self.render_value(
counter_value, counter['fallback'] or 'decimal',
previous_types=previous_types)
assert initial is not None
# Step 4
pad = counter['pad'] or (0, '')
pad_difference = pad[0] - len(initial)
if is_negative and use_negative:
pad_difference -= len(negative_prefix) + len(negative_suffix)
if pad_difference > 0:
initial = pad_difference * symbol(pad[1]) + initial
# Step 5
if is_negative and use_negative:
initial = negative_prefix + initial + negative_suffix
# Step 6
return initial
def render_marker(self, counter_name, counter_value):
"""Generate the content of a ::marker pseudo-element."""
counter = self.resolve_counter(counter_name)
if counter is None:
if 'decimal' in self:
return self.render_marker('decimal', counter_value)
else:
# Could happen if the UA stylesheet is not used
return ''
prefix = symbol(counter['prefix'] or ('string', ''))
suffix = symbol(counter['suffix'] or ('string', '. '))
value = self.render_value(counter_value, counter_name=counter_name)
assert value is not None
return prefix + value + suffix
def copy(self):
# Values are dicts but they are never modified, no need to deepcopy
return CounterStyle(super().copy())

View File

@@ -0,0 +1,192 @@
/*
Presentational hints stylsheet for HTML.
This stylesheet contains all the presentational hints rules that can be
expressed as CSS.
See https://www.w3.org/TR/html5/rendering.html#rendering
TODO: Attribute values are not case-insensitive, but they should be. We can add
a "i" flag when CSS Selectors Level 4 is supported.
*/
pre[wrap] { white-space: pre-wrap; }
br[clear=left] { clear: left; }
br[clear=right] { clear: right; }
br[clear=all], br[clear=both] { clear: both; }
ol[type="1"], li[type="1"] { list-style-type: decimal; }
ol[type=a], li[type=a] { list-style-type: lower-alpha; }
ol[type=A], li[type=A] { list-style-type: upper-alpha; }
ol[type=i], li[type=i] { list-style-type: lower-roman; }
ol[type=I], li[type=I] { list-style-type: upper-roman; }
ul[type=disc], li[type=disc] { list-style-type: disc; }
ul[type=circle], li[type=circle] { list-style-type: circle; }
ul[type=square], li[type=square] { list-style-type: square; }
table[align=left] { float: left; }
table[align=right] { float: right; }
table[align=center] { margin-left: auto; margin-right: auto; }
thead[align=absmiddle], tbody[align=absmiddle], tfoot[align=absmiddle],
tr[align=absmiddle], td[align=absmiddle], th[align=absmiddle] {
text-align: center;
}
caption[align=bottom] { caption-side: bottom; }
p[align=left], h1[align=left], h2[align=left], h3[align=left],
h4[align=left], h5[align=left], h6[align=left] {
text-align: left;
}
p[align=right], h1[align=right], h2[align=right], h3[align=right],
h4[align=right], h5[align=right], h6[align=right] {
text-align: right;
}
p[align=center], h1[align=center], h2[align=center], h3[align=center],
h4[align=center], h5[align=center], h6[align=center] {
text-align: center;
}
p[align=justify], h1[align=justify], h2[align=justify], h3[align=justify],
h4[align=justify], h5[align=justify], h6[align=justify] {
text-align: justify;
}
thead[valign=top], tbody[valign=top], tfoot[valign=top],
tr[valign=top], td[valign=top], th[valign=top] {
vertical-align: top;
}
thead[valign=middle], tbody[valign=middle], tfoot[valign=middle],
tr[valign=middle], td[valign=middle], th[valign=middle] {
vertical-align: middle;
}
thead[valign=bottom], tbody[valign=bottom], tfoot[valign=bottom],
tr[valign=bottom], td[valign=bottom], th[valign=bottom] {
vertical-align: bottom;
}
thead[valign=baseline], tbody[valign=baseline], tfoot[valign=baseline],
tr[valign=baseline], td[valign=baseline], th[valign=baseline] {
vertical-align: baseline;
}
td[nowrap], th[nowrap] { white-space: nowrap; }
table[rules=none], table[rules=groups], table[rules=rows],
table[rules=cols], table[rules=all] {
border-style: hidden;
border-collapse: collapse;
}
table[border]:not([border="0"]) { border-style: outset; }
table[frame=void] { border-style: hidden; }
table[frame=above] { border-style: outset hidden hidden hidden; }
table[frame=below] { border-style: hidden hidden outset hidden; }
table[frame=hsides] { border-style: outset hidden outset hidden; }
table[frame=lhs] { border-style: hidden hidden hidden outset; }
table[frame=rhs] { border-style: hidden outset hidden hidden; }
table[frame=vsides] { border-style: hidden outset; }
table[frame=box], table[frame=border] { border-style: outset; }
table[border]:not([border="0"]) > tr > td, table[border]:not([border="0"]) > tr > th,
table[border]:not([border="0"]) > thead > tr > td, table[border]:not([border="0"]) > thead > tr > th,
table[border]:not([border="0"]) > tbody > tr > td, table[border]:not([border="0"]) > tbody > tr > th,
table[border]:not([border="0"]) > tfoot > tr > td, table[border]:not([border="0"]) > tfoot > tr > th {
border-width: 1px;
border-style: inset;
}
table[rules=none] > tr > td, table[rules=none] > tr > th,
table[rules=none] > thead > tr > td, table[rules=none] > thead > tr > th,
table[rules=none] > tbody > tr > td, table[rules=none] > tbody > tr > th,
table[rules=none] > tfoot > tr > td, table[rules=none] > tfoot > tr > th,
table[rules=groups] > tr > td, table[rules=groups] > tr > th,
table[rules=groups] > thead > tr > td, table[rules=groups] > thead > tr > th,
table[rules=groups] > tbody > tr > td, table[rules=groups] > tbody > tr > th,
table[rules=groups] > tfoot > tr > td, table[rules=groups] > tfoot > tr > th,
table[rules=rows] > tr > td, table[rules=rows] > tr > th,
table[rules=rows] > thead > tr > td, table[rules=rows] > thead > tr > th,
table[rules=rows] > tbody > tr > td, table[rules=rows] > tbody > tr > th,
table[rules=rows] > tfoot > tr > td, table[rules=rows] > tfoot > tr > th {
border-width: 1px;
border-style: none;
}
table[rules=cols] > tr > td, table[rules=cols] > tr > th,
table[rules=cols] > thead > tr > td, table[rules=cols] > thead > tr > th,
table[rules=cols] > tbody > tr > td, table[rules=cols] > tbody > tr > th,
table[rules=cols] > tfoot > tr > td, table[rules=cols] > tfoot > tr > th {
border-width: 1px;
border-style: none solid;
}
table[rules=all] > tr > td, table[rules=all] > tr > th,
table[rules=all] > thead > tr > td, table[rules=all] > thead > tr > th,
table[rules=all] > tbody > tr > td, table[rules=all] > tbody > tr > th,
table[rules=all] > tfoot > tr > td, table[rules=all] > tfoot > tr > th {
border-width: 1px;
border-style: solid;
}
table[rules=groups] > colgroup {
border-left-width: 1px;
border-left-style: solid;
border-right-width: 1px;
border-right-style: solid;
}
table[rules=groups] > thead,
table[rules=groups] > tbody,
table[rules=groups] > tfoot {
border-top-width: 1px;
border-top-style: solid;
border-bottom-width: 1px;
border-bottom-style: solid;
}
table[rules=rows] > tr, table[rules=rows] > thead > tr,
table[rules=rows] > tbody > tr, table[rules=rows] > tfoot > tr {
border-top-width: 1px;
border-top-style: solid;
border-bottom-width: 1px;
border-bottom-style: solid;
}
hr[align=left] { margin-left: 0; margin-right: auto; }
hr[align=right] { margin-left: auto; margin-right: 0; }
hr[align=center] { margin-left: auto; margin-right: auto; }
hr[color], hr[noshade] { border-style: solid; }
iframe[frameborder="0"], iframe[frameborder=no] { border: none; }
applet[align=left], embed[align=left], iframe[align=left],
img[align=left], input[type=image][align=left], object[align=left] {
float: left;
}
applet[align=right], embed[align=right], iframe[align=right],
img[align=right], input[type=image][align=right], object[align=right] {
float: right;
}
applet[align=top], embed[align=top], iframe[align=top],
img[align=top], input[type=image][align=top], object[align=top] {
vertical-align: top;
}
applet[align=baseline], embed[align=baseline], iframe[align=baseline],
img[align=baseline], input[type=image][align=baseline], object[align=baseline] {
vertical-align: baseline;
}
applet[align=texttop], embed[align=texttop], iframe[align=texttop],
img[align=texttop], input[type=image][align=texttop], object[align=texttop] {
vertical-align: text-top;
}
applet[align=absmiddle], embed[align=absmiddle], iframe[align=absmiddle],
img[align=absmiddle], input[type=image][align=absmiddle], object[align=absmiddle],
applet[align=abscenter], embed[align=abscenter], iframe[align=abscenter],
img[align=abscenter], input[type=image][align=abscenter], object[align=abscenter] {
vertical-align: middle;
}
applet[align=bottom], embed[align=bottom], iframe[align=bottom],
img[align=bottom], input[type=image][align=bottom],
object[align=bottom] {
vertical-align: bottom;
}

View File

@@ -0,0 +1,761 @@
/*
User agent stylsheet for HTML.
Contributed by Peter Moulder.
Based on suggested styles in the HTML5 specification, CSS 2.1, and
what various web browsers use.
*/
/* https://www.w3.org/TR/html5/Overview#scroll-to-the-fragment-identifier */
*[id] { -weasy-anchor: attr(id); }
a[name] { -weasy-anchor: attr(name); }
*[dir] { /* unicode-bidi: embed; */ }
*[hidden] { display: none; }
*[dir=ltr] { direction: ltr; }
*[dir=rtl] { direction: rtl; }
/* :dir(ltr) { direction: ltr; } */
/* :dir(rtl) { direction: rtl; } */
*[lang] { -weasy-lang: attr(lang); }
:link { color: #0000EE; text-decoration: underline; }
a[href] { -weasy-link: attr(href); }
:visited { color: #551A8B; text-decoration: underline; }
a:link[rel~=help] { cursor: help; }
a:visited[rel~=help] { cursor: help; }
abbr[title] { text-decoration: dotted underline; }
acronym[title] { text-decoration: dotted underline; }
address { display: block; font-style: italic; /* unicode-bidi: isolate; */ }
area { display: none; }
area:link[rel~=help] { cursor: help; }
area:visited[rel~=help] { cursor: help; }
article { display: block; /* unicode-bidi: isolate; */ }
aside { display: block; /* unicode-bidi: isolate; */ }
b { font-weight: bold; }
base { display: none; }
basefont { display: none; }
bdi { /* unicode-bidi: isolate; */ }
bdi[dir] { /* unicode-bidi: isolate; */ }
bdo { /* unicode-bidi: bidi-override; */ }
bdo[dir] { /* unicode-bidi: bidi-override; */ }
big { font-size: larger; }
blink { text-decoration: blink; }
blockquote { display: block; margin: 1em 40px; /* unicode-bidi: isolate; */ }
body { display: block; margin: 8px; }
br::before { content: '\A'; white-space: pre-line; }
wbr::before { content: '\200B'; }
caption { display: table-caption; /* unicode-bidi: isolate; */ }
center { display: block; text-align: center; /* unicode-bidi: isolate; */ }
cite { font-style: italic; }
code { font-family: monospace; }
col { display: table-column; /* unicode-bidi: isolate; */ }
col[hidden] { display: table-column; /* unicode-bidi: isolate; */ visibility: collapse; }
colgroup { display: table-column-group; /* unicode-bidi: isolate; */ }
colgroup[hidden] { display: table-column-group; /* unicode-bidi: isolate; */ visibility: collapse; }
command { display: none; }
datalist { display: none; }
dd { display: block; margin-left: 40px; /* unicode-bidi: isolate; */ }
*[dir=ltr] dd { margin-left: 0; margin-right: 40px; }
*[dir=rtl] dd { margin-left: 40px; margin-right: 0; }
*[dir] *[dir=ltr] dd { margin-left: 0; margin-right: 40px; }
*[dir] *[dir=rtl] dd { margin-left: 40px; margin-right: 0; }
*[dir] *[dir] *[dir=ltr] dd { margin-left: 0; margin-right: 40px; }
*[dir] *[dir] *[dir=rtl] dd { margin-left: 40px; margin-right: 0; }
dd[dir=ltr][dir][dir] { margin-left: 0; margin-right: 40px; }
dd[dir=rtl][dir][dir] { margin-left: 40px; margin-right: 0; }
details { display: block; /* unicode-bidi: isolate; */ }
del { text-decoration: line-through; }
dfn { font-style: italic; }
dir { display: block; list-style-type: disc; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; /* unicode-bidi: isolate; */ }
*[dir=rtl] dir { padding-left: 0; padding-right: 40px; }
*[dir=ltr] dir { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=rtl] dir { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=ltr] dir { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=rtl] dir { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=ltr] dir { padding-left: 40px; padding-right: 0; }
dir[dir=rtl][dir][dir] { padding-left: 0; padding-right: 40px; }
dir[dir=ltr][dir][dir] { padding-left: 40px; padding-right: 0; }
dir dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
menu dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ol dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul dir { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
div { display: block; /* unicode-bidi: isolate; */ }
dl { display: block; margin-bottom: 1em; margin-top: 1em; /* unicode-bidi: isolate; */ }
dir dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl dl { margin-bottom: 0; margin-top: 0; }
ol dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dir dir dl { list-style-type: square; }
dir menu dl { list-style-type: square; }
dir ol dl { list-style-type: square; }
dir ul dl { list-style-type: square; }
menu dir dl { list-style-type: square; }
menu dl { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
menu menu dl { list-style-type: square; }
menu ol dl { list-style-type: square; }
menu ul dl { list-style-type: square; }
ol dir dl { list-style-type: square; }
ol menu dl { list-style-type: square; }
ol ol dl { list-style-type: square; }
ol ul dl { list-style-type: square; }
ul dir dl { list-style-type: square; }
ul menu dl { list-style-type: square; }
ul ol dl { list-style-type: square; }
ul ul dl { list-style-type: square; }
ol, ul { counter-reset: list-item }
dt { display: block; /* unicode-bidi: isolate; */ }
em { font-style: italic; }
fieldset { display: block; border-style: groove; border-width: 2px; margin-left: 2px; margin-right: 2px; padding: .35em .625em .75em .625em; }
figcaption { display: block; /* unicode-bidi: isolate; */ }
figure { display: block; margin: 1em 40px; /* unicode-bidi: isolate; */ }
footer { display: block; /* unicode-bidi: isolate; */ }
form { display: block; /* unicode-bidi: isolate; */ }
button,
input,
select,
textarea {
border: 1px solid black;
display: inline-block;
font-size: 0.85em;
height: 1.2em;
padding: 0.2em;
white-space: pre;
width: 20em;
}
input[type="button"],
input[type="reset"],
input[type="submit"],
button {
background: lightgrey;
border-radius: 0.25em;
text-align: center;
}
input[type="button"][value],
input[type="reset"][value],
input[type="submit"][value],
button[value] {
max-width: 100%;
width: auto;
}
input[type="submit"]:not([value])::before {
content: "Submit";
}
input[type="reset"]:not([value])::before {
content: "Reset";
}
input[type="checkbox"],
input[type="radio"] {
height: 1.2em;
width: 1.2em;
}
input[type="checkbox"][checked]:before,
input[type="radio"][checked]:before {
background: black;
content: "";
display: block;
height: 100%;
}
input[type="radio"][checked]:before {
border-radius: 50%;
}
input[type="hidden"] {
display: none;
}
input[type="radio"] {
border-radius: 50%;
margin: 0.2em 0.2em 0 0.4em;
}
input[value]::before {
content: attr(value);
display: block;
overflow: hidden;
}
input::before,
input[value=""]::before {
content: " ";
}
select {
background: lightgrey;
border-radius: 0.25em 0.25em;
position: relative;
white-space: normal;
}
select[multiple] {
height: 3.6em;
}
select:not([multiple])::before {
content: "˅";
position: absolute;
right: 0;
text-align: center;
width: 1.5em;
}
select option {
padding-right: 1.5em;
white-space: nowrap;
}
select:not([multiple]) option {
display: none;
}
select[multiple] option,
select:not(:has(option[selected])) option:first-of-type,
select option[selected]:not(option[selected] ~ option[selected]) {
display: block;
overflow: hidden;
}
textarea {
height: 3em;
margin: 0.1em 0;
overflow: hidden;
overflow-wrap: break-word;
padding: 0.2em;
white-space: pre-wrap;
}
textarea:empty {
height: 3em;
}
frame { display: block; }
frameset { display: block; }
h1 { display: block; font-size: 2em; font-weight: bold; hyphens: manual; margin-bottom: .67em; margin-top: .67em; page-break-after: avoid; page-break-inside: avoid; /* unicode-bidi: isolate; */ bookmark-level: 1; bookmark-label: content(text); }
section h1 { font-size: 1.50em; margin-bottom: .83em; margin-top: .83em; }
section section h1 { font-size: 1.17em; margin-bottom: 1.00em; margin-top: 1.00em; }
section section section h1 { font-size: 1.00em; margin-bottom: 1.33em; margin-top: 1.33em; }
section section section section h1 { font-size: .83em; margin-bottom: 1.67em; margin-top: 1.67em; }
section section section section section h1 { font-size: .67em; margin-bottom: 2.33em; margin-top: 2.33em; }
h2 { display: block; font-size: 1.50em; font-weight: bold; hyphens: manual; margin-bottom: .83em; margin-top: .83em; page-break-after: avoid; page-break-inside: avoid; /* unicode-bidi: isolate; */ bookmark-level: 2; bookmark-label: content(text); }
h3 { display: block; font-size: 1.17em; font-weight: bold; hyphens: manual; margin-bottom: 1.00em; margin-top: 1.00em; page-break-after: avoid; page-break-inside: avoid; /* unicode-bidi: isolate; */ bookmark-level: 3; bookmark-label: content(text); }
h4 { display: block; font-size: 1.00em; font-weight: bold; hyphens: manual; margin-bottom: 1.33em; margin-top: 1.33em; page-break-after: avoid; page-break-inside: avoid; /* unicode-bidi: isolate; */ bookmark-level: 4; bookmark-label: content(text); }
h5 { display: block; font-size: .83em; font-weight: bold; hyphens: manual; margin-bottom: 1.67em; margin-top: 1.67em; page-break-after: avoid; /* unicode-bidi: isolate; */ bookmark-level: 5; bookmark-label: content(text); }
h6 { display: block; font-size: .67em; font-weight: bold; hyphens: manual; margin-bottom: 2.33em; margin-top: 2.33em; page-break-after: avoid; /* unicode-bidi: isolate; */ bookmark-level: 6; bookmark-label: content(text); }
head { display: none; }
header { display: block; /* unicode-bidi: isolate; */ }
hgroup { display: block; /* unicode-bidi: isolate; */ }
hr { border-style: inset; border-width: 1px; color: gray; display: block; margin-bottom: .5em; margin-left: auto; margin-right: auto; margin-top: .5em; /* unicode-bidi: isolate; */ }
html { display: block; }
i { font-style: italic; }
*[dir=auto] { /* unicode-bidi: isolate; */ }
bdo[dir=auto] { /* unicode-bidi: bidi-override isolate; */ }
input[type=hidden] { display: none; }
menu[type=context] { display: none; }
pre[dir=auto] { /* unicode-bidi: plaintext; */ }
table[frame=above] { border-color: black; }
table[frame=below] { border-color: black; }
table[frame=border] { border-color: black; }
table[frame=box] { border-color: black; }
table[frame=hsides] { border-color: black; }
table[frame=lhs] { border-color: black; }
table[frame=rhs] { border-color: black; }
table[frame=void] { border-color: black; }
table[frame=vsides] { border-color: black; }
table[rules=all] { border-color: black; }
table[rules=cols] { border-color: black; }
table[rules=groups] { border-color: black; }
table[rules=none] { border-color: black; }
table[rules=rows] { border-color: black; }
textarea[dir=auto] { /* unicode-bidi: plaintext; */ }
iframe { border: 2px inset; }
iframe[seamless] { border: none; }
input { display: inline-block; text-indent: 0; }
ins { text-decoration: underline; }
kbd { font-family: monospace; }
keygen { display: inline-block; text-indent: 0; }
legend { display: block; /* unicode-bidi: isolate; */ }
li { display: list-item; /* unicode-bidi: isolate; */ }
link { display: none; }
listing { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; /* unicode-bidi: isolate; */ white-space: pre; }
mark { background: yellow; color: black; }
main { display: block; /* unicode-bidi: isolate; */ }
menu { display: block; list-style-type: disc; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; /* unicode-bidi: isolate; */ }
*[dir=rtl] menu { padding-left: 0; padding-right: 40px; }
*[dir=ltr] menu { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=rtl] menu { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=ltr] menu { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=rtl] menu { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=ltr] menu { padding-left: 40px; padding-right: 0; }
menu[dir=rtl][dir][dir] { padding-left: 0; padding-right: 40px; }
menu[dir=ltr][dir][dir] { padding-left: 40px; padding-right: 0; }
dir menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl menu { margin-bottom: 0; margin-top: 0; }
menu menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dir dir menu { list-style-type: square; }
dir menu menu { list-style-type: square; }
dir ol menu { list-style-type: square; }
dir ul menu { list-style-type: square; }
menu dir menu { list-style-type: square; }
menu menu menu { list-style-type: square; }
menu ol menu { list-style-type: square; }
menu ul menu { list-style-type: square; }
ol menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ol dir menu { list-style-type: square; }
ol menu menu { list-style-type: square; }
ol ol menu { list-style-type: square; }
ol ul menu { list-style-type: square; }
ul dir menu { list-style-type: square; }
ul menu menu { list-style-type: square; }
ul menu { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul ol menu { list-style-type: square; }
ul ul menu { list-style-type: square; }
meta { display: none; }
nav { display: block; /* unicode-bidi: isolate; */ }
nobr { white-space: nowrap; }
noembed { display: none; }
/* The HTML5 spec suggests display:none for the old (now forbidden) noframes element,
* but Morp doesn't currently handle frames, so we might as well render it.
*/
/*noframes { display: none; }*/
noframes { display: block; }
ol { page-break-before: avoid; }
ol { display: block; list-style-type: decimal; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; /* unicode-bidi: isolate; */ }
*[dir=ltr] ol { padding-left: 0; padding-right: 40px; }
*[dir=rtl] ol { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=ltr] ol { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=rtl] ol { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=ltr] ol { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=rtl] ol { padding-left: 40px; padding-right: 0; }
ol[dir=ltr][dir][dir] { padding-left: 0; padding-right: 40px; }
ol[dir=rtl][dir][dir] { padding-left: 40px; padding-right: 0; }
dir ol { margin-bottom: 0; margin-top: 0; }
dl ol { margin-bottom: 0; margin-top: 0; }
menu ol { margin-bottom: 0; margin-top: 0; }
ol ol { margin-bottom: 0; margin-top: 0; }
ul ol { margin-bottom: 0; margin-top: 0; }
optgroup { text-indent: 0; }
output { /* unicode-bidi: isolate; */ }
output[dir] { /* unicode-bidi: isolate; */ }
p { display: block; margin-bottom: 1em; margin-top: 1em; /* unicode-bidi: isolate; */ }
param { display: none; }
plaintext { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; /* unicode-bidi: isolate; */ white-space: pre; }
pre { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; /* unicode-bidi: isolate; */ white-space: pre; }
q::after { content: close-quote; }
q::before { content: open-quote; }
rp { display: none; }
/* rt { display: ruby-text; } */
/* ruby { display: ruby; } */
s { text-decoration: line-through; }
samp { font-family: monospace; }
script { display: none; }
section { display: block; /* unicode-bidi: isolate; */ }
small { font-size: smaller; }
source { display: none; }
strike { text-decoration: line-through; }
strong { font-weight: bolder; }
style { display: none; }
sub { font-size: smaller; line-height: normal; vertical-align: sub; }
summary { display: block; /* unicode-bidi: isolate; */ }
sup { font-size: smaller; line-height: normal; vertical-align: super; }
img, svg { overflow: hidden; }
table { border-collapse: separate; border-color: gray; border-spacing: 2px; display: table; text-indent: 0; /* unicode-bidi: isolate; */ }
/* The html5 spec doesn't mention the following, though the CSS 2.1 spec does
* hint at its use, and a couple of UAs do have this. I haven't looked into
* why the HTML5 spec doesn't include this rule.
*/
table { box-sizing: border-box; }
tbody { border-color: inherit; display: table-row-group; /* unicode-bidi: isolate; */ vertical-align: middle; }
tbody[hidden] { display: table-row-group; /* unicode-bidi: isolate; */ visibility: collapse; }
td { border-color: gray; display: table-cell; padding: 1px; /* unicode-bidi: isolate; */ vertical-align: inherit; }
td[hidden] { display: table-cell; /* unicode-bidi: isolate; */ visibility: collapse; }
textarea { display: inline-block; text-indent: 0; white-space: pre-wrap; }
tfoot { border-color: inherit; display: table-footer-group; /* unicode-bidi: isolate; */ vertical-align: middle; }
tfoot[hidden] { display: table-footer-group; /* unicode-bidi: isolate; */ visibility: collapse; }
table[rules=none] > tr > td, table[rules=none] > tr > th, table[rules=groups] > tr > td, table[rules=groups] > tr > th, table[rules=rows] > tr > td, table[rules=rows] > tr > th, table[rules=cols] > tr > td, table[rules=cols] > tr > th, table[rules=all] > tr > td, table[rules=all] > tr > th, table[rules=none] > thead > tr > td, table[rules=none] > thead > tr > th, table[rules=groups] > thead > tr > td, table[rules=groups] > thead > tr > th, table[rules=rows] > thead > tr > td, table[rules=rows] > thead > tr > th, table[rules=cols] > thead > tr > td, table[rules=cols] > thead > tr > th, table[rules=all] > thead > tr > td, table[rules=all] > thead > tr > th, table[rules=none] > tbody > tr > td, table[rules=none] > tbody > tr > th, table[rules=groups] > tbody > tr > td, table[rules=groups] > tbody > tr > th, table[rules=rows] > tbody > tr > td, table[rules=rows] > tbody > tr > th, table[rules=cols] > tbody > tr > td, table[rules=cols] > tbody > tr > th, table[rules=all] > tbody > tr > td, table[rules=all] > tbody > tr > th, table[rules=none] > tfoot > tr > td, table[rules=none] > tfoot > tr > th, table[rules=groups] > tfoot > tr > td, table[rules=groups] > tfoot > tr > th, table[rules=rows] > tfoot > tr > td, table[rules=rows] > tfoot > tr > th, table[rules=cols] > tfoot > tr > td, table[rules=cols] > tfoot > tr > th, table[rules=all] > tfoot > tr > td, table[rules=all] > tfoot > tr > th { border-color: black; }
th { border-color: gray; display: table-cell; font-weight: bold; padding: 1px; /* unicode-bidi: isolate; */ vertical-align: inherit; }
th[hidden] { display: table-cell; /* unicode-bidi: isolate; */ visibility: collapse; }
thead { border-color: inherit; display: table-header-group; /* unicode-bidi: isolate; */ vertical-align: middle; }
thead[hidden] { display: table-header-group; /* unicode-bidi: isolate; */ visibility: collapse; }
table > tr { vertical-align: middle; }
tr { border-color: inherit; display: table-row; /* unicode-bidi: isolate; */ vertical-align: inherit; }
tr[hidden] { display: table-row; /* unicode-bidi: isolate; */ visibility: collapse; }
template { display: none; }
title { display: none; }
track { display: none; }
tt { font-family: monospace; }
u { text-decoration: underline; }
::marker { /* unicode-bidi: isolate; */ font-variant-numeric: tabular-nums; }
ul { display: block; list-style-type: disc; margin-bottom: 1em; margin-top: 1em; padding-left: 40px; /* unicode-bidi: isolate; */ }
*[dir=ltr] ul { padding-left: 40px; padding-right: 0; }
*[dir=rtl] ul { padding-left: 0; padding-right: 40px; }
*[dir] *[dir=ltr] ul { padding-left: 40px; padding-right: 0; }
*[dir] *[dir=rtl] ul { padding-left: 0; padding-right: 40px; }
*[dir] *[dir] *[dir=ltr] ul { padding-left: 40px; padding-right: 0; }
*[dir] *[dir] *[dir=rtl] ul { padding-left: 0; padding-right: 40px; }
ul[dir=ltr][dir][dir] { padding-left: 40px; padding-right: 0; }
ul[dir=rtl][dir][dir] { padding-left: 0; padding-right: 40px; }
/* This isn't in the HTML5 spec's suggested styling, and should probably be a
* mere hint rather than a demand. It usually is the right thing, though.
*/
ul { display: block; page-break-before: avoid; }
dir ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dl ul { margin-bottom: 0; margin-top: 0; }
menu ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ol ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
ul ul { list-style-type: circle; margin-bottom: 0; margin-top: 0; }
dir dir ul { list-style-type: square; }
dir menu ul { list-style-type: square; }
dir ol ul { list-style-type: square; }
dir ul ul { list-style-type: square; }
menu dir ul { list-style-type: square; }
menu menu ul { list-style-type: square; }
menu ol ul { list-style-type: square; }
menu ul ul { list-style-type: square; }
ol dir ul { list-style-type: square; }
ol menu ul { list-style-type: square; }
ol ol ul { list-style-type: square; }
ol ul ul { list-style-type: square; }
ul dir ul { list-style-type: square; }
ul menu ul { list-style-type: square; }
ul ol ul { list-style-type: square; }
ul ul ul { list-style-type: square; }
var { font-style: italic; }
video { object-fit: contain; }
xmp { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; /* unicode-bidi: isolate; */ white-space: pre; }
::footnote-call { content: counter(footnote); vertical-align: super; font-size: smaller; line-height: inherit; }
::footnote-marker { content: counter(footnote) '. '; }
@page {
/* `size: auto` (the initial) is A4 portrait */
margin: 75px;
@footnote { margin-top: 1em }
@top-left-corner { text-align: right; vertical-align: middle }
@top-left { text-align: left; vertical-align: middle }
@top-center { text-align: center; vertical-align: middle }
@top-right { text-align: right; vertical-align: middle }
@top-right-corner { text-align: left; vertical-align: middle }
@left-top { text-align: center; vertical-align: top }
@left-middle { text-align: center; vertical-align: middle }
@left-bottom { text-align: center; vertical-align: bottom }
@right-top { text-align: center; vertical-align: top }
@right-middle { text-align: center; vertical-align: middle }
@right-bottom { text-align: center; vertical-align: bottom }
@bottom-left-corner { text-align: right; vertical-align: middle }
@bottom-left { text-align: left; vertical-align: middle }
@bottom-center { text-align: center; vertical-align: middle }
@bottom-right { text-align: right; vertical-align: middle }
@bottom-right-corner { text-align: left; vertical-align: middle }
}
/* Counters: https://www.w3.org/TR/css-counter-styles-3/#predefined-counters */
@counter-style disc {
system: cyclic;
symbols: ;
suffix: " ";
}
@counter-style circle {
system: cyclic;
symbols: ;
suffix: " ";
}
@counter-style square {
system: cyclic;
symbols: ;
suffix: " ";
}
@counter-style disclosure-open {
system: cyclic;
symbols: ;
suffix: " ";
}
@counter-style disclosure-closed {
system: cyclic;
/* TODO: handle rtl */
symbols: ;
suffix: " ";
}
@counter-style decimal {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
}
@counter-style decimal-leading-zero {
system: extends decimal;
pad: 2 '0';
}
@counter-style arabic-indic {
system: numeric;
symbols: ٠ ١ ٢ ٣ ٤ ٥ ٦ ٧ ٨ ٩;
}
@counter-style armenian {
system: additive;
range: 1 9999;
additive-symbols: 9000 Ք, 8000 Փ, 7000 Ւ, 6000 Ց, 5000 Ր, 4000 Տ, 3000 Վ, 2000 Ս, 1000 Ռ, 900 Ջ, 800 Պ, 700 Չ, 600 Ո, 500 Շ, 400 Ն, 300 Յ, 200 Մ, 100 Ճ, 90 Ղ, 80 Ձ, 70 Հ, 60 Կ, 50 Ծ, 40 Խ, 30 Լ, 20 Ի, 10 Ժ, 9 Թ, 8 Ը, 7 Է, 6 Զ, 5 Ե, 4 Դ, 3 Գ, 2 Բ, 1 Ա;
}
@counter-style upper-armenian {
system: extends armenian;
}
@counter-style lower-armenian {
system: additive;
range: 1 9999;
additive-symbols: 9000 ք, 8000 փ, 7000 ւ, 6000 ց, 5000 ր, 4000 տ, 3000 վ, 2000 ս, 1000 ռ, 900 ջ, 800 պ, 700 չ, 600 ո, 500 շ, 400 ն, 300 յ, 200 մ, 100 ճ, 90 ղ, 80 ձ, 70 հ, 60 կ, 50 ծ, 40 խ, 30 լ, 20 ի, 10 ժ, 9 թ, 8 ը, 7 է, 6 զ, 5 ե, 4 դ, 3 գ, 2 բ, 1 ա;
}
@counter-style bengali {
system: numeric;
symbols: ;
}
@counter-style cambodian {
system: numeric;
symbols: ;
}
@counter-style khmer {
system: extends cambodian;
}
@counter-style cjk-decimal {
system: numeric;
range: 0 infinite;
symbols: ;
suffix: "、";
}
@counter-style devanagari {
system: numeric;
symbols: ;
}
@counter-style georgian {
system: additive;
range: 1 19999;
additive-symbols: 10000 , 9000 , 8000 , 7000 , 6000 , 5000 , 4000 , 3000 , 2000 , 1000 , 900 , 800 , 700 , 600 , 500 , 400 , 300 , 200 , 100 , 90 , 80 , 70 , 60 , 50 , 40 , 30 , 20 , 10 , 9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 ;
}
@counter-style gujarati {
system: numeric;
symbols: ;
}
@counter-style gurmukhi {
system: numeric;
symbols: ;
}
@counter-style hebrew {
system: additive;
range: 1 10999;
additive-symbols: 10000 י׳, 9000 ט׳, 8000 ח׳, 7000 ז׳, 6000 ו׳, 5000 ה׳, 4000 ד׳, 3000 ג׳, 2000 ב׳, 1000 א׳, 400 ת, 300 ש, 200 ר, 100 ק, 90 צ, 80 פ, 70 ע, 60 ס, 50 נ, 40 מ, 30 ל, 20 כ, 19 יט, 18 יח, 17 יז, 16 טז, 15 טו, 10 י, 9 ט, 8 ח, 7 ז, 6 ו, 5 ה, 4 ד, 3 ג, 2 ב, 1 א;
}
@counter-style kannada {
system: numeric;
symbols: ;
}
@counter-style lao {
system: numeric;
symbols: ;
}
@counter-style malayalam {
system: numeric;
symbols: ;
}
@counter-style mongolian {
system: numeric;
symbols: ;
}
@counter-style myanmar {
system: numeric;
symbols: ;
}
@counter-style oriya {
system: numeric;
symbols: ;
}
@counter-style persian {
system: numeric;
symbols: ۰ ۱ ۲ ۳ ۴ ۵ ۶ ۷ ۸ ۹;
}
@counter-style lower-roman {
system: additive;
range: 1 3999;
additive-symbols: 1000 m, 900 cm, 500 d, 400 cd, 100 c, 90 xc, 50 l, 40 xl, 10 x, 9 ix, 5 v, 4 iv, 1 i;
}
@counter-style upper-roman {
system: additive;
range: 1 3999;
additive-symbols: 1000 M, 900 CM, 500 D, 400 CD, 100 C, 90 XC, 50 L, 40 XL, 10 X, 9 IX, 5 V, 4 IV, 1 I;
}
@counter-style tamil {
system: numeric;
symbols: ;
}
@counter-style telugu {
system: numeric;
symbols: ;
}
@counter-style thai {
system: numeric;
symbols: ;
}
@counter-style tibetan {
system: numeric;
symbols: ;
}
@counter-style lower-alpha {
system: alphabetic;
symbols: a b c d e f g h i j k l m n o p q r s t u v w x y z;
}
@counter-style lower-latin {
system: extends lower-alpha;
}
@counter-style upper-alpha {
system: alphabetic;
symbols: A B C D E F G H I J K L M N O P Q R S T U V W X Y Z;
}
@counter-style upper-latin {
system: extends upper-alpha;
}
@counter-style cjk-earthly-branch {
system: alphabetic;
symbols: ;
suffix: "、";
}
@counter-style cjk-heavenly-stem {
system: alphabetic;
symbols: ;
suffix: "、";
}
@counter-style lower-greek {
system: alphabetic;
symbols: α β γ δ ε ζ η θ ι κ λ μ ν ξ ο π ρ σ τ υ φ χ ψ ω;
}
@counter-style hiragana {
system: alphabetic;
symbols: ;
suffix: "、";
}
@counter-style hiragana-iroha {
system: alphabetic;
symbols: ;
suffix: "、";
}
@counter-style katakana {
system: alphabetic;
symbols: ;
suffix: "、";
}
@counter-style katakana-iroha {
system: alphabetic;
symbols: ;
suffix: "、";
}
@counter-style japanese-informal {
system: additive;
range: -9999 9999;
additive-symbols: 9000 九千, 8000 八千, 7000 七千, 6000 六千, 5000 五千, 4000 四千, 3000 三千, 2000 二千, 1000 , 900 九百, 800 八百, 700 七百, 600 六百, 500 五百, 400 四百, 300 三百, 200 二百, 100 , 90 九十, 80 八十, 70 七十, 60 六十, 50 五十, 40 四十, 30 三十, 20 二十, 10 , 9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 , 0 ;
suffix: ;
negative: マイナス;
fallback: cjk-decimal;
}
@counter-style japanese-formal {
system: additive;
range: -9999 9999;
additive-symbols: 9000 九阡, 8000 八阡, 7000 七阡, 6000 六阡, 5000 伍阡, 4000 四阡, 3000 参阡, 2000 弐阡, 1000 壱阡, 900 九百, 800 八百, 700 七百, 600 六百, 500 伍百, 400 四百, 300 参百, 200 弐百, 100 壱百, 90 九拾, 80 八拾, 70 七拾, 60 六拾, 50 伍拾, 40 四拾, 30 参拾, 20 弐拾, 10 壱拾, 9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 , 0 ;
suffix: ;
negative: マイナス;
fallback: cjk-decimal;
}
@counter-style korean-hangul-formal {
system: additive;
range: -9999 9999;
additive-symbols: 9000 구천, 8000 팔천, 7000 칠천, 6000 육천, 5000 오천, 4000 사천, 3000 삼천, 2000 이천, 1000 일천, 900 구백, 800 팔백, 700 칠백, 600 육백, 500 오백, 400 사백, 300 삼백, 200 이백, 100 일백, 90 구십, 80 팔십, 70 칠십, 60 육십, 50 오십, 40 사십, 30 삼십, 20 이십, 10 일십, 9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 , 0 ;
suffix: ', ';
negative: "마이너스 ";
}
@counter-style korean-hanja-informal {
system: additive;
range: -9999 9999;
additive-symbols: 9000 九千, 8000 八千, 7000 七千, 6000 六千, 5000 五千, 4000 四千, 3000 三千, 2000 二千, 1000 , 900 九百, 800 八百, 700 七百, 600 六百, 500 五百, 400 四百, 300 三百, 200 二百, 100 , 90 九十, 80 八十, 70 七十, 60 六十, 50 五十, 40 四十, 30 三十, 20 二十, 10 , 9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 , 0 ;
suffix: ', ';
negative: "마이너스 ";
}
@counter-style korean-hanja-formal {
system: additive;
range: -9999 9999;
additive-symbols: 9000 九仟, 8000 八仟, 7000 七仟, 6000 六仟, 5000 五仟, 4000 四仟, 3000 參仟, 2000 貳仟, 1000 壹仟, 900 九百, 800 八百, 700 七百, 600 六百, 500 五百, 400 四百, 300 參百, 200 貳百, 100 壹百, 90 九拾, 80 八拾, 70 七拾, 60 六拾, 50 五拾, 40 四拾, 30 參拾, 20 貳拾, 10 壹拾, 9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 , 0 ;
suffix: ', ';
negative: "마이너스 ";
}

View File

@@ -0,0 +1,5 @@
/* Default stylesheet for PDF forms */
button, input, select, textarea {
appearance: auto;
}

View File

@@ -0,0 +1,39 @@
"""Handle media queries.
https://www.w3.org/TR/mediaqueries-4/
"""
import tinycss2
from ..logger import LOGGER
from .utils import remove_whitespace, split_on_comma
def evaluate_media_query(query_list, device_media_type):
"""Return the boolean evaluation of `query_list` for the given
`device_media_type`.
:attr query_list: a cssutilts.stlysheets.MediaList
:attr device_media_type: a media type string (for now)
"""
# TODO: actual support for media queries, not just media types
return 'all' in query_list or device_media_type in query_list
def parse_media_query(tokens):
tokens = remove_whitespace(tokens)
if not tokens:
return ['all']
else:
media = []
for part in split_on_comma(tokens):
types = [token.type for token in part]
if types == ['ident']:
media.append(part[0].lower_value)
else:
LOGGER.warning(
'Expected a media type, got %r', tinycss2.serialize(part))
return
return media

View File

@@ -0,0 +1,359 @@
"""Various data about known CSS properties."""
import collections
from math import inf
from tinycss2.color3 import parse_color
Dimension = collections.namedtuple('Dimension', ['value', 'unit'])
ZERO_PIXELS = Dimension(0, 'px')
INITIAL_VALUES = {
# CSS 2.1: https://www.w3.org/TR/CSS21/propidx.html
'bottom': 'auto',
'caption_side': 'top',
'clear': 'none',
'clip': (), # computed value for 'auto'
'color': parse_color('black'), # chosen by the user agent
'direction': 'ltr',
'display': ('inline', 'flow'),
'empty_cells': 'show',
'float': 'none',
'left': 'auto',
'line_height': 'normal',
'margin_top': ZERO_PIXELS,
'margin_right': ZERO_PIXELS,
'margin_bottom': ZERO_PIXELS,
'margin_left': ZERO_PIXELS,
'padding_top': ZERO_PIXELS,
'padding_right': ZERO_PIXELS,
'padding_bottom': ZERO_PIXELS,
'padding_left': ZERO_PIXELS,
'position': 'static',
'right': 'auto',
'table_layout': 'auto',
'top': 'auto',
'unicode_bidi': 'normal',
'vertical_align': 'baseline',
'visibility': 'visible',
'z_index': 'auto',
# Backgrounds and Borders 3 (CR): https://www.w3.org/TR/css-backgrounds-3/
'background_attachment': ('scroll',),
'background_clip': ('border-box',),
'background_color': parse_color('transparent'),
'background_image': (('none', None),),
'background_origin': ('padding-box',),
'background_position': (('left', Dimension(0, '%'),
'top', Dimension(0, '%')),),
'background_repeat': (('repeat', 'repeat'),),
'background_size': (('auto', 'auto'),),
'border_bottom_color': 'currentColor',
'border_bottom_left_radius': (ZERO_PIXELS, ZERO_PIXELS),
'border_bottom_right_radius': (ZERO_PIXELS, ZERO_PIXELS),
'border_bottom_style': 'none',
'border_bottom_width': 3,
'border_collapse': 'separate',
'border_left_color': 'currentColor',
'border_left_style': 'none',
'border_left_width': 3,
'border_right_color': 'currentColor',
'border_right_style': 'none',
'border_right_width': 3,
'border_spacing': (0, 0),
'border_top_color': 'currentColor',
'border_top_left_radius': (ZERO_PIXELS, ZERO_PIXELS),
'border_top_right_radius': (ZERO_PIXELS, ZERO_PIXELS),
'border_top_style': 'none',
'border_top_width': 3, # computed value for 'medium'
'border_image_source': ('none', None),
'border_image_slice': (
Dimension(100, '%'), Dimension(100, '%'),
Dimension(100, '%'), Dimension(100, '%'),
None),
'border_image_width': (1, 1, 1, 1),
'border_image_outset': (
Dimension(0, None), Dimension(0, None),
Dimension(0, None), Dimension(0, None)),
'border_image_repeat': ('stretch', 'stretch'),
# Color 3 (REC): https://www.w3.org/TR/css-color-3/
'opacity': 1,
# Multi-column Layout (WD): https://www.w3.org/TR/css-multicol-1/
'column_width': 'auto',
'column_count': 'auto',
'column_rule_color': 'currentColor',
'column_rule_style': 'none',
'column_rule_width': 'medium',
'column_fill': 'balance',
'column_span': 'none',
# Fonts 3 (REC): https://www.w3.org/TR/css-fonts-3/
'font_family': ('serif',), # depends on user agent
'font_feature_settings': 'normal',
'font_kerning': 'auto',
'font_language_override': 'normal',
'font_size': 16, # actually medium, but we define medium from this
'font_stretch': 'normal',
'font_style': 'normal',
'font_variant': 'normal',
'font_variant_alternates': 'normal',
'font_variant_caps': 'normal',
'font_variant_east_asian': 'normal',
'font_variant_ligatures': 'normal',
'font_variant_numeric': 'normal',
'font_variant_position': 'normal',
'font_weight': 400,
# Fonts 4 (WD): https://www.w3.org/TR/css-fonts-4/
'font_variation_settings': 'normal',
# Fragmentation 3/4 (CR/WD): https://www.w3.org/TR/css-break-4/
'box_decoration_break': 'slice',
'break_after': 'auto',
'break_before': 'auto',
'break_inside': 'auto',
'margin_break': 'auto',
'orphans': 2,
'widows': 2,
# Generated Content 3 (WD): https://www.w3.org/TR/css-content-3/
'bookmark_label': (('content', 'text'),),
'bookmark_level': 'none',
'bookmark_state': 'open',
'content': 'normal',
'footnote_display': 'block',
'footnote_policy': 'auto',
'quotes': 'auto',
'string_set': 'none',
# Images 3/4 (CR/WD): https://www.w3.org/TR/css-images-4/
'image_resolution': 1, # dppx
'image_rendering': 'auto',
'image_orientation': 'from-image',
'object_fit': 'fill',
'object_position': (('left', Dimension(50, '%'),
'top', Dimension(50, '%')),),
# Paged Media 3 (WD): https://www.w3.org/TR/css-page-3/
'size': None, # set to A4 in computed_values
'page': 'auto',
'bleed_left': 'auto',
'bleed_right': 'auto',
'bleed_top': 'auto',
'bleed_bottom': 'auto',
'marks': (), # computed value for 'none'
# Text 3/4 (WD/WD): https://www.w3.org/TR/css-text-4/
'hyphenate_character': '', # computed value chosen by the user agent
'hyphenate_limit_chars': (5, 2, 2),
'hyphenate_limit_zone': ZERO_PIXELS,
'hyphens': 'manual',
'letter_spacing': 'normal',
'tab_size': 8,
'text_align_all': 'start',
'text_align_last': 'auto',
'text_indent': ZERO_PIXELS,
'text_transform': 'none',
'white_space': 'normal',
'word_break': 'normal',
'word_spacing': 0, # computed value for 'normal'
# Transforms 1 (CR): https://www.w3.org/TR/css-transforms-1/
'transform_origin': (Dimension(50, '%'), Dimension(50, '%')),
'transform': (), # computed value for 'none'
# User Interface 3/4 (REC/WD): https://www.w3.org/TR/css-ui-4/
'appearance': 'none',
'outline_color': 'currentColor', # invert is not supported
'outline_style': 'none',
'outline_width': 3, # computed value for 'medium'
# Sizing 3 (WD): https://www.w3.org/TR/css-sizing-3/
'box_sizing': 'content-box',
'height': 'auto',
'max_height': Dimension(inf, 'px'), # parsed value for 'none'
'max_width': Dimension(inf, 'px'),
'min_height': 'auto',
'min_width': 'auto',
'width': 'auto',
# Flexible Box Layout Module 1 (CR): https://www.w3.org/TR/css-flexbox-1/
'flex_basis': 'auto',
'flex_direction': 'row',
'flex_grow': 0,
'flex_shrink': 1,
'flex_wrap': 'nowrap',
# Grid Layout Module Level 2 (CR): https://www.w3.org/TR/css-grid-2/
'grid_auto_columns': ('auto',),
'grid_auto_flow': ('row',),
'grid_auto_rows': ('auto',),
'grid_template_areas': 'none',
'grid_template_columns': 'none',
'grid_template_rows': 'none',
'grid_row_start': 'auto',
'grid_column_start': 'auto',
'grid_row_end': 'auto',
'grid_column_end': 'auto',
# CSS Box Alignment Module Level 3 (WD): https://www.w3.org/TR/css-align-3/
'align_content': ('normal',),
'align_items': ('normal',),
'align_self': ('auto',),
'justify_content': ('normal',),
'justify_items': ('normal',),
'justify_self': ('auto',),
'order': 0,
'column_gap': 'normal',
'row_gap': 'normal',
# Text Decoration Module 3 (CR): https://www.w3.org/TR/css-text-decor-3/
'text_decoration_line': 'none',
'text_decoration_color': 'currentColor',
'text_decoration_style': 'solid',
# Overflow Module 3/4 (WD): https://www.w3.org/TR/css-overflow-4/
'block_ellipsis': 'none',
'continue': 'auto',
'max_lines': 'none',
'overflow': 'visible',
'overflow_wrap': 'normal',
'text_overflow': 'clip',
# Lists Module 3 (WD): https://drafts.csswg.org/css-lists-3/
# Means 'none', but allow `display: list-item` to increment the
# list-item counter. If we ever have a way for authors to query
# computed values (JavaScript?), this value should serialize to 'none'.
'counter_increment': 'auto',
'counter_reset': (), # parsed value for 'none'
'counter_set': (), # parsed value for 'none'
'list_style_image': ('none', None),
'list_style_position': 'outside',
'list_style_type': 'disc',
# Proprietary
'anchor': None, # computed value of 'none'
'link': None, # computed value of 'none'
'lang': None, # computed value of 'none'
}
KNOWN_PROPERTIES = set(name.replace('_', '-') for name in INITIAL_VALUES)
# Do not list shorthand properties here as we handle them before inheritance.
#
# Values inherited but not applicable to print are not included.
#
# text_decoration is not a really inherited, see
# https://www.w3.org/TR/CSS2/text.html#propdef-text-decoration
#
# link: click events normally bubble up to link ancestors
# See https://lists.w3.org/Archives/Public/www-style/2012Jun/0315.html
INHERITED = {
'block_ellipsis',
'border_collapse',
'border_spacing',
'caption_side',
'color',
'direction',
'empty_cells',
'font_family',
'font_feature_settings',
'font_kerning',
'font_language_override',
'font_size',
'font_style',
'font_stretch',
'font_variant',
'font_variant_alternates',
'font_variant_caps',
'font_variant_east_asian',
'font_variant_ligatures',
'font_variant_numeric',
'font_variant_position',
'font_variation_settings',
'font_weight',
'hyphens',
'hyphenate_character',
'hyphenate_limit_chars',
'hyphenate_limit_zone',
'image_rendering',
'image_resolution',
'lang',
'letter_spacing',
'line_height',
'link',
'list_style_image',
'list_style_position',
'list_style_type',
'orphans',
'overflow_wrap',
'quotes',
'tab_size',
'text_align_all',
'text_align_last',
'text_indent',
'text_transform',
'visibility',
'white_space',
'widows',
'word_break',
'word_spacing',
}
# https://www.w3.org/TR/CSS21/tables.html#model
# See also https://lists.w3.org/Archives/Public/www-style/2012Jun/0066.html
# Only non-inherited properties need to be included here.
TABLE_WRAPPER_BOX_PROPERTIES = {
'bottom',
'break_after',
'break_before',
'clear',
'counter_increment',
'counter_reset',
'counter_set',
'float',
'left',
'margin_top',
'margin_bottom',
'margin_left',
'margin_right',
'opacity',
'overflow',
'position',
'right',
'top',
'transform',
'transform_origin',
'vertical_align',
'z_index',
}
# Properties that have an initial value that is not always the same when
# computed.
INITIAL_NOT_COMPUTED = {
'display',
'column_gap',
'bleed_top',
'bleed_left',
'bleed_bottom',
'bleed_right',
'outline_width',
'outline_color',
'column_rule_width',
'column_rule_color',
'border_top_width',
'border_left_width',
'border_bottom_width',
'border_right_width',
'border_top_color',
'border_left_color',
'border_bottom_color',
'border_right_color',
}

View File

@@ -0,0 +1,225 @@
"""Handle target-counter, target-counters and target-text.
The TargetCollector is a structure providing required targets' counter_values
and stuff needed to build pending targets later, when the layout of all
targeted anchors has been done.
"""
import copy
from ..logger import LOGGER
class TargetLookupItem:
"""Item controlling pending targets and page based target counters.
Collected in the TargetCollector's ``target_lookup_items``.
"""
def __init__(self, state='pending'):
self.state = state
# Required by target-counter and target-counters to access the
# target's .cached_counter_values.
# Needed for target-text via extract_text().
self.target_box = None
# Functions that have to been called to check pending targets.
# Keys are (source_box, css_token).
self.parse_again_functions = {}
# Anchor position during pagination (page_number - 1)
self.page_maker_index = None
# target_box's page_counters during pagination
self.cached_page_counter_values = {}
class CounterLookupItem:
"""Item controlling page based counters.
Collected in the TargetCollector's ``counter_lookup_items``.
"""
def __init__(self, parse_again, missing_counters, missing_target_counters):
# Function that have to been called to check pending counter.
self.parse_again = parse_again
# Missing counters and target counters
self.missing_counters = missing_counters
self.missing_target_counters = missing_target_counters
# Box position during pagination (page_number - 1)
self.page_maker_index = None
# Marker for remake_page
self.pending = False
# Targeting box's page_counters during pagination
self.cached_page_counter_values = {}
def anchor_name_from_token(anchor_token):
"""Get anchor name from string or uri token."""
if anchor_token[0] == 'string' and anchor_token[1].startswith('#'):
return anchor_token[1][1:]
elif anchor_token[0] == 'url' and anchor_token[1][0] == 'internal':
return anchor_token[1][1]
class TargetCollector:
"""Collector of HTML targets used by CSS content with ``target-*``."""
def __init__(self):
# Lookup items for targets and page counters
self.target_lookup_items = {}
self.counter_lookup_items = {}
# When collecting is True, compute_content_list() collects missing
# page counters in CounterLookupItems. Otherwise, it mixes in the
# TargetLookupItem's cached_page_counter_values.
# Is switched to False in check_pending_targets().
self.collecting = True
# had_pending_targets is set to True when a target is needed but has
# not been seen yet. check_pending_targets then uses this information
# to call the needed parse_again functions.
self.had_pending_targets = False
def collect_anchor(self, anchor_name):
"""Create a TargetLookupItem for the given `anchor_name``."""
if isinstance(anchor_name, str):
if self.target_lookup_items.get(anchor_name) is not None:
LOGGER.warning('Anchor defined twice: %r', anchor_name)
else:
self.target_lookup_items.setdefault(
anchor_name, TargetLookupItem())
def lookup_target(self, anchor_token, source_box, css_token, parse_again):
"""Get a TargetLookupItem corresponding to ``anchor_token``.
If it is already filled by a previous anchor-element, the status is
'up-to-date'. Otherwise, it is 'pending', we must parse the whole
tree again.
"""
anchor_name = anchor_name_from_token(anchor_token)
item = self.target_lookup_items.get(
anchor_name, TargetLookupItem('undefined'))
if item.state == 'pending':
self.had_pending_targets = True
item.parse_again_functions.setdefault(
(source_box, css_token), parse_again)
if item.state == 'undefined':
LOGGER.error(
'Content discarded: target points to undefined anchor %r',
anchor_token)
return item
def store_target(self, anchor_name, target_counter_values, target_box):
"""Store a target called ``anchor_name``.
If there is a pending TargetLookupItem, it is updated. Only previously
collected anchors are stored.
"""
item = self.target_lookup_items.get(anchor_name)
if item and item.state == 'pending':
item.state = 'up-to-date'
item.target_box = target_box
# Store the counter_values in the target_box like
# compute_content_list does.
if target_box.cached_counter_values is None:
target_box.cached_counter_values = {
key: value.copy() for key, value
in target_counter_values.items()}
def collect_missing_counters(self, parent_box, css_token,
parse_again_function, missing_counters,
missing_target_counters):
"""Collect missing (probably page-based) counters during formatting.
The ``missing_counters`` are re-used during pagination.
The ``missing_link`` attribute added to the parent_box is required to
connect the paginated boxes to their originating ``parent_box``.
"""
# No counter collection during pagination
if not self.collecting:
return
# No need to add empty miss-lists
if missing_counters or missing_target_counters:
if parent_box.missing_link is None:
parent_box.missing_link = parent_box
counter_lookup_item = CounterLookupItem(
parse_again_function, missing_counters,
missing_target_counters)
self.counter_lookup_items.setdefault(
(parent_box, css_token), counter_lookup_item)
def check_pending_targets(self):
"""Check pending targets if needed."""
if self.had_pending_targets:
for item in self.target_lookup_items.values():
for function in item.parse_again_functions.values():
function()
self.had_pending_targets = False
# Ready for pagination
self.collecting = False
def cache_target_page_counters(self, anchor_name, page_counter_values,
page_maker_index, page_maker):
"""Store target's current ``page_maker_index`` and page counter values.
Eventually update associated targeting boxes.
"""
# Only store page counters when paginating
if self.collecting:
return
item = self.target_lookup_items.get(anchor_name)
if item and item.state == 'up-to-date':
item.page_maker_index = page_maker_index
if item.cached_page_counter_values != page_counter_values:
item.cached_page_counter_values = copy.deepcopy(
page_counter_values)
# Spread the news: update boxes affected by a change in the
# anchor's page counter values.
for (_, css_token), item in self.counter_lookup_items.items():
# Only update items that need counters in their content
if css_token != 'content':
continue
# Don't update if item has no missing target counter
missing_counters = item.missing_target_counters.get(
anchor_name)
if missing_counters is None:
continue
# Pending marker for remake_page
if (item.page_maker_index is None or
item.page_maker_index >= len(page_maker)):
item.pending = True
continue
# TODO: Is the item at all interested in the new
# page_counter_values? It probably is and this check is a
# brake.
for counter_name in missing_counters:
counter_value = page_counter_values.get(counter_name)
if counter_value is not None:
remake_state = (
page_maker[item.page_maker_index][-1])
remake_state['content_changed'] = True
item.parse_again(item.cached_page_counter_values)
break
# Hint: the box's own cached page counters trigger a
# separate 'content_changed'.

View File

@@ -0,0 +1,40 @@
/*
Simplified user-agent stylesheet for HTML5 in tests.
*/
@page { background: white; bleed: 0; @footnote { margin: 0 } }
html, body, div, h1, h2, h3, h4, ol, p, ul, hr, pre, section, article
{ display: block }
body { orphans: 1; widows: 1 }
li { display: list-item }
head { display: none }
pre { white-space: pre }
br:before { content: '\A'; white-space: pre-line }
ol { list-style-type: decimal }
ol, ul { counter-reset: list-item }
table, x-table { display: table;
box-sizing: border-box }
tr, x-tr { display: table-row }
thead, x-thead { display: table-header-group }
tbody, x-tbody { display: table-row-group }
tfoot, x-tfoot { display: table-footer-group }
col, x-col { display: table-column }
colgroup, x-colgroup { display: table-column-group }
td, th, x-td, x-th { display: table-cell }
caption, x-caption { display: table-caption }
*[lang] { -weasy-lang: attr(lang) }
a[href] { -weasy-link: attr(href) }
a[name] { -weasy-anchor: attr(name) }
*[id] { -weasy-anchor: attr(id) }
h1 { bookmark-level: 1; bookmark-label: content(text) }
h2 { bookmark-level: 2; bookmark-label: content(text) }
h3 { bookmark-level: 3; bookmark-label: content(text) }
h4 { bookmark-level: 4; bookmark-label: content(text) }
h5 { bookmark-level: 5; bookmark-label: content(text) }
h6 { bookmark-level: 6; bookmark-label: content(text) }
::marker { unicode-bidi: isolate; font-variant-numeric: tabular-nums }
::footnote-call { content: counter(footnote) }
::footnote-marker { content: counter(footnote) '.' }

View File

@@ -0,0 +1,782 @@
"""Utils for CSS properties."""
import functools
import math
from abc import ABC, abstractmethod
from urllib.parse import unquote, urljoin
from tinycss2.color3 import parse_color
from .. import LOGGER
from ..urls import iri_to_uri, url_is_absolute
from .properties import Dimension
# https://drafts.csswg.org/css-values-3/#angles
# 1<unit> is this many radians.
ANGLE_TO_RADIANS = {
'rad': 1,
'turn': 2 * math.pi,
'deg': math.pi / 180,
'grad': math.pi / 200,
}
# How many CSS pixels is one <unit>?
# https://www.w3.org/TR/CSS21/syndata.html#length-units
LENGTHS_TO_PIXELS = {
'px': 1,
'pt': 1. / 0.75,
'pc': 16., # LENGTHS_TO_PIXELS['pt'] * 12
'in': 96., # LENGTHS_TO_PIXELS['pt'] * 72
'cm': 96. / 2.54, # LENGTHS_TO_PIXELS['in'] / 2.54
'mm': 96. / 25.4, # LENGTHS_TO_PIXELS['in'] / 25.4
'q': 96. / 25.4 / 4, # LENGTHS_TO_PIXELS['mm'] / 4
}
# https://drafts.csswg.org/css-values/#resolution
RESOLUTION_TO_DPPX = {
'dppx': 1,
'dpi': 1 / LENGTHS_TO_PIXELS['in'],
'dpcm': 1 / LENGTHS_TO_PIXELS['cm'],
}
# Sets of possible length units
LENGTH_UNITS = set(LENGTHS_TO_PIXELS) | set(['ex', 'em', 'ch', 'rem'])
# Constants about background positions
ZERO_PERCENT = Dimension(0, '%')
FIFTY_PERCENT = Dimension(50, '%')
HUNDRED_PERCENT = Dimension(100, '%')
BACKGROUND_POSITION_PERCENTAGES = {
'top': ZERO_PERCENT,
'left': ZERO_PERCENT,
'center': FIFTY_PERCENT,
'bottom': HUNDRED_PERCENT,
'right': HUNDRED_PERCENT,
}
# Direction keywords used for gradients
DIRECTION_KEYWORDS = {
# ('angle', radians) 0 upwards, then clockwise
('to', 'top'): ('angle', 0),
('to', 'right'): ('angle', math.pi / 2),
('to', 'bottom'): ('angle', math.pi),
('to', 'left'): ('angle', math.pi * 3 / 2),
# ('corner', keyword)
('to', 'top', 'left'): ('corner', 'top_left'),
('to', 'left', 'top'): ('corner', 'top_left'),
('to', 'top', 'right'): ('corner', 'top_right'),
('to', 'right', 'top'): ('corner', 'top_right'),
('to', 'bottom', 'left'): ('corner', 'bottom_left'),
('to', 'left', 'bottom'): ('corner', 'bottom_left'),
('to', 'bottom', 'right'): ('corner', 'bottom_right'),
('to', 'right', 'bottom'): ('corner', 'bottom_right'),
}
# Default fallback values used in attr() functions
ATTR_FALLBACKS = {
'string': ('string', ''),
'color': ('ident', 'currentcolor'),
'url': ('external', 'about:invalid'),
'integer': ('number', 0),
'number': ('number', 0),
'%': ('number', 0),
}
for unit in LENGTH_UNITS:
ATTR_FALLBACKS[unit] = ('length', Dimension('0', unit))
for unit in ANGLE_TO_RADIANS:
ATTR_FALLBACKS[unit] = ('angle', Dimension('0', unit))
class InvalidValues(ValueError): # noqa: N818
"""Invalid or unsupported values for a known CSS property."""
class CenterKeywordFakeToken:
type = 'ident'
lower_value = 'center'
unit = None
class Pending(ABC):
"""Abstract class representing property value with pending validation."""
# See https://drafts.csswg.org/css-variables-2/#variables-in-shorthands.
def __init__(self, tokens, name):
self.tokens = tokens
self.name = name
self._reported_error = False
@abstractmethod
def validate(self, tokens, wanted_key):
"""Get validated value for wanted key."""
raise NotImplementedError
def solve(self, tokens, wanted_key):
"""Get validated value or raise error."""
try:
if not tokens:
# Having no tokens is allowed by grammar but refused by all
# properties and expanders.
raise InvalidValues('no value')
return self.validate(tokens, wanted_key)
except InvalidValues as exc:
if self._reported_error:
raise exc
source_line = self.tokens[0].source_line
source_column = self.tokens[0].source_column
value = ' '.join(token.serialize() for token in tokens)
message = (exc.args and exc.args[0]) or 'invalid value'
LOGGER.warning(
'Ignored `%s: %s` at %d:%d, %s.',
self.name, value, source_line, source_column, message)
self._reported_error = True
raise exc
def split_on_comma(tokens):
"""Split a list of tokens on commas, ie ``LiteralToken(',')``.
Only "top-level" comma tokens are splitting points, not commas inside a
function or blocks.
"""
parts = []
this_part = []
for token in tokens:
if token.type == 'literal' and token.value == ',':
parts.append(this_part)
this_part = []
else:
this_part.append(token)
parts.append(this_part)
return tuple(parts)
def split_on_optional_comma(tokens):
"""Split a list of tokens on optional commas, ie ``LiteralToken(',')``."""
parts = []
for split_part in split_on_comma(tokens):
if not split_part:
# Happens when there's a comma at the beginning, at the end, or
# when two commas are next to each other.
return
for part in split_part:
parts.append(part)
return parts
def remove_whitespace(tokens):
"""Remove any top-level whitespace and comments in a token list."""
return tuple(
token for token in tokens
if token.type not in ('whitespace', 'comment'))
def safe_urljoin(base_url, url):
if url_is_absolute(url):
return iri_to_uri(url)
elif base_url:
return iri_to_uri(urljoin(base_url, url))
else:
raise InvalidValues(
f'Relative URI reference without a base URI: {url!r}')
def comma_separated_list(function):
"""Decorator for validators that accept a comma separated list."""
@functools.wraps(function)
def wrapper(tokens, *args):
results = []
for part in split_on_comma(tokens):
result = function(remove_whitespace(part), *args)
if result is None:
return None
results.append(result)
return tuple(results)
wrapper.single_value = function
return wrapper
def get_keyword(token):
"""If ``token`` is a keyword, return its lowercase name.
Otherwise return ``None``.
"""
if token.type == 'ident':
return token.lower_value
def get_custom_ident(token):
"""If ``token`` is a keyword, return its name.
Otherwise return ``None``.
"""
if token.type == 'ident':
return token.value
def get_single_keyword(tokens):
"""If ``values`` is a 1-element list of keywords, return its name.
Otherwise return ``None``.
"""
if len(tokens) == 1:
token = tokens[0]
if token.type == 'ident':
return token.lower_value
def single_keyword(function):
"""Decorator for validators that only accept a single keyword."""
@functools.wraps(function)
def keyword_validator(tokens):
"""Wrap a validator to call get_single_keyword on tokens."""
keyword = get_single_keyword(tokens)
if function(keyword):
return keyword
return keyword_validator
def single_token(function):
"""Decorator for validators that only accept a single token."""
@functools.wraps(function)
def single_token_validator(tokens, *args):
"""Validate a property whose token is single."""
if len(tokens) == 1:
return function(tokens[0], *args)
single_token_validator.__func__ = function
return single_token_validator
def parse_linear_gradient_parameters(arguments):
first_arg = arguments[0]
if len(first_arg) == 1:
angle = get_angle(first_arg[0])
if angle is not None:
return ('angle', angle), arguments[1:]
else:
result = DIRECTION_KEYWORDS.get(tuple(map(get_keyword, first_arg)))
if result is not None:
return result, arguments[1:]
return ('angle', math.pi), arguments # Default direction is 'to bottom'
def parse_2d_position(tokens):
"""Common syntax of background-position and transform-origin."""
if len(tokens) == 1:
tokens = [tokens[0], CenterKeywordFakeToken]
elif len(tokens) != 2:
return None
token_1, token_2 = tokens
length_1 = get_length(token_1, percentage=True)
length_2 = get_length(token_2, percentage=True)
if length_1 and length_2:
return length_1, length_2
keyword_1, keyword_2 = map(get_keyword, tokens)
if length_1 and keyword_2 in ('top', 'center', 'bottom'):
return length_1, BACKGROUND_POSITION_PERCENTAGES[keyword_2]
elif length_2 and keyword_1 in ('left', 'center', 'right'):
return BACKGROUND_POSITION_PERCENTAGES[keyword_1], length_2
elif (keyword_1 in ('left', 'center', 'right') and
keyword_2 in ('top', 'center', 'bottom')):
return (BACKGROUND_POSITION_PERCENTAGES[keyword_1],
BACKGROUND_POSITION_PERCENTAGES[keyword_2])
elif (keyword_1 in ('top', 'center', 'bottom') and
keyword_2 in ('left', 'center', 'right')):
# Swap tokens. They need to be in (horizontal, vertical) order.
return (BACKGROUND_POSITION_PERCENTAGES[keyword_2],
BACKGROUND_POSITION_PERCENTAGES[keyword_1])
def parse_position(tokens):
"""Parse background-position and object-position.
See https://drafts.csswg.org/css-backgrounds-3/#the-background-position
https://drafts.csswg.org/css-images-3/#propdef-object-position
"""
result = parse_2d_position(tokens)
if result is not None:
pos_x, pos_y = result
return 'left', pos_x, 'top', pos_y
if len(tokens) == 4:
keyword_1 = get_keyword(tokens[0])
keyword_2 = get_keyword(tokens[2])
length_1 = get_length(tokens[1], percentage=True)
length_2 = get_length(tokens[3], percentage=True)
if length_1 and length_2:
if (keyword_1 in ('left', 'right') and
keyword_2 in ('top', 'bottom')):
return keyword_1, length_1, keyword_2, length_2
if (keyword_2 in ('left', 'right') and
keyword_1 in ('top', 'bottom')):
return keyword_2, length_2, keyword_1, length_1
if len(tokens) == 3:
length = get_length(tokens[2], percentage=True)
if length is not None:
keyword = get_keyword(tokens[1])
other_keyword = get_keyword(tokens[0])
else:
length = get_length(tokens[1], percentage=True)
other_keyword = get_keyword(tokens[2])
keyword = get_keyword(tokens[0])
if length is not None:
if other_keyword == 'center':
if keyword in ('top', 'bottom'):
return 'left', FIFTY_PERCENT, keyword, length
if keyword in ('left', 'right'):
return keyword, length, 'top', FIFTY_PERCENT
elif (keyword in ('left', 'right') and
other_keyword in ('top', 'bottom')):
return keyword, length, other_keyword, ZERO_PERCENT
elif (keyword in ('top', 'bottom') and
other_keyword in ('left', 'right')):
return other_keyword, ZERO_PERCENT, keyword, length
def parse_radial_gradient_parameters(arguments):
shape = None
position = None
size = None
size_shape = None
stack = arguments[0][::-1]
while stack:
token = stack.pop()
keyword = get_keyword(token)
if keyword == 'at':
position = parse_position(stack[::-1])
if position is None:
return
break
elif keyword in ('circle', 'ellipse') and shape is None:
shape = keyword
elif keyword in ('closest-corner', 'farthest-corner',
'closest-side', 'farthest-side') and size is None:
size = 'keyword', keyword
else:
if stack and size is None:
length_1 = get_length(token, percentage=True)
length_2 = get_length(stack[-1], percentage=True)
if None not in (length_1, length_2):
size = 'explicit', (length_1, length_2)
size_shape = 'ellipse'
stack.pop()
if size is None:
length_1 = get_length(token)
if length_1 is not None:
size = 'explicit', (length_1, length_1)
size_shape = 'circle'
if size is None:
return
if (shape, size_shape) in (('circle', 'ellipse'), ('circle', 'ellipse')):
return
return (
shape or size_shape or 'ellipse',
size or ('keyword', 'farthest-corner'),
position or ('left', FIFTY_PERCENT, 'top', FIFTY_PERCENT),
arguments[1:])
def parse_color_stop(tokens):
if len(tokens) == 1:
color = parse_color(tokens[0])
if color == 'currentColor':
# TODO: return the current color instead
return parse_color('black'), None
if color is not None:
return color, None
elif len(tokens) == 2:
color = parse_color(tokens[0])
position = get_length(tokens[1], negative=True, percentage=True)
if color is not None and position is not None:
return color, position
raise InvalidValues
def parse_function(function_token):
"""Parse functional notation.
Return ``(name, args)`` if the given token is a function with comma- or
space-separated arguments. Return ``None`` otherwise.
"""
if function_token.type != 'function':
return
content = list(remove_whitespace(function_token.arguments))
arguments = []
last_is_comma = False
while content:
token = content.pop(0)
is_comma = token.type == 'literal' and token.value == ','
if last_is_comma and is_comma:
return
if is_comma:
last_is_comma = True
else:
last_is_comma = False
if token.type == 'function':
argument_function = parse_function(token)
if argument_function is None:
return
arguments.append(token)
if last_is_comma:
return
return function_token.lower_name, arguments
def check_attr_function(token, allowed_type=None):
function = parse_function(token)
if function is None:
return
name, args = function
if name == 'attr' and len(args) in (1, 2, 3):
if args[0].type != 'ident':
return
attr_name = args[0].value
if len(args) == 1:
type_or_unit = 'string'
fallback = ''
else:
if args[1].type != 'ident':
return
type_or_unit = args[1].value
if type_or_unit not in ATTR_FALLBACKS:
return
if len(args) == 2:
fallback = ATTR_FALLBACKS[type_or_unit]
else:
fallback_type = args[2].type
if fallback_type == 'string':
fallback = args[2].value
else:
# TODO: handle other fallback types
return
if allowed_type in (None, type_or_unit):
return ('attr()', (attr_name, type_or_unit, fallback))
def check_counter_function(token, allowed_type=None):
from .validation.properties import list_style_type
function = parse_function(token)
if function is None:
return
name, args = function
arguments = []
if (name == 'counter' and len(args) in (1, 2)) or (
name == 'counters' and len(args) in (2, 3)):
ident = args.pop(0)
if ident.type != 'ident':
return
arguments.append(ident.value)
if name == 'counters':
string = args.pop(0)
if string.type != 'string':
return
arguments.append(string.value)
if args:
counter_style = list_style_type((args.pop(0),))
if counter_style is None:
return
arguments.append(counter_style)
else:
arguments.append('decimal')
return (f'{name}()', tuple(arguments))
def check_content_function(token):
function = parse_function(token)
if function is None:
return
name, args = function
if name == 'content':
if len(args) == 0:
return ('content()', 'text')
elif len(args) == 1:
ident = args.pop(0)
if ident.type == 'ident' and ident.lower_value in (
'text', 'before', 'after', 'first-letter', 'marker'):
return ('content()', ident.lower_value)
def check_string_or_element_function(string_or_element, token):
function = parse_function(token)
if function is None:
return
name, args = function
if name == string_or_element and len(args) in (1, 2):
custom_ident = args.pop(0)
if custom_ident.type != 'ident':
return
custom_ident = custom_ident.value
if args:
ident = args.pop(0)
if ident.type != 'ident' or ident.lower_value not in (
'first', 'start', 'last', 'first-except'):
return
ident = ident.lower_value
else:
ident = 'first'
return (f'{string_or_element}()', (custom_ident, ident))
def check_var_function(token):
if function := parse_function(token):
name, args = function
if name == 'var' and args:
ident = args.pop(0)
# TODO: we should check authorized tokens
# https://drafts.csswg.org/css-syntax-3/#typedef-declaration-value
return ident.type == 'ident' and ident.value.startswith('--')
for arg in args:
if check_var_function(arg):
return True
def get_string(token):
"""Parse a <string> token."""
if token.type == 'string':
return ('string', token.value)
if token.type == 'function':
if token.name == 'attr':
return check_attr_function(token, 'string')
elif token.name in ('counter', 'counters'):
return check_counter_function(token)
elif token.name == 'content':
return check_content_function(token)
elif token.name == 'string':
return check_string_or_element_function('string', token)
def get_length(token, negative=True, percentage=False):
"""Parse a <length> token."""
if percentage and token.type == 'percentage':
if negative or token.value >= 0:
return Dimension(token.value, '%')
if token.type == 'dimension' and token.unit in LENGTH_UNITS:
if negative or token.value >= 0:
return Dimension(token.value, token.unit)
if token.type == 'number' and token.value == 0:
return Dimension(0, None)
def get_angle(token):
"""Parse an <angle> token in radians."""
if token.type == 'dimension':
factor = ANGLE_TO_RADIANS.get(token.unit)
if factor is not None:
return token.value * factor
def get_resolution(token):
"""Parse a <resolution> token in ddpx."""
if token.type == 'dimension':
factor = RESOLUTION_TO_DPPX.get(token.unit)
if factor is not None:
return token.value * factor
def get_image(token, base_url):
"""Parse an <image> token."""
from ..images import LinearGradient, RadialGradient
parsed_url = get_url(token, base_url)
if parsed_url:
assert parsed_url[0] == 'url'
if parsed_url[1][0] == 'external':
return 'url', parsed_url[1][1]
if token.type != 'function':
return
arguments = split_on_comma(remove_whitespace(token.arguments))
name = token.lower_name
if name in ('linear-gradient', 'repeating-linear-gradient'):
direction, color_stops = parse_linear_gradient_parameters(arguments)
if color_stops:
return 'linear-gradient', LinearGradient(
[parse_color_stop(stop) for stop in color_stops],
direction, 'repeating' in name)
elif name in ('radial-gradient', 'repeating-radial-gradient'):
result = parse_radial_gradient_parameters(arguments)
if result is not None:
shape, size, position, color_stops = result
else:
shape = 'ellipse'
size = 'keyword', 'farthest-corner'
position = 'left', FIFTY_PERCENT, 'top', FIFTY_PERCENT
color_stops = arguments
if color_stops:
return 'radial-gradient', RadialGradient(
[parse_color_stop(stop) for stop in color_stops],
shape, size, position, 'repeating' in name)
def _get_url_tuple(string, base_url):
if string.startswith('#'):
return ('url', ('internal', unquote(string[1:])))
else:
return ('url', ('external', safe_urljoin(base_url, string)))
def get_url(token, base_url):
"""Parse an <url> token."""
if token.type == 'url':
return _get_url_tuple(token.value, base_url)
elif token.type == 'function':
if token.name == 'attr':
return check_attr_function(token, 'url')
elif token.name == 'url' and len(token.arguments) in (1, 2):
# Ignore url modifiers
# See https://drafts.csswg.org/css-values-3/#urls
return _get_url_tuple(token.arguments[0].value, base_url)
def get_quote(token):
"""Parse a <quote> token."""
keyword = get_keyword(token)
if keyword in (
'open-quote', 'close-quote',
'no-open-quote', 'no-close-quote'):
return keyword
def get_target(token, base_url):
"""Parse a <target> token."""
function = parse_function(token)
if function is None:
return
name, args = function
args = split_on_optional_comma(args)
if not args:
return
if name == 'target-counter':
if len(args) not in (2, 3):
return
elif name == 'target-counters':
if len(args) not in (3, 4):
return
elif name == 'target-text':
if len(args) not in (1, 2):
return
else:
return
values = []
link = args.pop(0)
string_link = get_string(link)
if string_link is None:
url = get_url(link, base_url)
if url is None:
return
values.append(url)
else:
values.append(string_link)
if name.startswith('target-counter'):
if not args:
return
ident = args.pop(0)
if ident.type != 'ident':
return
values.append(ident.value)
if name == 'target-counters':
string = get_string(args.pop(0))
if string is None:
return
values.append(string)
if args:
counter_style = get_keyword(args.pop(0))
else:
counter_style = 'decimal'
values.append(counter_style)
else:
if args:
content = get_keyword(args.pop(0))
if content not in ('content', 'before', 'after', 'first-letter'):
return
else:
content = 'content'
values.append(content)
return (f'{name}()', tuple(values))
def get_content_list(tokens, base_url):
"""Parse <content-list> tokens."""
# See https://www.w3.org/TR/css-content-3/#typedef-content-list
parsed_tokens = [
get_content_list_token(token, base_url) for token in tokens]
if None not in parsed_tokens:
return parsed_tokens
def get_content_list_token(token, base_url):
"""Parse one of the <content-list> tokens."""
# See https://www.w3.org/TR/css-content-3/#typedef-content-list
# <string>
string = get_string(token)
if string is not None:
return string
# contents
if get_keyword(token) == 'contents':
return ('content()', 'text')
# <uri>
url = get_url(token, base_url)
if url is not None:
return url
# <quote>
quote = get_quote(token)
if quote is not None:
return ('quote', quote)
# <target>
target = get_target(token, base_url)
if target is not None:
return target
function = parse_function(token)
if function is None:
return
name, args = function
# <leader()>
if name == 'leader':
if len(args) != 1:
return
arg, = args
if arg.type == 'ident':
if arg.value == 'dotted':
string = '.'
elif arg.value == 'solid':
string = '_'
elif arg.value == 'space':
string = ' '
else:
return
elif arg.type == 'string':
string = arg.value
return ('leader()', ('string', string))
# <element()>
elif name == 'element':
return check_string_or_element_function('element', token)

View File

@@ -0,0 +1,246 @@
"""Validate properties, expanders and descriptors."""
from cssselect2 import SelectorError, compile_selector_list
from tinycss2 import parse_blocks_contents, serialize
from tinycss2.ast import FunctionBlock, IdentToken, LiteralToken, WhitespaceToken
from ... import LOGGER
from ..utils import InvalidValues, remove_whitespace
from .expanders import EXPANDERS
from .properties import PREFIX, PROPRIETARY, UNSTABLE, validate_non_shorthand
# Not applicable to the print media
NOT_PRINT_MEDIA = {
# Aural media
'azimuth',
'cue',
'cue-after',
'cue-before',
'elevation',
'pause',
'pause-after',
'pause-before',
'pitch-range',
'pitch',
'play-during',
'richness',
'speak-header',
'speak-numeral',
'speak-punctuation',
'speak',
'speech-rate',
'stress',
'voice-family',
'volume',
# Animations, transitions, timelines
'animation',
'animation-composition',
'animation-delay',
'animation-direction',
'animation-duration',
'animation-fill-mode',
'animation-iteration-count',
'animation-name',
'animation-play-state',
'animation-range',
'animation-range-end',
'animation-range-start',
'animation-timeline',
'animation-timing-function',
'timeline-scope',
'transition',
'transition-delay',
'transition-duration',
'transition-property',
'transition-timing-function',
'view-timeline',
'view-timeline-axis',
'view-timeline-inset',
'view-timeline-name',
'view-transition-name',
'will-change',
# Dynamic and interactive
'caret',
'caret-color',
'caret-shape',
'cursor',
'field-sizing',
'pointer-event',
'resize',
'touch-action',
# Browser viewport scrolling
'overscroll-behavior',
'overscroll-behavior-block',
'overscroll-behavior-inline',
'overscroll-behavior-x',
'overscroll-behavior-y',
'scroll-behavior',
'scroll-margin',
'scroll-margin-block',
'scroll-margin-block-end',
'scroll-margin-block-start',
'scroll-margin-bottom',
'scroll-margin-inline',
'scroll-margin-inline-end',
'scroll-margin-inline-start',
'scroll-margin-left',
'scroll-margin-right',
'scroll-margin-top',
'scroll-padding',
'scroll-padding-block',
'scroll-padding-block-end',
'scroll-padding-block-start',
'scroll-padding-bottom',
'scroll-padding-inline',
'scroll-padding-inline-end',
'scroll-padding-inline-start',
'scroll-padding-left',
'scroll-padding-right',
'scroll-padding-top',
'scroll-snap-align',
'scroll-snap-stop',
'scroll-snap-type',
'scroll-timeline',
'scroll-timeline-axis',
'scroll-timeline-name',
'scrollbar-color',
'scrollbar-gutter',
'scrollbar-width',
}
NESTING_SELECTOR = LiteralToken(1, 1, '&')
ROOT_TOKEN = LiteralToken(1, 1, ':'), IdentToken(1, 1, 'root')
def preprocess_declarations(base_url, declarations, prelude=None):
"""Expand shorthand properties, filter unsupported properties and values.
Log a warning for every ignored declaration.
Return a iterable of ``(name, value, important)`` tuples.
"""
# Compile list of selectors.
if prelude is not None:
try:
if NESTING_SELECTOR in prelude:
# Handle & selector in non-nested rule. MDN explains that & is
# then equivalent to :scope, and :scope is equivalent to :root
# as we dont support :scope yet.
original_prelude, prelude = prelude, []
for token in original_prelude:
if token == NESTING_SELECTOR:
prelude.extend(ROOT_TOKEN)
else:
prelude.append(token)
selectors = compile_selector_list(prelude)
except SelectorError:
raise SelectorError(f"'{serialize(prelude)}'")
# Yield declarations.
is_token = LiteralToken(1, 1, ':'), FunctionBlock(1, 1, 'is', prelude)
for declaration in declarations:
if declaration.type == 'error':
LOGGER.warning(
'Error: %s at %d:%d.',
declaration.message,
declaration.source_line, declaration.source_column)
if declaration.type == 'qualified-rule':
# Nested rule.
if prelude is None:
continue
declaration_prelude = []
token_groups = [[]]
for token in declaration.prelude:
if token == ',':
token_groups.append([])
else:
token_groups[-1].append(token)
for token_group in token_groups:
if NESTING_SELECTOR in token_group:
# Replace & selector by parent.
for token in declaration.prelude:
if token == NESTING_SELECTOR:
declaration_prelude.extend(is_token)
else:
declaration_prelude.append(token)
else:
# No & selector, prepend parent.
is_token = (
LiteralToken(1, 1, ':'),
FunctionBlock(1, 1, 'is', prelude))
declaration_prelude.extend([
*is_token, WhitespaceToken(1, 1, ' '),
*token_group])
declaration_prelude.append(LiteralToken(1, 1, ','))
yield from preprocess_declarations(
base_url, parse_blocks_contents(declaration.content),
declaration_prelude[:-1])
if declaration.type != 'declaration':
continue
name = declaration.name
if not name.startswith('--'):
name = declaration.lower_name
def validation_error(level, reason):
getattr(LOGGER, level)(
'Ignored `%s:%s` at %d:%d, %s.',
declaration.name, serialize(declaration.value),
declaration.source_line, declaration.source_column, reason)
if name in NOT_PRINT_MEDIA:
validation_error(
'debug', 'the property does not apply for the print media')
continue
if name.startswith(PREFIX):
unprefixed_name = name[len(PREFIX):]
if unprefixed_name in PROPRIETARY:
name = unprefixed_name
elif unprefixed_name in UNSTABLE:
LOGGER.warning(
'Deprecated `%s:%s` at %d:%d, '
'prefixes on unstable attributes are deprecated, '
'use %r instead.',
declaration.name, serialize(declaration.value),
declaration.source_line, declaration.source_column,
unprefixed_name)
name = unprefixed_name
else:
LOGGER.warning(
'Ignored `%s:%s` at %d:%d, '
'prefix on this attribute is not supported, '
'use %r instead.',
declaration.name, serialize(declaration.value),
declaration.source_line, declaration.source_column,
unprefixed_name)
continue
if name.startswith('-') and not name.startswith('--'):
validation_error('debug', 'prefixed selectors are ignored')
continue
validator = EXPANDERS.get(name, validate_non_shorthand)
tokens = remove_whitespace(declaration.value)
try:
# Having no tokens is allowed by grammar but refused by all
# properties and expanders.
if not tokens:
raise InvalidValues('no value')
# Use list() to consume generators now and catch any error.
result = list(validator(tokens, name, base_url))
except InvalidValues as exc:
validation_error(
'warning',
exc.args[0] if exc.args and exc.args[0] else 'invalid value')
continue
important = declaration.important
for long_name, value in result:
if prelude is not None:
declaration = (long_name.replace('-', '_'), value, important)
yield selectors, declaration
else:
yield long_name.replace('-', '_'), value, important

View File

@@ -0,0 +1,347 @@
"""Validate descriptors used for some at-rules."""
from math import inf
import tinycss2
from ...logger import LOGGER
from . import properties
from ..utils import ( # isort:skip
InvalidValues, comma_separated_list, get_custom_ident, get_keyword,
get_single_keyword, get_url, remove_whitespace, single_keyword,
single_token, split_on_comma)
DESCRIPTORS = {
'font-face': {},
'counter-style': {},
}
NOT_PRINT_MEDIA = (
'font-display',
)
class NoneFakeToken:
type = 'ident'
lower_value = 'none'
class NormalFakeToken:
type = 'ident'
lower_value = 'normal'
def preprocess_descriptors(rule, base_url, descriptors):
"""Filter unsupported names and values for descriptors.
Log a warning for every ignored descriptor.
Return a iterable of ``(name, value)`` tuples.
"""
for descriptor in descriptors:
if descriptor.type != 'declaration' or descriptor.important:
continue
tokens = remove_whitespace(descriptor.value)
try:
if descriptor.name in NOT_PRINT_MEDIA:
continue
elif descriptor.name not in DESCRIPTORS[rule]:
raise InvalidValues('descriptor not supported')
function = DESCRIPTORS[rule][descriptor.name]
if function.wants_base_url:
value = function(tokens, base_url)
else:
value = function(tokens)
if value is None:
raise InvalidValues
result = ((descriptor.name, value),)
except InvalidValues as exc:
LOGGER.warning(
'Ignored `%s:%s` at %d:%d, %s.',
descriptor.name, tinycss2.serialize(descriptor.value),
descriptor.source_line, descriptor.source_column,
exc.args[0] if exc.args and exc.args[0] else 'invalid value')
continue
for long_name, value in result:
yield long_name.replace('-', '_'), value
def descriptor(rule, descriptor_name=None, wants_base_url=False):
"""Decorator adding a function to the ``DESCRIPTORS``.
The name of the descriptor covered by the decorated function is set to
``descriptor_name`` if given, or is inferred from the function name
(replacing underscores by hyphens).
:param wants_base_url:
The function takes the stylesheets base URL as an additional
parameter.
"""
def decorator(function):
"""Add ``function`` to the ``DESCRIPTORS``."""
if descriptor_name is None:
name = function.__name__.replace('_', '-')
else:
name = descriptor_name
assert name not in DESCRIPTORS[rule], name
function.wants_base_url = wants_base_url
DESCRIPTORS[rule][name] = function
return function
return decorator
def expand_font_variant(tokens):
keyword = get_single_keyword(tokens)
if keyword in ('normal', 'none'):
for suffix in (
'-alternates', '-caps', '-east-asian', '-numeric',
'-position'):
yield suffix, [NormalFakeToken]
token = NormalFakeToken if keyword == 'normal' else NoneFakeToken
yield '-ligatures', [token]
else:
features = {
'alternates': [],
'caps': [],
'east-asian': [],
'ligatures': [],
'numeric': [],
'position': []}
for token in tokens:
keyword = get_keyword(token)
if keyword == 'normal':
# We don't allow 'normal', only the specific values
raise InvalidValues
for feature in features:
function_name = f'font_variant_{feature.replace("-", "_")}'
if getattr(properties, function_name)([token]):
features[feature].append(token)
break
else:
raise InvalidValues
for feature, tokens in features.items():
if tokens:
yield (f'-{feature}', tokens)
@descriptor('font-face')
def font_family(tokens, allow_spaces=False):
"""``font-family`` descriptor validation."""
allowed_types = ['ident']
if allow_spaces:
allowed_types.append('whitespace')
if len(tokens) == 1 and tokens[0].type == 'string':
return tokens[0].value
if tokens and all(token.type in allowed_types for token in tokens):
return ' '.join(
token.value for token in tokens if token.type == 'ident')
@descriptor('font-face', wants_base_url=True)
@comma_separated_list
def src(tokens, base_url):
"""``src`` descriptor validation."""
if len(tokens) in (1, 2):
tokens, token = tokens[:-1], tokens[-1]
if token.type == 'function' and token.lower_name == 'format':
tokens, token = tokens[:-1], tokens[-1]
if token.type == 'function' and token.lower_name == 'local':
return 'local', font_family(token.arguments, allow_spaces=True)
url = get_url(token, base_url)
if url is not None and url[0] == 'url':
return url[1]
@descriptor('font-face')
@single_keyword
def font_style(keyword):
"""``font-style`` descriptor validation."""
return keyword in ('normal', 'italic', 'oblique')
@descriptor('font-face')
@single_token
def font_weight(token):
"""``font-weight`` descriptor validation."""
keyword = get_keyword(token)
if keyword in ('normal', 'bold'):
return keyword
if token.type == 'number' and token.int_value is not None:
if token.int_value in (100, 200, 300, 400, 500, 600, 700, 800, 900):
return token.int_value
@descriptor('font-face')
@single_keyword
def font_stretch(keyword):
"""``font-stretch`` descriptor validation."""
return keyword in (
'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed',
'normal',
'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded')
@descriptor('font-face')
def font_feature_settings(tokens):
"""``font-feature-settings`` descriptor validation."""
return properties.font_feature_settings(tokens)
@descriptor('font-face')
def font_variant(tokens):
"""``font-variant`` descriptor validation."""
if len(tokens) == 1:
keyword = get_keyword(tokens[0])
if keyword in ('normal', 'none', 'inherit'):
return []
values = []
for name, sub_tokens in expand_font_variant(tokens):
try:
values.append(properties.validate_non_shorthand(
sub_tokens, f'font-variant{name}', required=True))
except InvalidValues:
return None
return values
@descriptor('counter-style')
def system(tokens):
"""``system`` descriptor validation."""
if len(tokens) > 2:
return
keyword = get_keyword(tokens[0])
if keyword == 'extends':
if len(tokens) == 2:
second_keyword = get_keyword(tokens[1])
if second_keyword:
return (keyword, second_keyword, None)
elif keyword == 'fixed':
if len(tokens) == 1:
return (None, 'fixed', 1)
elif tokens[1].type == 'number' and tokens[1].is_integer:
return (None, 'fixed', tokens[1].int_value)
elif len(tokens) == 1 and keyword in (
'cyclic', 'numeric', 'alphabetic', 'symbolic', 'additive'):
return (None, keyword, None)
@descriptor('counter-style', wants_base_url=True)
def negative(tokens, base_url):
"""``negative`` descriptor validation."""
if len(tokens) > 2:
return
values = []
tokens = list(tokens)
while tokens:
token = tokens.pop(0)
if token.type in ('string', 'ident'):
values.append(('string', token.value))
continue
url = get_url(token, base_url)
if url is not None and url[0] == 'url':
values.append(('url', url[1]))
if len(values) == 1:
values.append(('string', ''))
if len(values) == 2:
return values
@descriptor('counter-style', 'prefix', wants_base_url=True)
@descriptor('counter-style', 'suffix', wants_base_url=True)
def prefix_suffix(tokens, base_url):
"""``prefix`` and ``suffix`` descriptors validation."""
if len(tokens) != 1:
return
token, = tokens
if token.type in ('string', 'ident'):
return ('string', token.value)
url = get_url(token, base_url)
if url is not None and url[0] == 'url':
return ('url', url[1])
@descriptor('counter-style')
@comma_separated_list
def range(tokens):
"""``range`` descriptor validation."""
if len(tokens) == 1:
keyword = get_single_keyword(tokens)
if keyword == 'auto':
return 'auto'
elif len(tokens) == 2:
values = []
for i, token in enumerate(tokens):
if token.type == 'ident' and token.value == 'infinite':
values.append(inf if i else -inf)
elif token.type == 'number' and token.is_integer:
values.append(token.int_value)
if len(values) == 2 and values[0] <= values[1]:
return tuple(values)
@descriptor('counter-style', wants_base_url=True)
def pad(tokens, base_url):
"""``pad`` descriptor validation."""
if len(tokens) == 2:
values = [None, None]
for token in tokens:
if token.type == 'number':
if token.is_integer and token.value >= 0 and values[0] is None:
values[0] = token.int_value
elif token.type in ('string', 'ident'):
values[1] = ('string', token.value)
url = get_url(token, base_url)
if url is not None and url[0] == 'url':
values[1] = ('url', url[1])
if None not in values:
return tuple(values)
@descriptor('counter-style')
@single_token
def fallback(token):
"""``fallback`` descriptor validation."""
ident = get_custom_ident(token)
if ident != 'none':
return ident
@descriptor('counter-style', wants_base_url=True)
def symbols(tokens, base_url):
"""``symbols`` descriptor validation."""
values = []
for token in tokens:
if token.type in ('string', 'ident'):
values.append(('string', token.value))
continue
url = get_url(token, base_url)
if url is not None and url[0] == 'url':
values.append(('url', url[1]))
continue
return
return tuple(values)
@descriptor('counter-style', wants_base_url=True)
def additive_symbols(tokens, base_url):
"""``additive-symbols`` descriptor validation."""
results = []
for part in split_on_comma(tokens):
result = pad(remove_whitespace(part), base_url)
if result is None:
return
if results and results[-1][0] <= result[0]:
return
results.append(result)
return tuple(results)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff