├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── css2video ├── __init__.py ├── components │ ├── __init__.py │ ├── base.py │ ├── rule.py │ └── stylesheet.py ├── constants.py ├── interpolators │ ├── __init__.py │ ├── interpolators.py │ ├── stylesheet.py │ └── value.py ├── outputters │ ├── __init__.py │ ├── property.py │ ├── rule.py │ ├── stylesheet.py │ └── value.py ├── parsers │ ├── __init__.py │ ├── api.py │ ├── base.py │ ├── property.py │ ├── rule.py │ ├── stylesheet.py │ └── value.py └── renderers │ ├── __init__.py │ ├── image │ ├── __init__.py │ ├── base.py │ ├── cutycapt.py │ └── render.py │ ├── render.py │ └── video │ ├── __init__.py │ ├── base.py │ ├── ffmpeg.py │ └── render.py ├── examples ├── test.css ├── test.html └── test.py ├── requirements.txt ├── setup.py ├── test_requirements.txt └── tests ├── __init__.py ├── parsers ├── __init__.py ├── rule │ ├── __init__.py │ ├── test_keyframe.py │ └── test_style.py ├── test_property.py └── value │ ├── __init__.py │ ├── test_array.py │ ├── test_color.py │ ├── test_function.py │ ├── test_length.py │ ├── test_number.py │ ├── test_percentage.py │ ├── test_text.py │ ├── test_time.py │ └── test_url.py ├── test_interpolators.py ├── test_outputters.py ├── test_parsers.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | .DS_STORE 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:latest 2 | 3 | RUN apt-get update -y && \ 4 | apt-get install -y cutycapt ffmpeg xvfb 5 | 6 | ADD . /app 7 | 8 | WORKDIR /app 9 | 10 | RUN pip install . 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Sagar Chakravarthy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # css2video 2 | 3 | A tool to convert CSS animations to an MPEG video 4 | 5 | ## Usage 6 | 7 | ``` 8 | # Clone the repository 9 | git clone https://github.com/bpsagar/css2video.git 10 | 11 | # Build the docker image 12 | docker build -t css2video . 13 | 14 | # Run the example, it should create a test.mp4 file in the examples folder 15 | docker run -v $PWD:/app css2video python examples/test.py 16 | 17 | # Make any changes to the test.py or add your own python script and run the 18 | # script inside the css2video container 19 | 20 | ``` 21 | 22 | ## Quirks 23 | 24 | - The animation doesn't get captured in the video if the CSS is linked in the HTML page. So don't add the link tag (that would point to the CSS file) in the HTML file. 25 | - Keyframe CSS should be explicit: 26 | - Explicitly define the default values. 27 | - Avoid short hand CSS values. 28 | - Each frames take a second to render so the whole rendering process is a bit slow. 29 | 30 | **Note**: Feel free to notify me about any issues 31 | 32 | -------------------------------------------------------------------------------- /css2video/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpsagar/css2video/0fc9e0eb6101fcc3a76dfe34ed91cf0d4157fee3/css2video/__init__.py -------------------------------------------------------------------------------- /css2video/components/__init__.py: -------------------------------------------------------------------------------- 1 | from .stylesheet import StyleSheetComponent 2 | -------------------------------------------------------------------------------- /css2video/components/base.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | 4 | class BaseComponent(object): 5 | """A base component for other CSS components 6 | 7 | Public attributes: 8 | - Dict: Dictionary form of the component 9 | """ 10 | 11 | def __init__(self, Dict): 12 | super(BaseComponent, self).__init__() 13 | self.Dict = Dict 14 | 15 | @classmethod 16 | def from_dict(cls, Dict): 17 | """Creates a component from dictionary""" 18 | return cls(Dict=Dict) 19 | 20 | def to_dict(self): 21 | """Returns the dictionary form of the component""" 22 | return self.Dict 23 | 24 | def duplicate(self): 25 | """Returns a duplicate of this component""" 26 | return self.__class__(Dict=copy.deepcopy(self.Dict)) 27 | -------------------------------------------------------------------------------- /css2video/components/rule.py: -------------------------------------------------------------------------------- 1 | from .base import BaseComponent 2 | 3 | 4 | class StyleRuleComponent(BaseComponent): 5 | """A wrapper for CSS style rule dictionary object""" 6 | 7 | def __init__(self, *args, **kwargs): 8 | super(StyleRuleComponent, self).__init__(*args, **kwargs) 9 | 10 | @property 11 | def properties(self): 12 | '''Returns all the properties as a dictionary''' 13 | return { 14 | p['property_name']: p['property_value'] 15 | for p in self.Dict.get('properties', []) 16 | } 17 | 18 | def animation_properties(self): 19 | '''Returns all the animation related properties as a dictionary''' 20 | properties = { 21 | 'animation-name': '', 22 | 'animation-duration': 0, 23 | 'animation-delay': 0, 24 | 'animation-iteration-count': 1, 25 | 'animation-timing-function': 'ease' 26 | } 27 | for key in properties: 28 | if self.properties.get(key): 29 | value = self.properties.get(key) 30 | properties[key] = value.get('value') 31 | return properties 32 | 33 | def property_list(self): 34 | '''Returns a list of property names''' 35 | return [p['property_name'] for p in self.Dict.get('properties', [])] 36 | 37 | def add_property(self, name, value): 38 | '''Add a new property and value''' 39 | self.Dict['properties'].append({ 40 | 'property_name': name, 41 | 'property_value': value 42 | }) 43 | 44 | 45 | class KeyframePropertiesComponent(BaseComponent): 46 | """A wrapper for CSS keyframe properties inside a keyframes rule""" 47 | 48 | def __init__(self, *args, **kwargs): 49 | super(KeyframePropertiesComponent, self).__init__(*args, **kwargs) 50 | 51 | @property 52 | def time_offset(self): 53 | return self.Dict.get('selector') 54 | 55 | @property 56 | def properties(self): 57 | '''Returns all the properties as a dictionary''' 58 | return { 59 | p['property_name']: p['property_value'] 60 | for p in self.Dict.get('properties', []) 61 | } 62 | 63 | 64 | class KeyframesRuleComponent(BaseComponent): 65 | """A wrapper for CSS keyframes rule dictionary object""" 66 | 67 | def __init__(self, *args, **kwargs): 68 | super(KeyframesRuleComponent, self).__init__(*args, **kwargs) 69 | 70 | @property 71 | def name(self): 72 | """Name of animation""" 73 | return self.Dict.get('name') 74 | 75 | @property 76 | def keyframe_properties(self): 77 | """Keyframe properties of the animation""" 78 | keyframe_properties = [ 79 | KeyframePropertiesComponent(k) 80 | for k in self.Dict.get('keyframes', []) 81 | ] 82 | keyframe_properties.sort(key=lambda x: x.time_offset) 83 | return keyframe_properties 84 | 85 | def get_property_sets(self, time_offset): 86 | """For a given time offset, it returns the keyframe properties 87 | before and after this time offset 88 | 89 | Args: 90 | - time_offset: time offset in percentage 91 | 92 | Returns: 93 | (props_1, props_2) where props_1 are properties before 94 | the time offset and props_2 are properties after the time offset 95 | """ 96 | set1 = set2 = None 97 | for kfp in self.keyframe_properties: 98 | if kfp.time_offset <= time_offset: 99 | set1 = kfp 100 | 101 | for kfp in self.keyframe_properties[::-1]: 102 | if kfp.time_offset >= time_offset: 103 | set2 = kfp 104 | return (set1, set2) 105 | -------------------------------------------------------------------------------- /css2video/components/stylesheet.py: -------------------------------------------------------------------------------- 1 | from .base import BaseComponent 2 | from .rule import KeyframesRuleComponent 3 | from .rule import StyleRuleComponent 4 | 5 | 6 | class StyleSheetComponent(BaseComponent): 7 | """A wrapper from stylesheet dictionary object""" 8 | 9 | def __init__(self, *args, **kwargs): 10 | super(StyleSheetComponent, self).__init__(*args, **kwargs) 11 | 12 | @property 13 | def style_rules(self): 14 | """Returns the style components""" 15 | style_rules = filter( 16 | lambda x: x['type'] == 'style', self.Dict.get('rules', [])) 17 | return [ 18 | StyleRuleComponent.from_dict(Dict=rule) for rule in style_rules 19 | ] 20 | 21 | @property 22 | def keyframes_rules(self): 23 | keyframes_rules = filter( 24 | lambda x: x['type'] == 'keyframes', self.Dict.get('rules', [])) 25 | return [ 26 | KeyframesRuleComponent.from_dict(Dict=rule) 27 | for rule in keyframes_rules 28 | ] 29 | 30 | def animation_names(self): 31 | """Returns a list of animation names""" 32 | return [rule.name for rule in self.keyframes_rules] 33 | 34 | def get_keyframes_rule(self, name): 35 | """Returns the keyframes rule with this name 36 | Args: 37 | - name: name of the animation 38 | """ 39 | for keyframes_rule in self.keyframes_rules: 40 | if name == keyframes_rule.name: 41 | return keyframes_rule 42 | -------------------------------------------------------------------------------- /css2video/constants.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class ValueType(object): 4 | """Class to define constants for value types""" 5 | 6 | number = 'number' 7 | length = 'length' 8 | percentage = 'percentage' 9 | time = 'time' 10 | color = 'color' 11 | text = 'text' 12 | url = 'url' 13 | function = 'function' 14 | array = 'array' 15 | 16 | 17 | class RuleType(object): 18 | """Class to define constants for rule types""" 19 | 20 | style = 'style' 21 | keyframes = 'keyframes' 22 | -------------------------------------------------------------------------------- /css2video/interpolators/__init__.py: -------------------------------------------------------------------------------- 1 | from .stylesheet import interpolate_stylesheet 2 | from .value import interpolate_value 3 | -------------------------------------------------------------------------------- /css2video/interpolators/interpolators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # Cubic bezier function 4 | _cubic_bezier = lambda t, v0, v1, v2, v3: ((1-t)**3) * v0 + 3 * ((1-t)**2) * t * v1 + 3 * (1-t) * (t**2) * v2 + (t**3)*v3 5 | 6 | 7 | def bezier_interpolate(x1, y1, x2, y2, x, iterations=64): 8 | assert(x1 >= 0 and x2 >= 0) 9 | assert(x1 <= 1 and x2 <= 1) 10 | 11 | # search for x 12 | t = 0.5 13 | r = 0.5 14 | while iterations > 0: 15 | iterations -= 1 16 | 17 | v = _cubic_bezier(t, 0, x1, x2, 1) 18 | if v == x: 19 | break 20 | elif v < x: 21 | t += r 22 | else: 23 | t -= r 24 | r = r / 2 25 | logging.debug("Bezier interpolation") 26 | logging.debug(str((x1, x2, y1, y2, x))) 27 | logging.debug(str(32 - iterations) + " iteration(s)") 28 | return _cubic_bezier(t, 0, y1, y2, 1) 29 | 30 | 31 | res = bezier_interpolate(0.4, 0.2, 0.6, 0.8, 0.5) 32 | 33 | 34 | class Interpolators: 35 | '''Standard interpolators''' 36 | LINEAR = lambda v1, v2, f: v1 * (1-f) + v2 * f 37 | EASE = lambda v1, v2, f: Interpolators.LINEAR(v1, v2, bezier_interpolate(0.25,0.1,0.25,1,f)) 38 | EASE_IN = lambda v1, v2, f: Interpolators.LINEAR(v1, v2, bezier_interpolate(0.42,0,1,1,f)) 39 | EASE_OUT = lambda v1, v2, f: Interpolators.LINEAR(v1, v2, bezier_interpolate(0,0,0.58,1,f)) 40 | EASE_IN_OUT = lambda v1, v2, f: Interpolators.LINEAR(v1, v2, bezier_interpolate(0.42,0,0.58,1,f)) 41 | 42 | @staticmethod 43 | def get_timing_function(name): 44 | name = name.upper() 45 | name = name.replace('-', '_') 46 | return getattr(Interpolators, name) 47 | -------------------------------------------------------------------------------- /css2video/interpolators/stylesheet.py: -------------------------------------------------------------------------------- 1 | from ..components import StyleSheetComponent 2 | from .value import interpolate_value 3 | 4 | 5 | class MissingProperty(Exception): 6 | '''Exception raised when a property value is missing while interpolating 7 | values''' 8 | pass 9 | 10 | 11 | class StyleSheetInterpolator(object): 12 | '''Generate stylesheet dictionary object''' 13 | 14 | def __init__(self, stylesheet_dict, *args, **kwargs): 15 | super(StyleSheetInterpolator, self).__init__(*args, **kwargs) 16 | self.stylesheet = StyleSheetComponent.from_dict(Dict=stylesheet_dict) 17 | 18 | def get_time_offset(self, animation_properties, time): 19 | '''Returns the time offset in percentage for the set of provided 20 | animation properties at the given time. Return None if the time is not 21 | within the animation duration''' 22 | delay = animation_properties['animation-delay'] 23 | duration = animation_properties['animation-duration'] 24 | iteration_count = animation_properties['animation-iteration-count'] 25 | animation_start = delay 26 | animation_end = delay + (duration * iteration_count) 27 | 28 | if time < animation_start or time > animation_end: 29 | return None 30 | 31 | time -= delay 32 | time_offset = time % duration 33 | return (time_offset * 100) / duration 34 | 35 | def generate(self, time): 36 | '''Returns a stylesheet dictionary object for the given time''' 37 | stylesheet = self.stylesheet.duplicate() 38 | 39 | for rule in stylesheet.style_rules: 40 | animation_properties = rule.animation_properties() 41 | animation_name = animation_properties.get('animation-name') 42 | if not animation_name: 43 | continue 44 | 45 | time_offset = self.get_time_offset(animation_properties, time) 46 | if time_offset is None: 47 | continue 48 | 49 | keyframes_rule = stylesheet.get_keyframes_rule(animation_name) 50 | kfp1, kfp2 = keyframes_rule.get_property_sets( 51 | time_offset=time_offset) 52 | 53 | if kfp2.time_offset == kfp1.time_offset: 54 | fraction = 0 55 | else: 56 | fraction = ( 57 | (time_offset - kfp1.time_offset) / 58 | (kfp2.time_offset - kfp1.time_offset) 59 | ) 60 | 61 | for pname1, value1 in kfp1.properties.items(): 62 | if pname1 not in kfp2.properties: 63 | raise MissingProperty('Missing property %s' % pname1) 64 | value2 = kfp2.properties[pname1] 65 | value = interpolate_value( 66 | from_value=value1, to_value=value2, fraction=fraction, 67 | type=animation_properties['animation-timing-function']) 68 | rule.add_property(name=pname1, value=value) 69 | return stylesheet 70 | 71 | 72 | def interpolate_stylesheet(stylesheet_dict, time): 73 | """Interpolates the animation property value based on the given time and 74 | returns the resulting stylesheet as a dictionary""" 75 | generator = StyleSheetInterpolator(stylesheet_dict=stylesheet_dict) 76 | return generator.generate(time=time).to_dict() 77 | -------------------------------------------------------------------------------- /css2video/interpolators/value.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from .interpolators import Interpolators 4 | 5 | 6 | class ValueTypeMismatch(Exception): 7 | '''Exception raised when there is mismatch between the two values''' 8 | 9 | 10 | class FunctionValueArgCountMismatch(Exception): 11 | '''Exception raised when trying to interpolate function values which don't 12 | have the same number of arguments''' 13 | 14 | 15 | class ArrayLengthMismatch(Exception): 16 | '''Exception raised when trying to interpolate array values which don't 17 | have the same length''' 18 | 19 | 20 | def interpolate_value(from_value, to_value, fraction, type='linear'): 21 | '''Interpolate a value between the given two values 22 | from_value - initial value 23 | to_value - final value 24 | fraction - fraction position from the initial value 25 | type - type of interpolation function''' 26 | 27 | if from_value.get('type') != to_value.get('type'): 28 | raise ValueTypeMismatch() 29 | 30 | value_type = from_value.get('type') 31 | interpolator_fn = Interpolators.get_timing_function(name=type) 32 | 33 | required_value = copy.deepcopy(from_value) 34 | 35 | if value_type in ['number', 'length', 'percentage', 'time']: 36 | # Interpolate the value 37 | required_value['value'] = interpolator_fn( 38 | from_value['value'], to_value['value'], fraction) 39 | 40 | if value_type == 'color': 41 | # Interpolate RGBA values 42 | required_value['red'] = interpolator_fn( 43 | from_value['red'], to_value['red'], fraction) 44 | required_value['green'] = interpolator_fn( 45 | from_value['green'], to_value['green'], fraction) 46 | required_value['blue'] = interpolator_fn( 47 | from_value['blue'], to_value['blue'], fraction) 48 | required_value['alpha'] = interpolator_fn( 49 | from_value['alpha'], to_value['alpha'], fraction) 50 | 51 | if value_type in ['text', 'url']: 52 | # Text values cannot be interpolated. 53 | # When the fraction is 1, return the final value 54 | if fraction == 1: 55 | required_value['value'] = to_value['value'] 56 | 57 | if value_type == 'function': 58 | # Check the argument count of both the values and interpolate each of 59 | # the value 60 | from_args = from_value.get('args', []) 61 | to_args = to_value.get('args', []) 62 | if len(from_args) != len(to_args): 63 | raise FunctionValueArgCountMismatch() 64 | args = [] 65 | for arg1, arg2 in zip(from_args, to_args): 66 | args.append(interpolate_value(arg1, arg2, fraction, type)) 67 | required_value['args'] = args 68 | 69 | if value_type == 'array': 70 | # Check the length of the array and interpolate each of the array items 71 | from_values = from_value.get('values', []) 72 | to_values = to_value.get('values', []) 73 | if len(from_values) != len(to_values): 74 | raise ArrayLengthMismatch() 75 | values = [] 76 | for value1, value2 in zip(from_values, to_values): 77 | values.append(interpolate_value(value1, value2, fraction, type)) 78 | required_value['values'] = values 79 | 80 | return required_value 81 | -------------------------------------------------------------------------------- /css2video/outputters/__init__.py: -------------------------------------------------------------------------------- 1 | from .property import output_property 2 | from .rule import output_rule 3 | from .stylesheet import output_stylesheet 4 | from .value import output_value 5 | -------------------------------------------------------------------------------- /css2video/outputters/property.py: -------------------------------------------------------------------------------- 1 | from .value import output_value 2 | 3 | 4 | def output_property(property_dict): 5 | '''Returns the property as a string''' 6 | value = output_value(property_dict['property_value']) 7 | return '{name}: {value};'.format( 8 | name=property_dict['property_name'], value=value) 9 | -------------------------------------------------------------------------------- /css2video/outputters/rule.py: -------------------------------------------------------------------------------- 1 | from .property import output_property 2 | 3 | 4 | class MissingRuleType(Exception): 5 | '''Exception raised when the type of rule is not specified''' 6 | pass 7 | 8 | 9 | def _output_keyframe_properties(keyframe_dict): 10 | '''Returns the keyframe properties''' 11 | properties = '\n\t'.join( 12 | [output_property(p) for p in keyframe_dict.get('properties', [])]) 13 | return ( 14 | '\t%d%% {\n' 15 | '\t\t%s\n' 16 | '\t}' 17 | ) % (keyframe_dict['selector'], properties) 18 | 19 | 20 | def output_rule(rule_dict): 21 | '''Returns the rule as a string''' 22 | type = rule_dict.get('type') 23 | 24 | if type is None: 25 | raise MissingRuleType() 26 | if type == 'style': 27 | properties = '\n\t'.join( 28 | [output_property(p) for p in rule_dict.get('properties', [])]) 29 | return ( 30 | '%s {\n' 31 | '\t%s\n' 32 | '}' 33 | ) % (rule_dict['selector'], properties) 34 | if type == 'keyframes': 35 | keyframe_properties = '\n'.join([ 36 | _output_keyframe_properties(p) 37 | for p in rule_dict.get('keyframes', []) 38 | ]) 39 | return ( 40 | '@keyframes %s {\n' 41 | '%s\n' 42 | '}' 43 | ) % (rule_dict['name'], keyframe_properties) 44 | -------------------------------------------------------------------------------- /css2video/outputters/stylesheet.py: -------------------------------------------------------------------------------- 1 | from .rule import output_rule 2 | 3 | 4 | def output_stylesheet(stylesheet_dict): 5 | '''Returns the stylesheet as a string''' 6 | return '\n'.join( 7 | [output_rule(rule) for rule in stylesheet_dict.get('rules', []) if rule['type'] == 'style']) 8 | -------------------------------------------------------------------------------- /css2video/outputters/value.py: -------------------------------------------------------------------------------- 1 | 2 | class MissingValueType(Exception): 3 | '''Exception raised when the type of value is not specified''' 4 | pass 5 | 6 | 7 | def output_value(value_dict): 8 | '''Return the value as a string''' 9 | type = value_dict.get('type') 10 | 11 | if type is None: 12 | raise MissingValueType() 13 | if type == 'number': 14 | return '{value}'.format(**value_dict) 15 | if type == 'length': 16 | return '{value}{unit}'.format(**value_dict) 17 | if type == 'percentage': 18 | return '{value}%'.format(**value_dict) 19 | if type == 'time': 20 | return '{value}s'.format(**value_dict) 21 | if type == 'color': 22 | return 'rgba({red}, {green}, {blue}, {alpha})'.format(**value_dict) 23 | if type == 'text': 24 | return '{value}'.format(**value_dict) 25 | if type == 'url': 26 | return '{value}'.format(**value_dict) 27 | if type == 'function': 28 | args = ' '.join([output_value(arg) for arg in value_dict['args']]) 29 | return '{name}({args})'.format(name=value_dict['name'], args=args) 30 | if type == 'array': 31 | args = ' '.join([output_value(arg) for arg in value_dict['values']]) 32 | return '{args}'.format(args=args) 33 | -------------------------------------------------------------------------------- /css2video/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import parse_property 2 | from .api import parse_rule 3 | from .api import parse_stylesheet 4 | from .api import parse_value 5 | -------------------------------------------------------------------------------- /css2video/parsers/api.py: -------------------------------------------------------------------------------- 1 | from .property import Property 2 | from .rule import Rule 3 | from .stylesheet import StyleSheet 4 | from .value import Value 5 | 6 | 7 | def parse_value(string): 8 | '''Parse a value''' 9 | return Value.parser().parseString(string)[0] 10 | 11 | 12 | def parse_property(string): 13 | '''Parse a CSS property''' 14 | return Property.parser().parseString(string)[0] 15 | 16 | 17 | def parse_rule(string): 18 | '''Parse a CSS rule''' 19 | return Rule.parser().parseString(string)[0] 20 | 21 | 22 | def parse_stylesheet(string): 23 | '''Parse a stylesheet''' 24 | return StyleSheet.parser().parseString(string)[0] 25 | -------------------------------------------------------------------------------- /css2video/parsers/base.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | 4 | class BaseParser(object): 5 | """ 6 | A base class for all the parsers 7 | 8 | Attributes 9 | ---------- 10 | ParseException : object 11 | exception to be raised when there is a parsing error 12 | 13 | Methods 14 | ------- 15 | grammar : 16 | method to be overrridden by inheriting class which defines the grammar 17 | parse_action : 18 | method to be overrridden by inheriting class which defines the parse 19 | action for the matched token 20 | parser : 21 | returns a parser by setting the parse action to the grammar 22 | parse : 23 | method to parse a string into a dictionary using the grammar and parse 24 | action 25 | """ 26 | 27 | ParseException = None 28 | 29 | @classmethod 30 | def grammar(cls): 31 | """This method is overridden by the class which inherits the 32 | BaseParser. The method should return a grammar""" 33 | raise NotImplemented() 34 | 35 | @classmethod 36 | def parse_action(cls): 37 | """This method is overridden by the class which inherits the 38 | BaseParser. The method should create a dictionary from the parsed 39 | tokens""" 40 | raise NotImplemented() 41 | 42 | @classmethod 43 | def parser(cls): 44 | """Returns the grammar with parse action set so that once the result is 45 | parsed, the output is available as a dictionary""" 46 | return cls.grammar().setParseAction(cls.parse_action) 47 | 48 | @classmethod 49 | def parse(cls, string): 50 | """ 51 | Parse a given string using the grammar and parse action and returns 52 | a dictionary 53 | 54 | Parameters 55 | ---------- 56 | string : str 57 | string to be parsed 58 | 59 | Returns 60 | ------- 61 | dict : 62 | dictionary after parsing the string 63 | """ 64 | try: 65 | return cls.parser().parseString(string, parseAll=True)[0] 66 | except pp.ParseException: 67 | raise cls.ParseException() 68 | -------------------------------------------------------------------------------- /css2video/parsers/property.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .base import BaseParser 4 | from .value import Value 5 | 6 | 7 | class PropertyParseException(Exception): 8 | """Raised when there is an exception while parsing a property""" 9 | pass 10 | 11 | 12 | class Property(BaseParser): 13 | """Parse a CSS property""" 14 | 15 | ParseException = PropertyParseException 16 | 17 | @classmethod 18 | def grammar(cls): 19 | """Grammar to parse a CSS property""" 20 | name = pp.Word(pp.alphas + '-') 21 | value = Value.parser() 22 | return ( 23 | name + 24 | pp.Suppress(pp.Literal(':')) + 25 | value + 26 | pp.Suppress(pp.Literal(";")) 27 | ) 28 | 29 | @classmethod 30 | def parse_action(cls, tokens): 31 | """Returns a dictionary from the parsed tokens""" 32 | return { 33 | 'property_name': tokens[0].lower(), # Normalize the name 34 | 'property_value': tokens[1] 35 | } 36 | -------------------------------------------------------------------------------- /css2video/parsers/rule.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .base import BaseParser 4 | from .property import Property 5 | from css2video.constants import RuleType 6 | 7 | 8 | class StyleParseException(Exception): 9 | """Raised when there is an exception while parsing the style rule""" 10 | pass 11 | 12 | 13 | class Style(BaseParser): 14 | """Parse a CSS style rule""" 15 | 16 | ParseException = StyleParseException 17 | 18 | @classmethod 19 | def grammar(cls): 20 | """Grammar to parse a CSS style rule""" 21 | selector = pp.Word(pp.alphanums + '#.,*>+~[]=|^$:-() ') 22 | return ( 23 | selector + 24 | pp.Suppress(pp.Literal('{')) + 25 | pp.ZeroOrMore(Property.parser()) + 26 | pp.Suppress(pp.Literal('}')) 27 | ) 28 | 29 | @classmethod 30 | def parse_action(cls, tokens): 31 | """Returns a dictionary from the parsed tokens""" 32 | return { 33 | 'type': RuleType.style, 34 | 'selector': tokens[0].strip(), 35 | 'properties': tokens[1:] 36 | } 37 | 38 | 39 | class KeyframePropertiesParseException(Exception): 40 | """Raised when there is an exception while parsing the keyframe 41 | properties""" 42 | pass 43 | 44 | 45 | class KeyframeProperties(BaseParser): 46 | """Parse keyframe properties""" 47 | 48 | ParseException = KeyframePropertiesParseException 49 | 50 | @classmethod 51 | def grammar(cls): 52 | """Grammar to parse keyframe properties""" 53 | # Todo: Handle keyframe properties where there are more than one 54 | # keyframe selectors 55 | keyframe_selector = ( 56 | ( 57 | pp.Word(pp.nums + '.') + 58 | pp.Suppress(pp.Literal('%')).leaveWhitespace() 59 | ) | 60 | pp.Literal('from') | 61 | pp.Literal('to') 62 | ) 63 | return ( 64 | keyframe_selector + 65 | pp.Suppress(pp.Literal('{')) + 66 | pp.ZeroOrMore(Property.parser()) + 67 | pp.Suppress(pp.Literal('}')) 68 | ) 69 | 70 | @classmethod 71 | def parse_action(cls, tokens): 72 | """Returns a dictionary from the parsed tokens""" 73 | if tokens[0] == 'from': 74 | tokens[0] = '0' 75 | elif tokens[0] == 'to': 76 | tokens[0] = '100' 77 | return { 78 | 'selector': float(tokens[0]), 79 | 'properties': tokens[1:] 80 | } 81 | 82 | 83 | class KeyframesParseException(Exception): 84 | """Raised when there is an exception while parsing a keyframes rule""" 85 | pass 86 | 87 | 88 | class Keyframes(BaseParser): 89 | """Parse a CSS keyframe rule""" 90 | 91 | ParseException = KeyframesParseException 92 | 93 | @classmethod 94 | def grammar(cls): 95 | """Grammar to parse a CSS keyframe rule""" 96 | name = pp.Word(pp.alphanums + '_-') 97 | return ( 98 | pp.Suppress(pp.Literal('@keyframes')) + 99 | name + 100 | pp.Suppress(pp.Literal('{')) + 101 | pp.ZeroOrMore(KeyframeProperties.parser()) + 102 | pp.Suppress(pp.Literal('}')) 103 | ) 104 | 105 | @classmethod 106 | def parse_action(cls, tokens): 107 | """Returns a dictionary from the parsed tokens""" 108 | return { 109 | 'type': RuleType.keyframes, 110 | 'name': tokens[0], 111 | 'keyframes': tokens[1:] 112 | } 113 | 114 | 115 | class RuleParseException(Exception): 116 | """Raised when there is an exception while parsing a rule""" 117 | pass 118 | 119 | 120 | class Rule(object): 121 | """Parse any CSS rule""" 122 | 123 | @classmethod 124 | def parser(cls): 125 | """Grammar to parse any CSS rule""" 126 | return ( 127 | Style.parser() ^ 128 | Keyframes.parser() 129 | ) 130 | -------------------------------------------------------------------------------- /css2video/parsers/stylesheet.py: -------------------------------------------------------------------------------- 1 | from .base import BaseParser 2 | from .rule import Rule 3 | import pyparsing as pp 4 | 5 | 6 | class StyleSheet(BaseParser): 7 | '''Parse a stylesheet''' 8 | 9 | @classmethod 10 | def grammar(cls): 11 | '''Grammar to parse a style sheet''' 12 | return pp.ZeroOrMore(Rule.parser()) 13 | 14 | @classmethod 15 | def parse_action(cls, tokens): 16 | '''Returns a dictionary from the parsed tokens''' 17 | return { 18 | 'rules': [rule for rule in tokens] 19 | } 20 | -------------------------------------------------------------------------------- /css2video/parsers/value.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from css2video.constants import ValueType 4 | from .base import BaseParser 5 | 6 | 7 | class NumberParseException(Exception): 8 | """Raised when there is an exception while parsing a number""" 9 | pass 10 | 11 | 12 | class Number(BaseParser): 13 | """Parser to parse a number""" 14 | 15 | ParseException = NumberParseException 16 | 17 | @classmethod 18 | def grammar(cls): 19 | """Returns the grammar to parse a number that may have a sign and a 20 | decimal point""" 21 | sign = pp.Word('+-', exact=1) 22 | digits = pp.Word(pp.nums) 23 | decimal = pp.Combine(pp.Word('.', exact=1) + digits) 24 | return pp.Combine(pp.Optional(sign) + digits + pp.Optional(decimal)) 25 | 26 | @classmethod 27 | def parse_action(cls, tokens): 28 | """Returns a dictionary from the parsed tokens""" 29 | return { 30 | 'type': ValueType.number, 31 | 'value': float(tokens[0]) 32 | } 33 | 34 | 35 | class LengthParseException(Exception): 36 | """Raised when there is an execption while parsing a length""" 37 | pass 38 | 39 | 40 | class Length(BaseParser): 41 | """Parse a length value that has a unit attached along with the number""" 42 | 43 | ParseException = LengthParseException 44 | 45 | UNITS = ['em', 'ex', 'px', 'cm', 'mm', 'in', 'pt', 'pc', 'deg'] 46 | 47 | @classmethod 48 | def grammar(cls): 49 | """Grammar to parse the length value""" 50 | number = Number.grammar() 51 | units = pp.CaselessLiteral(cls.UNITS[0]) 52 | for unit in cls.UNITS[1:]: 53 | units |= pp.Literal(unit) 54 | return number + units.leaveWhitespace() 55 | 56 | @classmethod 57 | def parse_action(cls, tokens): 58 | """Returns a dictionary from the parsed tokens""" 59 | return { 60 | 'type': ValueType.length, 61 | 'value': float(tokens[0]), 62 | 'unit': tokens[1].lower() # normalizing the units 63 | } 64 | 65 | 66 | class PercentageParseException(Exception): 67 | """Raised when there is an exception while parsing a percentage value""" 68 | pass 69 | 70 | 71 | class Percentage(BaseParser): 72 | """Parse a percentage value""" 73 | 74 | ParseException = PercentageParseException 75 | 76 | @classmethod 77 | def grammar(cls): 78 | """Grammar to parse the percentage value""" 79 | number = Number.grammar() 80 | percentage = pp.Literal('%').leaveWhitespace() 81 | return number + percentage 82 | 83 | @classmethod 84 | def parse_action(cls, tokens): 85 | """Returns a dictionary from the parsed tokens""" 86 | return { 87 | 'type': ValueType.percentage, 88 | 'value': float(tokens[0]), 89 | } 90 | 91 | 92 | class TimeParseException(Exception): 93 | """Raised when there is an execption while parsing a time value""" 94 | pass 95 | 96 | 97 | class Time(BaseParser): 98 | """Parse a time value which is in seconds""" 99 | 100 | ParseException = TimeParseException 101 | 102 | @classmethod 103 | def grammar(cls): 104 | """Grammar to parse a time value""" 105 | number = Number.grammar() 106 | seconds = pp.CaselessLiteral('s').leaveWhitespace() 107 | return number + seconds 108 | 109 | @classmethod 110 | def parse_action(cls, tokens): 111 | """Returns a dictionary from the parsed tokens""" 112 | return { 113 | 'type': ValueType.time, 114 | 'value': float(tokens[0]) 115 | } 116 | 117 | 118 | class ColorParseException(Exception): 119 | """Raised when there is an exception while parsing a color value""" 120 | pass 121 | 122 | 123 | class Color(BaseParser): 124 | """Parse a color value. It can be hex, RGB or RGBA""" 125 | 126 | ParseException = ColorParseException 127 | 128 | HEX_CHARS = '0123456789ABCDEFabcdef' 129 | 130 | @classmethod 131 | def grammar(cls): 132 | """Grammar to parse a color value""" 133 | hex3 = ( 134 | pp.Suppress(pp.Literal('#')) + 135 | pp.Word(cls.HEX_CHARS, exact=3).leaveWhitespace() 136 | ) 137 | hex6 = ( 138 | pp.Suppress(pp.Literal('#')) + 139 | pp.Word(cls.HEX_CHARS, exact=6).leaveWhitespace() 140 | ) 141 | rgb = ( 142 | pp.Suppress(pp.CaselessLiteral('rgb(')) + 143 | Number.grammar() + 144 | pp.Suppress(pp.Literal(',')) + 145 | Number.grammar() + 146 | pp.Suppress(pp.Literal(',')) + 147 | Number.grammar() + 148 | pp.Suppress(pp.Literal(')')) 149 | ) 150 | rgba = ( 151 | pp.Suppress(pp.CaselessLiteral('rgba(')) + 152 | Number.grammar() + 153 | pp.Suppress(pp.Literal(',')) + 154 | Number.grammar() + 155 | pp.Suppress(pp.Literal(',')) + 156 | Number.grammar() + 157 | pp.Suppress(pp.Literal(',')) + 158 | Number.grammar() + 159 | pp.Suppress(pp.Literal(')')) 160 | ) 161 | return hex6 | hex3 | rgba | rgb 162 | 163 | @classmethod 164 | def parse_action(cls, tokens): 165 | """Returns a dictionary from the parsed tokens""" 166 | alpha = 1 167 | if len(tokens) == 1: 168 | red, green, blue = cls.hex_to_rgb(tokens[0]) 169 | elif len(tokens) == 3: 170 | red, green, blue = map(float, tokens) 171 | elif len(tokens) == 4: 172 | red, green, blue, alpha = map(float, tokens) 173 | 174 | return { 175 | 'type': ValueType.color, 176 | 'red': red, 177 | 'green': green, 178 | 'blue': blue, 179 | 'alpha': alpha 180 | } 181 | 182 | @classmethod 183 | def hex_to_rgb(cls, hexval): 184 | """Convert hex value to RGB value""" 185 | if len(hexval) == 3: 186 | new_hexval = '' 187 | for c in hexval: 188 | new_hexval += c * 2 189 | hexval = new_hexval 190 | return [int(hexval[i:i + 2], base=16)for i in range(0, 6, 2)] 191 | 192 | 193 | class TextParseException(Exception): 194 | """Raised when there is an exception while parsing a text value""" 195 | pass 196 | 197 | 198 | class Text(BaseParser): 199 | """Parse a text value""" 200 | 201 | ParseException = TextParseException 202 | 203 | @classmethod 204 | def grammar(cls): 205 | """Grammar to parse a text value""" 206 | return pp.Word(pp.alphas + '-') 207 | 208 | @classmethod 209 | def parse_action(cls, tokens): 210 | """Returns a dictionary from the parsed tokens""" 211 | return { 212 | 'type': ValueType.text, 213 | 'value': tokens[0] 214 | } 215 | 216 | 217 | class UrlParseException(Exception): 218 | """Raised when there is an exception while parsing an URL value""" 219 | pass 220 | 221 | 222 | class Url(BaseParser): 223 | """Parse a URL value which is usually a quoted string""" 224 | 225 | ParseException = UrlParseException 226 | 227 | @classmethod 228 | def grammar(cls): 229 | """Grammar to parse a URL value""" 230 | return pp.quotedString 231 | 232 | @classmethod 233 | def parse_action(cls, tokens): 234 | """Returns a dictionary from the parsed tokens""" 235 | return { 236 | 'type': ValueType.url, 237 | 'value': tokens[0] 238 | } 239 | 240 | 241 | class FunctionParseException(Exception): 242 | """Raised when there is an while parsing a function value""" 243 | pass 244 | 245 | 246 | class Function(BaseParser): 247 | """Parse a function value with optional arguments passed""" 248 | 249 | ParseException = FunctionParseException 250 | 251 | @classmethod 252 | def grammar(cls): 253 | """Grammar to parse a function value""" 254 | name = pp.Word(pp.alphas) 255 | args = cls.args_parser() 256 | 257 | return ( 258 | name + 259 | pp.Suppress(pp.Literal('(')) + 260 | args + 261 | pp.Suppress(pp.Literal(')')) 262 | ) 263 | 264 | @classmethod 265 | def parse_action(cls, tokens): 266 | """Returns a dictionary from the parsed tokens""" 267 | return { 268 | 'type': ValueType.function, 269 | 'name': tokens[0], 270 | 'args': tokens[1:] 271 | } 272 | 273 | @classmethod 274 | def args_parser(cls): 275 | """Returns the arguments of the function which can be a Number, Length, 276 | Percentage, Time, Color, Text or URL""" 277 | arg_types = [Number, Length, Percentage, Time, Color, Text, Url] 278 | arg_parser = arg_types[0].parser() 279 | for arg_type in arg_types[1:]: 280 | arg_parser ^= arg_type.parser() 281 | return pp.OneOrMore(arg_parser) 282 | 283 | 284 | class ArrayParseException(Exception): 285 | """Raised when there is an exception while parsing an array value""" 286 | pass 287 | 288 | 289 | class Array(BaseParser): 290 | """Parse an array value. The array item values can be of any type""" 291 | 292 | ParseException = ArrayParseException 293 | 294 | @classmethod 295 | def grammar(cls): 296 | """Grammar to parse an array value""" 297 | array_types = [ 298 | Number, Length, Percentage, Time, Color, Text, Url, Function] 299 | array_parser = array_types[0].parser() 300 | for array_type in array_types[1:]: 301 | array_parser ^= array_type.parser() 302 | return array_parser + pp.OneOrMore(array_parser) 303 | 304 | @classmethod 305 | def parse_action(cls, tokens): 306 | """Returns a dictionary from the parsed tokens""" 307 | return { 308 | 'type': ValueType.array, 309 | 'values': [value for value in tokens] 310 | } 311 | 312 | 313 | class Value(object): 314 | """Parse any CSS property value""" 315 | 316 | @classmethod 317 | def parser(cls): 318 | """Grammar to parse any CSS Property value""" 319 | return ( 320 | Number.parser() ^ 321 | Length.parser() ^ 322 | Percentage.parser() ^ 323 | Time.parser() ^ 324 | Color.parser() ^ 325 | Text.parser() ^ 326 | Url.parser() ^ 327 | Function.parser() ^ 328 | Array.parser() 329 | ) 330 | -------------------------------------------------------------------------------- /css2video/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | from .render import render_animation 2 | -------------------------------------------------------------------------------- /css2video/renderers/image/__init__.py: -------------------------------------------------------------------------------- 1 | from .render import render_image -------------------------------------------------------------------------------- /css2video/renderers/image/base.py: -------------------------------------------------------------------------------- 1 | 2 | class RenderNotImplemented(Exception): 3 | """Exception raised when a renderer class has not implemented the render 4 | function""" 5 | 6 | 7 | class BaseImageRenderer(object): 8 | """Base Renderer class that accepts HTML file path and CSS file path 9 | 10 | Public attributes: 11 | - html_path: path to the HTML file 12 | - css_path: path to the CSS file 13 | - output_path: path where the image has to be stored 14 | - width: width of the image file to be rendered 15 | - height: height of the image file to be rendered 16 | """ 17 | 18 | def __init__( 19 | self, html_path, css_path, output_path, width=800, height=600): 20 | super(BaseImageRenderer, self).__init__() 21 | self.html_path = html_path 22 | self.css_path = css_path 23 | self.output_path = output_path 24 | self.width = width 25 | self.height = height 26 | 27 | def render(self): 28 | """Renders the HTML and CSS as an image. Subclasses should override 29 | this method""" 30 | raise RenderNotImplemented('Render function is not implemented.') 31 | -------------------------------------------------------------------------------- /css2video/renderers/image/cutycapt.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | import time 4 | 5 | from .base import BaseImageRenderer 6 | 7 | 8 | class CutyCaptRenderer(BaseImageRenderer): 9 | """Renderer which uses to CutyCapt to convert HTML, CSS to image""" 10 | 11 | def __init__(self, *args, **kwargs): 12 | super(CutyCaptRenderer, self).__init__(*args, **kwargs) 13 | 14 | def render(self): 15 | """Render the HTML and CSS as an image""" 16 | command_args = { 17 | 'url': 'file:%s' % self.html_path, 18 | 'user-style-path': self.css_path, 19 | 'out': self.output_path, 20 | 'min-width': int(self.width), 21 | 'min-height': int(self.height) 22 | } 23 | args = ' '.join( 24 | ['--%s=%s' % (key, value) for key, value in command_args.items()]) 25 | xvfb_command = ( 26 | 'xvfb-run --server-args="-screen 0, {width}x{height}x24"'.format( 27 | width=self.width, height=self.height 28 | ) 29 | ) 30 | command = '{xvfb} cutycapt {args}'.format(args=args, xvfb=xvfb_command) 31 | 32 | process = subprocess.Popen( 33 | shlex.split(command), 34 | stdout=subprocess.PIPE, 35 | stderr=subprocess.PIPE 36 | ) 37 | okay, errors = process.communicate() 38 | # If we run without sleeping, it skips a few frames 39 | time.sleep(1) 40 | 41 | -------------------------------------------------------------------------------- /css2video/renderers/image/render.py: -------------------------------------------------------------------------------- 1 | from .cutycapt import CutyCaptRenderer 2 | 3 | 4 | def render_image( 5 | html_path, css_path, output_path, width=800, height=600, 6 | renderer='CUTYCAPT'): 7 | """Render HTML CSS as an image""" 8 | 9 | if renderer == 'CUTYCAPT': 10 | renderer = CutyCaptRenderer( 11 | html_path, css_path, output_path, width, height) 12 | 13 | renderer.render() 14 | -------------------------------------------------------------------------------- /css2video/renderers/render.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | from tqdm import tqdm 5 | 6 | from .image import render_image 7 | from .video import render_video 8 | from css2video.interpolators import interpolate_stylesheet 9 | from css2video.parsers.stylesheet import StyleSheet 10 | from css2video.outputters import output_stylesheet 11 | 12 | 13 | def _get_image_sequence(total_frames): 14 | return '%0{digits}d.jpg'.format(digits=len(str(total_frames))) 15 | 16 | 17 | def _get_frame_image_name(frame, total_frames): 18 | image_sequence = _get_image_sequence(total_frames=total_frames) 19 | return image_sequence % frame 20 | 21 | 22 | def render_animation( 23 | html_path, css_path, output_path, duration, framerate=30, width=800, 24 | height=600, image_renderer='CUTYCAPT', video_renderer='FFMPEG'): 25 | 26 | with open(css_path, 'r') as fd: 27 | css = fd.read() 28 | 29 | html_path = os.path.abspath(html_path) 30 | output_path = os.path.abspath(output_path) 31 | 32 | stylesheet_dict = StyleSheet.parse(css) 33 | total_frames = framerate * duration 34 | duration_per_frame = float(duration) / total_frames 35 | 36 | temp_dir = tempfile.mkdtemp() 37 | 38 | for frame in tqdm(range(total_frames)): 39 | time = frame * duration_per_frame 40 | filename = _get_frame_image_name( 41 | frame=frame, total_frames=total_frames) 42 | 43 | temp_stylesheet_dict = interpolate_stylesheet( 44 | stylesheet_dict=stylesheet_dict, time=time) 45 | temp_css = output_stylesheet(stylesheet_dict=temp_stylesheet_dict) 46 | temp_css_path = os.path.join(temp_dir, 'temp.css') 47 | with open(temp_css_path, 'w') as fd: 48 | fd.write(temp_css) 49 | 50 | temp_output_path = os.path.join(temp_dir, filename) 51 | 52 | render_image( 53 | html_path=html_path, css_path=temp_css_path, 54 | output_path=temp_output_path, width=width, height=height, 55 | renderer=image_renderer 56 | ) 57 | 58 | input_image_sequence = os.path.join( 59 | temp_dir, _get_image_sequence(total_frames=total_frames)) 60 | render_video( 61 | image_sequence=input_image_sequence, output_path=output_path, 62 | framerate=framerate, renderer=video_renderer 63 | ) 64 | shutil.rmtree(temp_dir) 65 | -------------------------------------------------------------------------------- /css2video/renderers/video/__init__.py: -------------------------------------------------------------------------------- 1 | from .render import render_video 2 | -------------------------------------------------------------------------------- /css2video/renderers/video/base.py: -------------------------------------------------------------------------------- 1 | 2 | class RenderNotImplemented(Exception): 3 | """Exception raised when a renderer class has not implemented the render 4 | function""" 5 | 6 | 7 | class BaseVideoRenderer(object): 8 | """Base Video Renderer class that accepts a sequence of images, framerate 9 | and outputs a video 10 | 11 | Public attributes 12 | - image_sequence: Sequence of image to be used 13 | - output_path: Path where the output video needs to be saved 14 | - framerate: No of images per second of the video 15 | """ 16 | 17 | def __init__(self, image_sequence, output_path, framerate): 18 | super(BaseVideoRenderer, self).__init__() 19 | self.image_sequence = image_sequence 20 | self.output_path = output_path 21 | self.framerate = framerate 22 | 23 | def render(self): 24 | """Renders the video. Subclasses should override this method""" 25 | raise RenderNotImplemented('Render function is not implemented.') 26 | -------------------------------------------------------------------------------- /css2video/renderers/video/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | 4 | from .base import BaseVideoRenderer 5 | 6 | 7 | class FFMpegRenderer(BaseVideoRenderer): 8 | """Video renderer that uses ffmpeg command""" 9 | 10 | def __init__(self, *args, **kwargs): 11 | super(FFMpegRenderer, self).__init__(*args, **kwargs) 12 | 13 | def render(self): 14 | """Render the video""" 15 | 16 | command_args = { 17 | 'i': self.image_sequence, 18 | 'r': '%d' % self.framerate 19 | } 20 | args = ' '.join( 21 | ['-%s %s' % (key, value) for key, value in command_args.items()]) 22 | command = 'ffmpeg %s %s' % (args, self.output_path) 23 | 24 | process = subprocess.Popen( 25 | shlex.split(command), stdout=subprocess.PIPE, stderr=subprocess.PIPE) 26 | process.wait() 27 | 28 | -------------------------------------------------------------------------------- /css2video/renderers/video/render.py: -------------------------------------------------------------------------------- 1 | from .ffmpeg import FFMpegRenderer 2 | 3 | 4 | def render_video(image_sequence, output_path, framerate=30, renderer='FFMPEG'): 5 | if renderer == 'FFMPEG': 6 | renderer = FFMpegRenderer(image_sequence, output_path, framerate) 7 | renderer.render() 8 | -------------------------------------------------------------------------------- /examples/test.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | div { 7 | background-color: #AAA; 8 | width: 200px; 9 | height: 200px; 10 | animation-name: move; 11 | animation-duration: 1s; 12 | } 13 | 14 | @keyframes move { 15 | 0% { 16 | margin-left: 0px; 17 | margin-top: 0px; 18 | } 19 | 50% { 20 | margin-left: 100px; 21 | margin-top: 100px; 22 | } 23 | 100% { 24 | margin-left: 0px; 25 | margin-top: 0px; 26 | } 27 | } -------------------------------------------------------------------------------- /examples/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Use this example file to render your own HTML CSS to MPEG video. 3 | """ 4 | import os 5 | from css2video.renderers import render_animation 6 | 7 | 8 | directory = os.path.dirname(__file__) 9 | html_path = os.path.join(directory, 'test.html') 10 | css_path = os.path.join(directory, 'test.css') 11 | output_path = os.path.join(directory, 'test.mp4') 12 | 13 | 14 | render_animation( 15 | html_path=html_path, 16 | css_path=css_path, 17 | output_path=output_path, 18 | duration=2, 19 | framerate=30, 20 | width=300, 21 | height=300, 22 | ) 23 | 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyparsing==2.2.0 2 | tqdm==4.19.5 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name='css2video', 5 | version='1.0.0', 6 | author='Sagar Chakravarthy', 7 | author_email='bp.sagar@gmail.com', 8 | packages=find_packages(), 9 | include_package_data=True, 10 | install_requires=[ 11 | 'pyparsing==2.2.0', 12 | 'tqdm==4.19.5' 13 | ], 14 | zip_safe=False 15 | ) 16 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpsagar/css2video/0fc9e0eb6101fcc3a76dfe34ed91cf0d4157fee3/tests/__init__.py -------------------------------------------------------------------------------- /tests/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpsagar/css2video/0fc9e0eb6101fcc3a76dfe34ed91cf0d4157fee3/tests/parsers/__init__.py -------------------------------------------------------------------------------- /tests/parsers/rule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpsagar/css2video/0fc9e0eb6101fcc3a76dfe34ed91cf0d4157fee3/tests/parsers/rule/__init__.py -------------------------------------------------------------------------------- /tests/parsers/rule/test_keyframe.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import RuleType 4 | from css2video.parsers.rule import ( 5 | Property, KeyframeProperties, KeyframePropertiesParseException, 6 | Keyframes, KeyframesParseException 7 | ) 8 | 9 | 10 | class TestCase(unittest.TestCase): 11 | 12 | def test_keyframe_properties_parser(self): 13 | response = KeyframeProperties.parse('from { margin: 10px; }') 14 | self.assertEqual(response['selector'], 0.) 15 | self.assertEqual(response['properties'], [ 16 | Property.parse('margin: 10px;') 17 | ]) 18 | 19 | response = KeyframeProperties.parse('to { padding: 0 5px; }') 20 | self.assertEqual(response['selector'], 100.) 21 | self.assertEqual(response['properties'], [ 22 | Property.parse('padding: 0 5px;') 23 | ]) 24 | 25 | response = KeyframeProperties.parse('50% { padding: 0 5px; }') 26 | self.assertEqual(response['selector'], 50.) 27 | self.assertEqual(response['properties'], [ 28 | Property.parse('padding: 0 5px;') 29 | ]) 30 | 31 | with self.assertRaises(KeyframePropertiesParseException): 32 | KeyframeProperties.parse('50 % { ; }') 33 | 34 | def test_keyframes_parser(self): 35 | keyframes = [ 36 | 'from { margin-left: 1000px; }', 37 | '50% { margin-left: 800px; }', 38 | 'to { margin-left: 0; }' 39 | ] 40 | response = Keyframes.parse(""" 41 | @keyframes slide-in {{ 42 | {keyframes} 43 | }} 44 | """.format(keyframes='\n'.join(keyframes))) 45 | self.assertEqual(response['type'], RuleType.keyframes) 46 | self.assertEqual(response['name'], 'slide-in') 47 | self.assertEqual(response['keyframes'], [ 48 | KeyframeProperties.parse(kf) for kf in keyframes 49 | ]) 50 | 51 | with self.assertRaises(KeyframesParseException): 52 | Keyframes.parse('@keyframes asd { from { ; } }') 53 | -------------------------------------------------------------------------------- /tests/parsers/rule/test_style.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import RuleType, ValueType 4 | from css2video.parsers.rule import Style, StyleParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | def test_parser(self): 9 | response = Style.parse('div { margin: 20px; }') 10 | self.assertEqual(response['type'], RuleType.style) 11 | self.assertEqual(response['selector'], 'div') 12 | self.assertEqual(response['properties'], [ 13 | dict( 14 | property_name='margin', 15 | property_value=dict( 16 | type=ValueType.length, value=20., unit='px' 17 | ) 18 | ) 19 | ]) 20 | 21 | with self.assertRaises(StyleParseException): 22 | Style.parse('div { ; }') 23 | -------------------------------------------------------------------------------- /tests/parsers/test_property.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.property import Property, PropertyParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Property.parse('margin: 20px;') 11 | self.assertEqual(response['property_name'], 'margin') 12 | self.assertEqual(response['property_value'], dict( 13 | type=ValueType.length, value=20., unit='px' 14 | )) 15 | 16 | response = Property.parse(' background-color : #FFF ;') 17 | self.assertEqual(response['property_name'], 'background-color') 18 | self.assertEqual(response['property_value'], dict( 19 | type=ValueType.color, red=255, green=255, blue=255, alpha=1 20 | )) 21 | 22 | with self.assertRaises(PropertyParseException): 23 | Property.parse('rand-adb:- 20px') 24 | -------------------------------------------------------------------------------- /tests/parsers/value/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpsagar/css2video/0fc9e0eb6101fcc3a76dfe34ed91cf0d4157fee3/tests/parsers/value/__init__.py -------------------------------------------------------------------------------- /tests/parsers/value/test_array.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Array, ArrayParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Array.parse('20px rgba(0, 0, 0, 1)') 11 | self.assertEqual( 12 | response, 13 | dict(type=ValueType.array, values=[ 14 | dict(type=ValueType.length, value=20., unit='px'), 15 | dict(type=ValueType.color, red=0, green=0, blue=0, alpha=1) 16 | ]) 17 | ) 18 | 19 | with self.assertRaises(ArrayParseException): 20 | Array.parse('20px, 50px') 21 | -------------------------------------------------------------------------------- /tests/parsers/value/test_color.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Color, ColorParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Color.parse('#ffF') 11 | self.assertEqual( 12 | response, 13 | dict(type=ValueType.color, red=255, green=255, blue=255, alpha=1) 14 | ) 15 | 16 | response = Color.parse('#FFFFFF') 17 | self.assertEqual( 18 | response, 19 | dict(type=ValueType.color, red=255, green=255, blue=255, alpha=1) 20 | ) 21 | 22 | response = Color.parse('rgba(0, 0, 0, 1)') 23 | self.assertEqual( 24 | response, 25 | dict(type=ValueType.color, red=0, green=0, blue=0, alpha=1) 26 | ) 27 | 28 | response = Color.parse('RGB(0, 0, 0)') 29 | self.assertEqual( 30 | response, 31 | dict(type=ValueType.color, red=0, green=0, blue=0, alpha=1) 32 | ) 33 | 34 | with self.assertRaises(ColorParseException): 35 | Color.parse('#FFFF') 36 | 37 | with self.assertRaises(ColorParseException): 38 | Color.parse('rgb(0, 0, 0, 0.9') 39 | -------------------------------------------------------------------------------- /tests/parsers/value/test_function.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Function, FunctionParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Function.parse('url("background.png")') 11 | self.assertEqual( 12 | response, 13 | dict(type=ValueType.function, name='url', args=[ 14 | dict(type=ValueType.url, value='"background.png"') 15 | ]) 16 | ) 17 | 18 | response = Function.parse('translate(0 20px)') 19 | self.assertEqual( 20 | response, 21 | dict(type=ValueType.function, name='translate', args=[ 22 | dict(type=ValueType.number, value=0.), 23 | dict(type=ValueType.length, value=20., unit='px') 24 | ]) 25 | ) 26 | 27 | with self.assertRaises(FunctionParseException): 28 | Function.parse('hello') 29 | -------------------------------------------------------------------------------- /tests/parsers/value/test_length.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Length, LengthParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Length.parse('20px') 11 | self.assertEqual( 12 | response, dict(type=ValueType.length, value=20., unit='px') 13 | ) 14 | 15 | response = Length.parse('-40.5EM') 16 | self.assertEqual( 17 | response, dict(type=ValueType.length, value=-40.5, unit='em') 18 | ) 19 | 20 | with self.assertRaises(LengthParseException): 21 | Length.parse('20') 22 | 23 | with self.assertRaises(LengthParseException): 24 | Length.parse('20 px') 25 | -------------------------------------------------------------------------------- /tests/parsers/value/test_number.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Number, NumberParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Number.parse('20') 11 | self.assertEqual(response, dict(type=ValueType.number, value=20.)) 12 | 13 | response = Number.parse('+1') 14 | self.assertEqual(response, dict(type=ValueType.number, value=1.)) 15 | 16 | response = Number.parse('-20.5') 17 | self.assertEqual(response, dict(type=ValueType.number, value=-20.5)) 18 | 19 | with self.assertRaises(NumberParseException): 20 | Number.parse('ab') 21 | 22 | with self.assertRaises(NumberParseException): 23 | response = Number.parse('20.4.2') 24 | -------------------------------------------------------------------------------- /tests/parsers/value/test_percentage.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Percentage, PercentageParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Percentage.parse('20%') 11 | self.assertEqual( 12 | response, dict(type=ValueType.percentage, value=20.) 13 | ) 14 | 15 | response = Percentage.parse('-40.5%') 16 | self.assertEqual( 17 | response, dict(type=ValueType.percentage, value=-40.5) 18 | ) 19 | 20 | with self.assertRaises(PercentageParseException): 21 | Percentage.parse('20') 22 | 23 | with self.assertRaises(PercentageParseException): 24 | Percentage.parse('20 %') 25 | -------------------------------------------------------------------------------- /tests/parsers/value/test_text.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Text, TextParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Text.parse('inline-block') 11 | self.assertEqual( 12 | response, dict(type=ValueType.text, value='inline-block')) 13 | 14 | with self.assertRaises(TextParseException): 15 | Text.parse('inline block') 16 | -------------------------------------------------------------------------------- /tests/parsers/value/test_time.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Time, TimeParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Time.parse('20s') 11 | self.assertEqual( 12 | response, dict(type=ValueType.time, value=20.) 13 | ) 14 | 15 | response = Time.parse('-40.5S') 16 | self.assertEqual( 17 | response, dict(type=ValueType.time, value=-40.5) 18 | ) 19 | 20 | with self.assertRaises(TimeParseException): 21 | Time.parse('20') 22 | 23 | with self.assertRaises(TimeParseException): 24 | Time.parse('20 s') 25 | -------------------------------------------------------------------------------- /tests/parsers/value/test_url.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.constants import ValueType 4 | from css2video.parsers.value import Url, UrlParseException 5 | 6 | 7 | class TestCase(unittest.TestCase): 8 | 9 | def test_parser(self): 10 | response = Url.parse('"http://www.google.com"') 11 | self.assertEqual( 12 | response, 13 | dict(type=ValueType.url, value='"http://www.google.com"') 14 | ) 15 | 16 | with self.assertRaises(UrlParseException): 17 | Url.parse('random-text') 18 | -------------------------------------------------------------------------------- /tests/test_interpolators.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.interpolators import interpolate_value 4 | from .utils import isEqual 5 | 6 | 7 | class TestInterpolators(unittest.TestCase): 8 | '''Interpolators tests''' 9 | 10 | def test_value(self): 11 | data = [ 12 | ( 13 | {'type': 'number', 'value': 100}, 14 | {'type': 'number', 'value': 0}, 15 | 0.5, 16 | 'linear', 17 | {'type': 'number', 'value': 50}, 18 | ), 19 | ( 20 | { 21 | 'type': 'color', 'red': 0, 'green': 50, 'blue': 200, 22 | 'alpha': 1 23 | }, 24 | { 25 | 'type': 'color', 'red': 0, 'green': 150, 'blue': 0, 26 | 'alpha': 0.5 27 | }, 28 | 0.5, 29 | 'linear', 30 | { 31 | 'type': 'color', 'red': 0, 'green': 100, 'blue': 100, 32 | 'alpha': 0.75 33 | }, 34 | ), 35 | ( 36 | { 37 | 'type': 'array', 38 | 'values': [ 39 | {'type': 'length', 'value': 100, 'unit': 'px'}, 40 | {'type': 'number', 'value': 0}, 41 | ] 42 | }, 43 | { 44 | 'type': 'array', 45 | 'values': [ 46 | {'type': 'length', 'value': 0, 'unit': 'px'}, 47 | {'type': 'number', 'value': 100}, 48 | ] 49 | }, 50 | 0.5, 51 | 'linear', 52 | { 53 | 'type': 'array', 54 | 'values': [ 55 | {'type': 'length', 'value': 50, 'unit': 'px'}, 56 | {'type': 'number', 'value': 50}, 57 | ] 58 | } 59 | ), 60 | ( 61 | { 62 | 'type': 'function', 63 | 'name': 'translateX', 64 | 'args': [ 65 | {'type': 'length', 'value': 100, 'unit': 'px'}, 66 | {'type': 'number', 'value': 0}, 67 | ] 68 | }, 69 | { 70 | 'type': 'function', 71 | 'name': 'translateX', 72 | 'args': [ 73 | {'type': 'length', 'value': 0, 'unit': 'px'}, 74 | {'type': 'number', 'value': 100}, 75 | ] 76 | }, 77 | 0.5, 78 | 'linear', 79 | { 80 | 'type': 'function', 81 | 'name': 'translateX', 82 | 'args': [ 83 | {'type': 'length', 'value': 50, 'unit': 'px'}, 84 | {'type': 'number', 'value': 50}, 85 | ] 86 | } 87 | ) 88 | ] 89 | for v1, v2, f, t, expected_value in data: 90 | v = interpolate_value(v1, v2, f, t) 91 | self.assertTrue(isEqual(v, expected_value)) 92 | -------------------------------------------------------------------------------- /tests/test_outputters.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.outputters import output_property 4 | from css2video.outputters import output_rule 5 | from css2video.outputters import output_stylesheet 6 | from css2video.outputters import output_value 7 | 8 | 9 | class TestParser(unittest.TestCase): 10 | '''Outputters tests''' 11 | 12 | def test_value(self): 13 | '''Tests all types of CSS property values''' 14 | value_data = { 15 | '100': { 16 | 'type': 'number', 17 | 'value': 100 18 | }, 19 | '12.5': { 20 | 'type': 'number', 21 | 'value': 12.5 22 | }, 23 | '20px': { 24 | 'type': 'length', 25 | 'value': 20, 26 | 'unit': 'px' 27 | }, 28 | '10%': { 29 | 'type': 'percentage', 30 | 'value': 10 31 | }, 32 | '0.3s': { 33 | 'type': 'time', 34 | 'value': 0.3 35 | }, 36 | 'rgba(0, 0, 0, 1)': { 37 | 'type': 'color', 38 | 'red': 0, 39 | 'green': 0, 40 | 'blue': 0, 41 | 'alpha': 1 42 | }, 43 | 'rgba(255, 255, 255, 1)': { 44 | 'type': 'color', 45 | 'red': 255, 46 | 'green': 255, 47 | 'blue': 255, 48 | 'alpha': 1 49 | }, 50 | 'translateX(20px 2s)': { 51 | 'type': 'function', 52 | 'name': 'translateX', 53 | 'args': [ 54 | { 55 | 'type': 'length', 56 | 'value': 20, 57 | 'unit': 'px' 58 | }, 59 | { 60 | 'type': 'time', 61 | 'value': 2 62 | }, 63 | ] 64 | }, 65 | '20px rgba(255, 255, 255, 1)': { 66 | 'type': 'array', 67 | 'values': [ 68 | { 69 | 'type': 'length', 70 | 'value': 20, 71 | 'unit': 'px' 72 | }, 73 | { 74 | 'type': 'color', 75 | 'red': 255, 76 | 'green': 255, 77 | 'blue': 255, 78 | 'alpha': 1 79 | } 80 | ] 81 | }, 82 | '"http://www.google.com"': { 83 | 'type': 'url', 84 | 'value': '"http://www.google.com"' 85 | } 86 | } 87 | for expected_value_string, value_dict in value_data.items(): 88 | value_string = output_value(value_dict) 89 | self.assertEqual(value_string, expected_value_string) 90 | 91 | def test_property(self): 92 | property_data = [ 93 | ( 94 | 'background-color: rgba(0, 0, 0, 0.5);', 95 | { 96 | 'property_name': 'background-color', 97 | 'property_value': { 98 | 'type': 'color', 99 | 'red': 0, 100 | 'green': 0, 101 | 'blue': 0, 102 | 'alpha': 0.5 103 | } 104 | } 105 | ), 106 | ( 107 | 'box-shadow: 0px 0px 4px rgba(0, 0, 0, 1);', 108 | { 109 | 'property_name': 'box-shadow', 110 | 'property_value': { 111 | 'type': 'array', 112 | 'values': [ 113 | { 114 | 'type': 'length', 115 | 'value': 0, 116 | 'unit': 'px' 117 | }, 118 | { 119 | 'type': 'length', 120 | 'value': 0, 121 | 'unit': 'px' 122 | }, 123 | { 124 | 'type': 'length', 125 | 'value': 4, 126 | 'unit': 'px' 127 | }, 128 | { 129 | 'type': 'color', 130 | 'red': 0, 131 | 'green': 0, 132 | 'blue': 0, 133 | 'alpha': 1 134 | } 135 | ] 136 | } 137 | } 138 | ) 139 | ] 140 | for expected_property_string, property_dict in property_data: 141 | property_string = output_property(property_dict) 142 | self.assertEqual(property_string, expected_property_string) 143 | 144 | def test_rule(self): 145 | rule_data = [ 146 | ( 147 | ( 148 | 'div {\n' 149 | '\tmargin-top: 20px;\n' 150 | '}' 151 | ), 152 | { 153 | 'type': 'style', 154 | 'selector': 'div', 155 | 'properties': [ 156 | { 157 | 'property_name': 'margin-top', 158 | 'property_value': { 159 | 'type': 'length', 160 | 'value': 20, 161 | 'unit': 'px' 162 | } 163 | } 164 | ] 165 | } 166 | ), 167 | ( 168 | ( 169 | '@keyframes mymove {\n' 170 | '\t0% {\n' 171 | '\t\ttop: 0px;\n' 172 | '\t}\n' 173 | '\t100% {\n' 174 | '\t\ttop: 200px;\n' 175 | '\t}\n' 176 | '}' 177 | ), 178 | { 179 | 'type': 'keyframes', 180 | 'name': 'mymove', 181 | 'keyframes': [ 182 | { 183 | 'keyframe_selector': 0, 184 | 'properties': [ 185 | { 186 | 'property_name': 'top', 187 | 'property_value': { 188 | 'type': 'length', 189 | 'value': 0, 190 | 'unit': 'px' 191 | } 192 | } 193 | ] 194 | }, 195 | { 196 | 'keyframe_selector': 100, 197 | 'properties': [ 198 | { 199 | 'property_name': 'top', 200 | 'property_value': { 201 | 'type': 'length', 202 | 'value': 200, 203 | 'unit': 'px' 204 | } 205 | } 206 | ] 207 | } 208 | ] 209 | } 210 | ) 211 | ] 212 | 213 | for expected_rule_string, rule_dict in rule_data: 214 | rule_string = output_rule(rule_dict) 215 | self.assertEqual(rule_string, expected_rule_string) 216 | 217 | def test_stylesheet(self): 218 | stylesheet_data = [ 219 | ( 220 | ( 221 | 'div {\n' 222 | '\tmargin-top: 20px;\n' 223 | '}\n' 224 | '@keyframes mymove {\n' 225 | '\t0% {\n' 226 | '\t\ttop: 0px;\n' 227 | '\t}\n' 228 | '\t100% {\n' 229 | '\t\ttop: 200px;\n' 230 | '\t}\n' 231 | '}' 232 | ), 233 | { 234 | 'rules': [ 235 | { 236 | 'type': 'style', 237 | 'selector': 'div', 238 | 'properties': [ 239 | { 240 | 'property_name': 'margin-top', 241 | 'property_value': { 242 | 'type': 'length', 243 | 'value': 20, 244 | 'unit': 'px' 245 | } 246 | } 247 | ] 248 | }, 249 | { 250 | 'type': 'keyframes', 251 | 'name': 'mymove', 252 | 'keyframes': [ 253 | { 254 | 'keyframe_selector': 0, 255 | 'properties': [ 256 | { 257 | 'property_name': 'top', 258 | 'property_value': { 259 | 'type': 'length', 260 | 'value': 0, 261 | 'unit': 'px' 262 | } 263 | } 264 | ] 265 | }, 266 | { 267 | 'keyframe_selector': 100, 268 | 'properties': [ 269 | { 270 | 'property_name': 'top', 271 | 'property_value': { 272 | 'type': 'length', 273 | 'value': 200, 274 | 'unit': 'px' 275 | } 276 | } 277 | ] 278 | } 279 | ] 280 | } 281 | ] 282 | } 283 | ) 284 | ] 285 | 286 | for expected_string, stylesheet_dict in stylesheet_data: 287 | string = output_stylesheet(stylesheet_dict) 288 | self.assertEqual(string, expected_string) 289 | -------------------------------------------------------------------------------- /tests/test_parsers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from css2video.parsers import parse_property 4 | from css2video.parsers import parse_rule 5 | from css2video.parsers import parse_stylesheet 6 | from css2video.parsers import parse_value 7 | from .utils import isEqual 8 | 9 | 10 | class TestParser(unittest.TestCase): 11 | '''Parser tests''' 12 | 13 | def test_value(self): 14 | '''Tests all types of CSS property values''' 15 | value_data = { 16 | '100': { 17 | 'type': 'number', 18 | 'value': 100 19 | }, 20 | '12.5': { 21 | 'type': 'number', 22 | 'value': 12.5 23 | }, 24 | '20px': { 25 | 'type': 'length', 26 | 'value': 20, 27 | 'unit': 'px' 28 | }, 29 | '10%': { 30 | 'type': 'percentage', 31 | 'value': 10 32 | }, 33 | '0.3s': { 34 | 'type': 'time', 35 | 'value': 0.3 36 | }, 37 | 'rgba(0, 0, 0, 1)': { 38 | 'type': 'color', 39 | 'red': 0, 40 | 'green': 0, 41 | 'blue': 0, 42 | 'alpha': 1 43 | }, 44 | '#FFF': { 45 | 'type': 'color', 46 | 'red': 255, 47 | 'green': 255, 48 | 'blue': 255, 49 | 'alpha': 1 50 | }, 51 | 'translateX(20px 2s)': { 52 | 'type': 'function', 53 | 'name': 'translateX', 54 | 'args': [ 55 | { 56 | 'type': 'length', 57 | 'value': 20, 58 | 'unit': 'px' 59 | }, 60 | { 61 | 'type': 'time', 62 | 'value': 2 63 | }, 64 | ] 65 | }, 66 | '20px rgba(255, 255, 255, 1)': { 67 | 'type': 'array', 68 | 'values': [ 69 | { 70 | 'type': 'length', 71 | 'value': 20, 72 | 'unit': 'px' 73 | }, 74 | { 75 | 'type': 'color', 76 | 'red': 255, 77 | 'green': 255, 78 | 'blue': 255, 79 | 'alpha': 1 80 | } 81 | ] 82 | }, 83 | '"http://www.google.com"': { 84 | 'type': 'url', 85 | 'value': '"http://www.google.com"' 86 | } 87 | } 88 | for value, expected_parsed_value in value_data.items(): 89 | parsed_value = parse_value(value) 90 | self.assertTrue(isEqual(parsed_value, expected_parsed_value)) 91 | 92 | def test_property(self): 93 | property_data = [ 94 | ( 95 | 'background-color: rgba(0, 0, 0, 0.5);', 96 | { 97 | 'property_name': 'background-color', 98 | 'property_value': { 99 | 'type': 'color', 100 | 'red': 0, 101 | 'green': 0, 102 | 'blue': 0, 103 | 'alpha': 0.5 104 | } 105 | } 106 | ), 107 | ( 108 | 'box-shadow: 0px 0px 4px #000;', 109 | { 110 | 'property_name': 'box-shadow', 111 | 'property_value': { 112 | 'type': 'array', 113 | 'values': [ 114 | { 115 | 'type': 'length', 116 | 'value': 0, 117 | 'unit': 'px' 118 | }, 119 | { 120 | 'type': 'length', 121 | 'value': 0, 122 | 'unit': 'px' 123 | }, 124 | { 125 | 'type': 'length', 126 | 'value': 4, 127 | 'unit': 'px' 128 | }, 129 | { 130 | 'type': 'color', 131 | 'red': 0, 132 | 'green': 0, 133 | 'blue': 0, 134 | 'alpha': 1 135 | } 136 | ] 137 | } 138 | } 139 | ) 140 | ] 141 | for property, expected_parsed_property in property_data: 142 | parsed_property = parse_property(property) 143 | self.assertTrue(parsed_property, expected_parsed_property) 144 | 145 | def test_rule(self): 146 | rule_data = [ 147 | ( 148 | 'div{margin-top: 20px;}', 149 | { 150 | 'type': 'style', 151 | 'selector': 'div', 152 | 'properties': [ 153 | { 154 | 'property_name': 'margin-top', 155 | 'property_value': { 156 | 'type': 'length', 157 | 'value': 20, 158 | 'unit': 'px' 159 | } 160 | } 161 | ] 162 | } 163 | ), 164 | ( 165 | ''' 166 | @keyframes mymove { 167 | from {top: 0px;} 168 | to {top: 200px;} 169 | } 170 | ''', 171 | { 172 | 'type': 'keyframes', 173 | 'name': 'mymove', 174 | 'keyframes': [ 175 | { 176 | 'keyframe_selector': 0, 177 | 'properties': [ 178 | { 179 | 'property_name': 'top', 180 | 'property_value': { 181 | 'type': 'length', 182 | 'value': 0, 183 | 'unit': 'px' 184 | } 185 | } 186 | ] 187 | }, 188 | { 189 | 'keyframe_selector': 100, 190 | 'properties': [ 191 | { 192 | 'property_name': 'top', 193 | 'property_value': { 194 | 'type': 'length', 195 | 'value': 200, 196 | 'unit': 'px' 197 | } 198 | } 199 | ] 200 | } 201 | ] 202 | } 203 | ) 204 | ] 205 | 206 | for rule, expected_parsed_rule in rule_data: 207 | parsed_rule = parse_rule(rule) 208 | self.assertTrue(parsed_rule, expected_parsed_rule) 209 | 210 | def test_stylesheet(self): 211 | stylesheet = ''' 212 | div{margin-top: 20px;} 213 | @keyframes mymove { 214 | from {top: 0px;} 215 | to {top: 200px;} 216 | } 217 | ''' 218 | expected_parsed_stylesheet = { 219 | 'rules': [ 220 | { 221 | 'type': 'style', 222 | 'selector': 'div', 223 | 'properties': [ 224 | { 225 | 'property_name': 'margin-top', 226 | 'property_value': { 227 | 'type': 'length', 228 | 'value': 20, 229 | 'unit': 'px' 230 | } 231 | } 232 | ] 233 | }, 234 | { 235 | 'type': 'keyframes', 236 | 'name': 'mymove', 237 | 'keyframes': [ 238 | { 239 | 'keyframe_selector': 0, 240 | 'properties': [ 241 | { 242 | 'property_name': 'top', 243 | 'property_value': { 244 | 'type': 'length', 245 | 'value': 0, 246 | 'unit': 'px' 247 | } 248 | } 249 | ] 250 | }, 251 | { 252 | 'keyframe_selector': 100, 253 | 'properties': [ 254 | { 255 | 'property_name': 'top', 256 | 'property_value': { 257 | 'type': 'length', 258 | 'value': 200, 259 | 'unit': 'px' 260 | } 261 | } 262 | ] 263 | } 264 | ] 265 | } 266 | ] 267 | } 268 | parsed_stylesheet = parse_stylesheet(stylesheet) 269 | self.assertTrue(isEqual(parsed_stylesheet, expected_parsed_stylesheet)) 270 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | 2 | def isEqual(value1, value2): 3 | '''Compare any two values and returns true if they are equal''' 4 | if isinstance(value1, list) and isinstance(value2, list): 5 | is_equal = True 6 | for v1, v2 in zip(value1, value2): 7 | is_equal = is_equal and isEqual(v1, v2) 8 | if not is_equal: 9 | break 10 | return is_equal 11 | 12 | if isinstance(value1, dict) and isinstance(value2, dict): 13 | is_equal = len(value1.keys()) == len(value2.keys()) 14 | for key1 in value1: 15 | is_equal = is_equal and (key1 in value2) 16 | if not is_equal: 17 | break 18 | is_equal = is_equal and isEqual(value1[key1], value2[key1]) 19 | if not is_equal: 20 | break 21 | return is_equal 22 | return value1 == value2 23 | --------------------------------------------------------------------------------