├── .gitignore ├── LICENSE.txt ├── README.md ├── delta ├── __init__.py ├── base.py ├── deep_eq.py ├── html.py └── op.py ├── playground ├── index.html ├── quill.core.css ├── quill.js └── quill.snow.css ├── pyproject.toml └── tests ├── __init__.py ├── test_compose.py ├── test_delta.py ├── test_diff.py ├── test_helpers.py ├── test_html.py ├── test_op.py └── test_transform.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | .pytest_cache 29 | .vscode 30 | poetry.lock 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # pyenv 58 | .python-version 59 | 60 | # Environments 61 | .env 62 | .venv 63 | env/ 64 | venv/ 65 | ENV/ 66 | env.bak/ 67 | venv.bak/ 68 | 69 | 70 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Brantley Harris 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Delta (Python Port) 3 | 4 | Python port of the javascript Delta library for QuillJS: https://github.com/quilljs/delta 5 | 6 | Some basic pythonizing has been done, but mostly it works exactly like the above library. 7 | 8 | There is no other python specific documentation at this time, sorry. Please see the tests 9 | for reference examples. 10 | 11 | ## Install with [Poetry](https://poetry.eustace.io/docs/#installation) 12 | 13 | With HTML rendering: 14 | 15 | > poetry add -E html quill-delta 16 | 17 | Without HTML rendering: 18 | 19 | > poetry add quill-delta 20 | 21 | ## Install with pip 22 | 23 | Note: If you're using `zsh`, see below. 24 | 25 | With HTML rendering: 26 | 27 | > pip install quill-delta[html] 28 | 29 | With HTML rendering (zsh): 30 | 31 | > pip install quill-delta"[html]" 32 | 33 | Without HTML rendering: 34 | 35 | > pip install quill-delta 36 | 37 | 38 | # Rendering HTML in Python 39 | 40 | This library includes a module `delta.html` that renders html from an operation list, 41 | allowing you to render Quill Delta operations in full from a Python server. 42 | 43 | For example: 44 | 45 | from delta import html 46 | 47 | ops = [ 48 | { "insert":"Quill\nEditor\n\n" }, 49 | { "insert": "bold", 50 | "attributes": {"bold": True}}, 51 | { "insert":" and the " }, 52 | { "insert":"italic", 53 | "attributes": { "italic": True }}, 54 | { "insert":"\n\nNormal\n" }, 55 | ] 56 | 57 | html.render(ops) 58 | 59 | Result (line formatting added for readability): 60 | 61 |

Quill

62 |

Editor

63 |


64 |

bold and the italic

65 |


66 |

Normal

67 | 68 | [See test_html.py](tests/test_html.py) for more examples. 69 | 70 | 71 | # Developing 72 | 73 | ## Setup 74 | If you'd to contribute to quill-delta-python, get started setting your development environment by running: 75 | 76 | Checkout the repository 77 | 78 | > git clone https://github.com/forgeworks/quill-delta-python.git 79 | 80 | Make sure you have python 3 installed, e.g., 81 | 82 | > python --version 83 | 84 | From inside your new quill-delta-python directory: 85 | 86 | > python3 -m venv env 87 | > source env/bin/activate 88 | > pip install poetry 89 | > poetry install -E html 90 | 91 | ## Tests 92 | To run tests do: 93 | 94 | > py.test 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /delta/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Delta 2 | 3 | __version__ = '1.0.1' -------------------------------------------------------------------------------- /delta/base.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import diff_match_patch 3 | 4 | try: 5 | from functools import reduce 6 | except: 7 | pass 8 | 9 | from . import op 10 | 11 | 12 | NULL_CHARACTER = chr(0) 13 | DIFF_EQUAL = 0 14 | DIFF_INSERT = 1 15 | DIFF_DELETE = -1 16 | 17 | 18 | def merge(a, b): 19 | return copy.deepcopy(a or {}).update(b or {}) 20 | 21 | def differ(a, b, timeout=1): 22 | differ = diff_match_patch.diff_match_patch() 23 | differ.Diff_Timeout = timeout 24 | return differ.diff_main(a, b) 25 | 26 | def smallest(*parts): 27 | return min(filter(lambda x: x is not None, parts)) 28 | 29 | 30 | class Delta(object): 31 | def __init__(self, ops=None, **attrs): 32 | if hasattr(ops, 'ops'): 33 | ops = ops.ops 34 | self.ops = ops or [] 35 | self.__dict__.update(attrs) 36 | 37 | def __eq__(self, other): 38 | return self.ops == other.ops 39 | 40 | def __repr__(self): 41 | return "{}({})".format(self.__class__.__name__, self.ops) 42 | 43 | def insert(self, text, **attrs): 44 | if text == "": 45 | return self 46 | new_op = {'insert': text} 47 | if attrs: 48 | new_op['attributes'] = attrs 49 | return self.push(new_op) 50 | 51 | def delete(self, length): 52 | if length <= 0: 53 | return self 54 | return self.push({'delete': length}); 55 | 56 | def retain(self, length, **attrs): 57 | if length <= 0: 58 | return self 59 | new_op = {'retain': length} 60 | if attrs: 61 | new_op['attributes'] = attrs 62 | return self.push(new_op) 63 | 64 | def push(self, operation): 65 | index = len(self.ops) 66 | new_op = copy.deepcopy(operation) 67 | try: 68 | last_op = self.ops[index - 1] 69 | except IndexError: 70 | self.ops.append(new_op) 71 | return self 72 | 73 | if op.type(new_op) == op.type(last_op) == 'delete': 74 | last_op['delete'] += new_op['delete'] 75 | return self 76 | 77 | if op.type(last_op) == 'delete' and op.type(new_op) == 'insert': 78 | index -= 1 79 | try: 80 | last_op = self.ops[index - 1] 81 | except IndexError: 82 | self.ops.insert(0, new_op) 83 | return self 84 | 85 | if new_op.get('attributes') == last_op.get('attributes'): 86 | if isinstance(new_op.get('insert'), str) and isinstance(last_op.get('insert'), str): 87 | last_op['insert'] += new_op['insert'] 88 | return self 89 | 90 | if isinstance(new_op.get('retain'), int) and isinstance(last_op.get('retain'), int): 91 | last_op['retain'] += new_op['retain'] 92 | return self 93 | 94 | self.ops.insert(index, new_op) 95 | return self 96 | 97 | def extend(self, ops): 98 | if hasattr(ops, 'ops'): 99 | ops = ops.ops 100 | if not ops: 101 | return self 102 | self.push(ops[0]) 103 | self.ops.extend(ops[1:]) 104 | return self 105 | 106 | def concat(self, other): 107 | delta = self.__class__(copy.deepcopy(self.ops)) 108 | delta.extend(other) 109 | return delta 110 | 111 | def chop(self): 112 | try: 113 | last_op = self.ops[-1] 114 | if op.type(last_op) == 'retain' and not last_op.get('attributes'): 115 | self.ops.pop() 116 | except IndexError: 117 | pass 118 | return self 119 | 120 | def document(self): 121 | parts = [] 122 | for op in self: 123 | insert = op.get('insert') 124 | if insert: 125 | if isinstance(insert, str): 126 | parts.append(insert) 127 | else: 128 | parts.append(NULL_CHARACTER) 129 | else: 130 | raise ValueError("document() can only be called on Deltas that have only insert ops") 131 | return "".join(parts) 132 | 133 | def __iter__(self): 134 | return iter(self.ops) 135 | 136 | def __getitem__(self, index): 137 | if isinstance(index, int): 138 | start = index 139 | stop = index + 1 140 | 141 | elif isinstance(index, slice): 142 | start = index.start or 0 143 | stop = index.stop or None 144 | 145 | if index.step is not None: 146 | print(index) 147 | raise ValueError("no support for step slices") 148 | 149 | if (start is not None and start < 0) or (stop is not None and stop < 0): 150 | raise ValueError("no support for negative indexing.") 151 | 152 | ops = [] 153 | iter = self.iterator() 154 | index = 0 155 | while iter.has_next(): 156 | if stop is not None and index >= stop: 157 | break 158 | if index < start: 159 | next_op = iter.next(start - index) 160 | else: 161 | if stop is not None: 162 | next_op = iter.next(stop-index) 163 | else: 164 | next_op = iter.next() 165 | ops.append(next_op) 166 | index += op.length(next_op) 167 | 168 | return Delta(ops) 169 | 170 | def __len__(self): 171 | return sum(op.length(o) for o in self.ops) 172 | 173 | def iterator(self): 174 | return op.iterator(self.ops) 175 | 176 | def change_length(self): 177 | length = 0 178 | for operator in self: 179 | if op.type(operator) == 'delete': 180 | length -= operator['delete'] 181 | else: 182 | length += op.length(operator) 183 | return length 184 | 185 | def length(self): 186 | return sum(op.length(o) for o in self) 187 | 188 | def compose(self, other): 189 | self_it = self.iterator() 190 | other_it = other.iterator() 191 | delta = self.__class__() 192 | while self_it.has_next() or other_it.has_next(): 193 | if other_it.peek_type() == 'insert': 194 | delta.push(other_it.next()) 195 | elif self_it.peek_type() == 'delete': 196 | delta.push(self_it.next()) 197 | else: 198 | length = smallest(self_it.peek_length(), other_it.peek_length()) 199 | self_op = self_it.next(length) 200 | other_op = other_it.next(length) 201 | if 'retain' in other_op: 202 | new_op = {} 203 | if 'retain' in self_op: 204 | new_op['retain'] = length 205 | elif 'insert' in self_op: 206 | new_op['insert'] = self_op['insert'] 207 | # Preserve null when composing with a retain, otherwise remove it for inserts 208 | attributes = op.compose(self_op.get('attributes'), other_op.get('attributes'), isinstance(self_op.get('retain'), int)) 209 | if (attributes): 210 | new_op['attributes'] = attributes 211 | delta.push(new_op) 212 | # Other op should be delete, we could be an insert or retain 213 | # Insert + delete cancels out 214 | elif op.type(other_op) == 'delete' and 'retain' in self_op: 215 | delta.push(other_op) 216 | return delta.chop() 217 | 218 | def diff(self, other): 219 | """ 220 | Returns a diff of two *documents*, which is defined as a delta 221 | with only inserts. 222 | """ 223 | if self.ops == other.ops: 224 | return self.__class__() 225 | 226 | self_doc = self.document() 227 | other_doc = other.document() 228 | self_it = self.iterator() 229 | other_it = other.iterator() 230 | 231 | delta = self.__class__() 232 | for code, text in differ(self_doc, other_doc): 233 | length = len(text) 234 | while length > 0: 235 | op_length = 0 236 | if code == DIFF_INSERT: 237 | op_length = min(other_it.peek_length(), length) 238 | delta.push(other_it.next(op_length)) 239 | elif code == DIFF_DELETE: 240 | op_length = min(length, self_it.peek_length()) 241 | self_it.next(op_length) 242 | delta.delete(op_length) 243 | elif code == DIFF_EQUAL: 244 | op_length = min(self_it.peek_length(), other_it.peek_length(), length) 245 | self_op = self_it.next(op_length) 246 | other_op = other_it.next(op_length) 247 | if self_op.get('insert') == other_op.get('insert'): 248 | attributes = op.diff(self_op.get('attributes'), other_op.get('attributes')) 249 | delta.retain(op_length, **(attributes or {})) 250 | else: 251 | delta.push(other_op).delete(op_length) 252 | else: 253 | raise RuntimeError("Diff library returned unknown op code: %r", code) 254 | if op_length == 0: 255 | return 256 | length -= op_length 257 | return delta.chop() 258 | 259 | def each_line(self, fn, newline='\n'): 260 | for line, attributes, index in self.iter_lines(): 261 | if fn(line, attributes, index) is False: 262 | break 263 | 264 | def iter_lines(self, newline='\n'): 265 | iter = self.iterator() 266 | line = self.__class__() 267 | i = 0 268 | while iter.has_next(): 269 | if iter.peek_type() != 'insert': 270 | return 271 | self_op = iter.peek() 272 | start = op.length(self_op) - iter.peek_length() 273 | if isinstance(self_op.get('insert'), str): 274 | index = self_op['insert'][start:].find(newline) 275 | else: 276 | index = -1 277 | 278 | if index < 0: 279 | line.push(iter.next()) 280 | elif index > 0: 281 | line.push(iter.next(index)) 282 | else: 283 | yield line, iter.next(1).get('attributes', {}), i 284 | i += 1 285 | line = Delta() 286 | if len(line) > 0: 287 | yield line, {}, i 288 | 289 | def transform(self, other, priority=False): 290 | if isinstance(other, int): 291 | return self.transform_position(other, priority) 292 | 293 | self_it = self.iterator() 294 | other_it = other.iterator() 295 | delta = Delta() 296 | 297 | while self_it.has_next() or other_it.has_next(): 298 | if self_it.peek_type() == 'insert' and (priority or other_it.peek_type() != 'insert'): 299 | delta.retain(op.length(self_it.next())) 300 | elif other_it.peek_type() == 'insert': 301 | delta.push(other_it.next()) 302 | else: 303 | length = smallest(self_it.peek_length(), other_it.peek_length()) 304 | self_op = self_it.next(length) 305 | other_op = other_it.next(length) 306 | if self_op.get('delete'): 307 | # Our delete either makes their delete redundant or removes their retain 308 | continue 309 | elif other_op.get('delete'): 310 | delta.push(other_op) 311 | else: 312 | # We retain either their retain or insert 313 | delta.retain(length, **(op.transform(self_op.get('attributes'), other_op.get('attributes'), priority) or {})) 314 | 315 | return delta.chop() 316 | 317 | def transform_position(self, index, priority=False): 318 | iter = self.iterator() 319 | offset = 0 320 | while iter.has_next() and offset <= index: 321 | length = iter.peek_length() 322 | next_type = iter.peek_type() 323 | iter.next() 324 | if next_type == 'delete': 325 | index -= min(length, index - offset) 326 | continue 327 | elif next_type == 'insert' and (offset < index or not priority): 328 | index += length 329 | offset += length 330 | return index 331 | -------------------------------------------------------------------------------- /delta/deep_eq.py: -------------------------------------------------------------------------------- 1 | #Copyright (c) 2010-2013 Samuel Sutch [samuel.sutch@gmail.com] 2 | # 3 | #Permission is hereby granted, free of charge, to any person obtaining a copy 4 | #of this software and associated documentation files (the "Software"), to deal 5 | #in the Software without restriction, including without limitation the rights 6 | #to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | #copies of the Software, and to permit persons to whom the Software is 8 | #furnished to do so, subject to the following conditions: 9 | # 10 | #The above copyright notice and this permission notice shall be included in 11 | #all copies or substantial portions of the Software. 12 | # 13 | #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | #IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | #FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | #AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | #LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | #OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | #THE SOFTWARE. 20 | 21 | import datetime, time, functools, operator, types 22 | 23 | default_fudge = datetime.timedelta(seconds=0, microseconds=0, days=0) 24 | 25 | def deep_eq(_v1, _v2, datetime_fudge=default_fudge, _assert=False): 26 | """ 27 | Tests for deep equality between two python data structures recursing 28 | into sub-structures if necessary. Works with all python types including 29 | iterators and generators. This function was dreampt up to test API responses 30 | but could be used for anything. Be careful. With deeply nested structures 31 | you may blow the stack. 32 | 33 | Options: 34 | datetime_fudge => this is a datetime.timedelta object which, when 35 | comparing dates, will accept values that differ 36 | by the number of seconds specified 37 | _assert => passing yes for this will raise an assertion error 38 | when values do not match, instead of returning 39 | false (very useful in combination with pdb) 40 | 41 | Doctests included: 42 | 43 | >>> x1, y1 = ({'a': 'b'}, {'a': 'b'}) 44 | >>> deep_eq(x1, y1) 45 | True 46 | >>> x2, y2 = ({'a': 'b'}, {'b': 'a'}) 47 | >>> deep_eq(x2, y2) 48 | False 49 | >>> x3, y3 = ({'a': {'b': 'c'}}, {'a': {'b': 'c'}}) 50 | >>> deep_eq(x3, y3) 51 | True 52 | >>> x4, y4 = ({'c': 't', 'a': {'b': 'c'}}, {'a': {'b': 'n'}, 'c': 't'}) 53 | >>> deep_eq(x4, y4) 54 | False 55 | >>> x5, y5 = ({'a': [1,2,3]}, {'a': [1,2,3]}) 56 | >>> deep_eq(x5, y5) 57 | True 58 | >>> x6, y6 = ({'a': [1,'b',8]}, {'a': [2,'b',8]}) 59 | >>> deep_eq(x6, y6) 60 | False 61 | >>> x7, y7 = ('a', 'a') 62 | >>> deep_eq(x7, y7) 63 | True 64 | >>> x8, y8 = (['p','n',['asdf']], ['p','n',['asdf']]) 65 | >>> deep_eq(x8, y8) 66 | True 67 | >>> x9, y9 = (['p','n',['asdf',['omg']]], ['p', 'n', ['asdf',['nowai']]]) 68 | >>> deep_eq(x9, y9) 69 | False 70 | >>> x10, y10 = (1, 2) 71 | >>> deep_eq(x10, y10) 72 | False 73 | >>> deep_eq((str(p) for p in xrange(10)), (str(p) for p in xrange(10))) 74 | True 75 | >>> str(deep_eq(range(4), range(4))) 76 | 'True' 77 | >>> deep_eq(xrange(100), xrange(100)) 78 | True 79 | >>> deep_eq(xrange(2), xrange(5)) 80 | False 81 | >>> import datetime 82 | >>> from datetime import datetime as dt 83 | >>> d1, d2 = (dt.now(), dt.now() + datetime.timedelta(seconds=4)) 84 | >>> deep_eq(d1, d2) 85 | False 86 | >>> deep_eq(d1, d2, datetime_fudge=datetime.timedelta(seconds=5)) 87 | True 88 | """ 89 | _deep_eq = functools.partial(deep_eq, datetime_fudge=datetime_fudge, 90 | _assert=_assert) 91 | 92 | def _check_assert(R, a, b, reason=''): 93 | if _assert and not R: 94 | assert 0, "an assertion has failed in deep_eq (%s) %s != %s" % ( 95 | reason, str(a), str(b)) 96 | return R 97 | 98 | def _deep_dict_eq(d1, d2): 99 | k1, k2 = (sorted(d1.keys()), sorted(d2.keys())) 100 | if k1 != k2: # keys should be exactly equal 101 | return _check_assert(False, k1, k2, "keys") 102 | 103 | return _check_assert(operator.eq(sum(_deep_eq(d1[k], d2[k]) 104 | for k in k1), 105 | len(k1)), d1, d2, "dictionaries") 106 | 107 | def _deep_iter_eq(l1, l2): 108 | if len(l1) != len(l2): 109 | return _check_assert(False, l1, l2, "lengths") 110 | return _check_assert(operator.eq(sum(_deep_eq(v1, v2) 111 | for v1, v2 in zip(l1, l2)), 112 | len(l1)), l1, l2, "iterables") 113 | 114 | def op(a, b): 115 | _op = operator.eq 116 | if type(a) == datetime.datetime and type(b) == datetime.datetime: 117 | s = datetime_fudge.seconds 118 | t1, t2 = (time.mktime(a.timetuple()), time.mktime(b.timetuple())) 119 | l = t1 - t2 120 | l = -l if l > 0 else l 121 | return _check_assert((-s if s > 0 else s) <= l, a, b, "dates") 122 | return _check_assert(_op(a, b), a, b, "values") 123 | 124 | c1, c2 = (_v1, _v2) 125 | 126 | # guard against strings because they are iterable and their 127 | # elements yield iterables infinitely. 128 | # I N C E P T I O N 129 | for t in types.StringTypes: 130 | if isinstance(_v1, t): 131 | break 132 | else: 133 | if isinstance(_v1, types.DictType): 134 | op = _deep_dict_eq 135 | else: 136 | try: 137 | c1, c2 = (list(iter(_v1)), list(iter(_v2))) 138 | except TypeError: 139 | c1, c2 = _v1, _v2 140 | else: 141 | op = _deep_iter_eq 142 | 143 | return op(c1, c2) -------------------------------------------------------------------------------- /delta/html.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import wraps 3 | from .base import Delta 4 | from lxml.html import HtmlElement, Element 5 | from lxml import html 6 | from cssutils import parseStyle 7 | 8 | CLASSES = { 9 | 'font': { 10 | 'serif': 'ql-font-serif', 11 | 'monospace': 'ql-font-monospace' 12 | }, 13 | 'size': { 14 | 'small': 'ql-size-small', 15 | 'large': 'ql-size-large', 16 | 'huge': 'ql-size-huge', 17 | } 18 | } 19 | 20 | CODE_BLOCK_CLASS = 'ql-syntax' 21 | VIDEO_IFRAME_CLASS = 'ql-video' 22 | INDENT_CLASS = 'ql-indent-%d' 23 | DIRECTION_CLASS = 'ql-direction-%s' 24 | ALIGN_CLASS = 'ql-align-%s' 25 | 26 | 27 | logger = logging.getLogger('quill') 28 | 29 | 30 | ### Helpers ### 31 | def sub_element(root, *a, **kwargs): 32 | e = root.makeelement(*a, **kwargs) 33 | root.append(e) 34 | return e 35 | 36 | def styled(element, styles): 37 | if element.tag != 'span': 38 | element = sub_element(element, 'span') 39 | declare = parseStyle(element.attrib.get('style', '')) 40 | for k, v in styles.items(): 41 | declare.setProperty(k, v) 42 | element.attrib['style'] = declare.getCssText(' ') 43 | return element 44 | 45 | def classed(element, *classes): 46 | if element.tag != 'span': 47 | element = sub_element(element, 'span') 48 | return add_class(element, *classes) 49 | 50 | def add_class(element, *classes): 51 | current = element.attrib.get('class') 52 | if current: 53 | current = set(current.split()) 54 | else: 55 | current = set() 56 | classes = current.union(set(classes)) 57 | element.attrib['class'] = " ".join(sorted(list(classes))) 58 | return element 59 | 60 | 61 | ### Registry ### 62 | class Format: 63 | all = [] 64 | 65 | def __init__(self, fn, name): 66 | self.all.append(self) 67 | self.name = name 68 | self.fn = fn 69 | self.check_fn = None 70 | 71 | def __repr__(self): 72 | return "<%s %r>" % (self.__class__.__name__, self.name) 73 | 74 | def __call__(self, root, op): 75 | if self._check(op): 76 | try: 77 | el = self.fn(root, op) 78 | except Exception as e: 79 | logger.error("Rendering format failed: %r", e) 80 | el = "" 81 | return el 82 | return root 83 | 84 | def check(self, fn): 85 | self.check_fn = fn 86 | return fn 87 | 88 | def _check(self, op): 89 | if self.check_fn: 90 | return self.check_fn(op) 91 | 92 | attrs = op.get('attributes', None) 93 | if attrs and self.name in attrs: 94 | return True 95 | return False 96 | 97 | def format(fn, name=None, cls=Format): 98 | if isinstance(fn, str): 99 | name = fn 100 | def wrapper(fn): 101 | return format(fn, name, cls) 102 | return wrapper 103 | return cls(fn, name or fn.__name__) 104 | 105 | 106 | class BlockFormat(Format): 107 | """ 108 | Block formats change the entire line through the attrs of the endline, not through 109 | something like the insert. 110 | """ 111 | all = [] 112 | 113 | def __init__(self, fn, name): 114 | self.all.append(self) 115 | self.name = name 116 | self.fn = fn 117 | self.check_fn = None 118 | 119 | def __call__(self, root, attrs): 120 | if self.name in attrs: 121 | root = self.fn(root, attrs) 122 | return root 123 | 124 | def __repr__(self): 125 | return "" % self.name 126 | 127 | 128 | ### Formats ### 129 | @format 130 | def header(root, op): 131 | root.tag = 'h%s' % op['attributes']['header'] 132 | return root 133 | 134 | @format 135 | def strong(root, op): 136 | return sub_element(root, 'strong') 137 | 138 | @format 139 | def bold(root, op): 140 | return strong.fn(root, op) 141 | 142 | @format 143 | def em(root, op): 144 | return sub_element(root, 'em') 145 | 146 | @format 147 | def italic(root, op): 148 | return em.fn(root, 'em') 149 | 150 | @format 151 | def underline(root, op): 152 | return sub_element(root, 'u') 153 | 154 | @format 155 | def strike(root, op): 156 | return sub_element(root, 's') 157 | 158 | @format 159 | def script(root, op): 160 | if op['attributes']['script'] == 'super': 161 | return sub_element(root, 'sup') 162 | if op['attributes']['script'] == 'sub': 163 | return sub_element(root, 'sub') 164 | return root 165 | 166 | @format 167 | def background(root, op): 168 | return styled(root, {'background-color': op['attributes']['background']}) 169 | 170 | @format 171 | def color(root, op): 172 | return styled(root, {'color': op['attributes']['color']}) 173 | 174 | @format 175 | def link(root, op): 176 | el = sub_element(root, 'a') 177 | link = op['attributes']['link'] 178 | 179 | if isinstance(link, str): 180 | el.attrib['href'] = op['attributes']['link'] 181 | elif isinstance(link, dict): 182 | for attrname, attrvalue in link.items(): 183 | el.attrib[attrname] = attrvalue 184 | 185 | return el 186 | 187 | @format 188 | def classes(root, op): 189 | attrs = op.get('attributes', None) 190 | if attrs: 191 | for name, options in CLASSES.items(): 192 | value = op['attributes'].get(name) 193 | if value in options: 194 | root = classed(root, options[value]) 195 | return root 196 | 197 | @classes.check 198 | def classes_check(op): 199 | return True 200 | 201 | @format 202 | def image(root, op): 203 | el = sub_element(root, 'img') 204 | el.attrib['src'] = op['insert']['image'] 205 | attrs = op.get('attributes', None) 206 | if attrs and attrs.get('width', None): 207 | el.attrib['width'] = op['attributes']['width'] 208 | if attrs and attrs.get('height', None): 209 | el.attrib['height'] = op['attributes']['height'] 210 | return el 211 | 212 | @image.check 213 | def image_check(op): 214 | insert = op.get('insert') 215 | return isinstance(insert, dict) and insert.get('image') 216 | 217 | @format 218 | def video(root, op): 219 | attributes = op.get('attributes', {}) 220 | iframe = root.makeelement('iframe') 221 | iframe.attrib.update({ 222 | 'class': VIDEO_IFRAME_CLASS, 223 | 'frameborder': '0', 224 | 'allowfullscreen': 'true', 225 | 'src': op['insert']['video'] 226 | }) 227 | if isinstance(attributes, dict) and attributes.get('align', None): 228 | align_block(iframe, attributes) 229 | root.addprevious(iframe) 230 | return iframe 231 | 232 | @video.check 233 | def video_check(op): 234 | insert = op.get('insert') 235 | return isinstance(insert, dict) and insert.get('video') 236 | 237 | 238 | ### Block Formats ### 239 | LIST_TYPES = {'ordered': 'ol', 'bullet': 'ul'} 240 | 241 | @format('indent', cls=BlockFormat) 242 | def indent(block, attrs): 243 | level = attrs['indent'] 244 | if level >= 1 and level <= 8: 245 | return add_class(block, INDENT_CLASS % level) 246 | return block 247 | 248 | @format('list', cls=BlockFormat) 249 | def list_block(block, attrs): 250 | block.tag = 'li' 251 | previous = block.getprevious() 252 | list_tag = LIST_TYPES.get(attrs['list'], 'ol') 253 | if previous is not None and previous.tag == list_tag: 254 | list_el = previous 255 | else: 256 | list_el = sub_element(block.getparent(), list_tag) 257 | list_el.append(block) 258 | return block 259 | 260 | @format('direction', cls=BlockFormat) 261 | def list_block(block, attrs): 262 | return add_class(block, DIRECTION_CLASS % attrs['direction']) 263 | 264 | @format('align', cls=BlockFormat) 265 | def align_block(block, attrs): 266 | return add_class(block, ALIGN_CLASS % attrs['align']) 267 | 268 | @format('header', cls=BlockFormat) 269 | def header_block(block, attrs): 270 | block.tag = 'h%s' % attrs['header'] 271 | return block 272 | 273 | @format('blockquote', cls=BlockFormat) 274 | def blockquote(block, attrs): 275 | block.tag = 'blockquote' 276 | return block 277 | 278 | @format("code-block") 279 | def code_block(root, op): 280 | root.tag = 'pre' 281 | root.attrib.update({ 282 | 'class': CODE_BLOCK_CLASS, 283 | 'spellcheck': 'false' 284 | }) 285 | return root 286 | 287 | 288 | ### Processors ### 289 | def append_op(root, op): 290 | for fmt in Format.all: 291 | root = fmt(root, op) 292 | 293 | text = op.get('insert') 294 | if isinstance(text, str) and text: 295 | if list(root): 296 | last = root[-1] 297 | if last.tail: 298 | last.tail += text 299 | else: 300 | last.tail = text 301 | else: 302 | if root.text: 303 | root.text += text 304 | else: 305 | root.text = text 306 | 307 | 308 | def append_line(root, delta, attrs, index): 309 | block = sub_element(root, 'p') 310 | 311 | for op in delta.ops: 312 | append_op(block, op) 313 | 314 | if len(block) <= 0 and not block.text: 315 | br = sub_element(block, 'br') 316 | 317 | for fmt in BlockFormat.all: 318 | root = fmt(block, attrs) 319 | 320 | 321 | def render(delta, method='html', pretty=False): 322 | if not isinstance(delta, Delta): 323 | delta = Delta(delta) 324 | 325 | root = html.fragment_fromstring("") 326 | for line, attrs, index in delta.iter_lines(): 327 | append_line(root, line, attrs, index) 328 | 329 | result = "".join( 330 | html.tostring(child, method=method, with_tail=True, encoding='unicode', pretty_print=pretty) 331 | for child in root) 332 | return result 333 | -------------------------------------------------------------------------------- /delta/op.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | 4 | def compose(a, b, keep_null=False): 5 | """ 6 | Compose two operations into one. 7 | 8 | ``keep_null`` [default=false] is a boolean that controls whether None/Null 9 | attributes are retrained. 10 | """ 11 | if a is None: 12 | a = {} 13 | if b is None: 14 | b = {} 15 | 16 | # deep copy b, but get rid of None values if keep_null is falsey 17 | attributes = dict((k, copy.deepcopy(v)) for k, v in b.items() if keep_null or v is not None) 18 | 19 | for k, v in a.items(): 20 | if k not in b: 21 | attributes[k] = copy.deepcopy(v) 22 | 23 | return attributes or None 24 | 25 | 26 | def diff(a, b): 27 | """ 28 | Return the difference between operations a and b. 29 | """ 30 | if a is None: 31 | a = {} 32 | if b is None: 33 | b = {} 34 | 35 | keys = set(a.keys()).union(set(b.keys())) 36 | 37 | attributes = {} 38 | for k in keys: 39 | av, bv = a.get(k, None), b.get(k, None) 40 | if av != bv: 41 | attributes[k] = bv 42 | 43 | return attributes or None 44 | 45 | 46 | def transform(a, b, priority=True): 47 | """ 48 | Return the transformation from operation a to b. 49 | 50 | If ``priority`` is falsey [default=True] then just return b. 51 | """ 52 | if a is None: 53 | a = {} 54 | if b is None: 55 | b = {} 56 | 57 | if not priority: 58 | return b or None 59 | 60 | attributes = {} 61 | for k, v in b.items(): 62 | if k not in a: 63 | attributes[k] = v 64 | 65 | return attributes or None 66 | 67 | 68 | def length_of(op): 69 | typ = type_of(op) 70 | if typ == 'delete': 71 | return op['delete'] 72 | elif typ == 'retain': 73 | return op['retain'] 74 | elif isinstance(op.get('insert'), str): 75 | return len(op['insert']) 76 | else: 77 | return 1 78 | 79 | 80 | def type_of(op): 81 | if not op: 82 | return None 83 | if isinstance(op.get('delete'), int): 84 | return 'delete' 85 | if isinstance(op.get('retain'), int): 86 | return 'retain' 87 | return 'insert' 88 | 89 | 90 | 91 | class Iterator(object): 92 | """ 93 | An iterator that enables itself to break off operations 94 | to exactly the length needed via the ``next()`` method. 95 | """ 96 | def __init__(self, ops=[]): 97 | self.ops = ops 98 | self.reset() 99 | 100 | def reset(self): 101 | self.index = 0 102 | self.offset = 0 103 | 104 | def has_next(self): 105 | return self.peek_length() is not None 106 | 107 | def next(self, length=None): 108 | offset = self.offset 109 | 110 | op = self.peek() 111 | op_type = type_of(op) 112 | if op is None: 113 | return { 'retain': None } 114 | 115 | op_length = length_of(op) 116 | if (length is None or length >= op_length - offset): 117 | length = op_length - offset 118 | self.index += 1 119 | self.offset = 0 120 | else: 121 | self.offset += length 122 | 123 | if op_type == 'delete': 124 | return { 'delete': length } 125 | 126 | result_op = {} 127 | if op.get('attributes'): 128 | result_op['attributes'] = op['attributes'] 129 | 130 | if op_type == 'retain': 131 | result_op['retain'] = length 132 | elif isinstance(op.get('insert'), str): 133 | result_op['insert'] = op['insert'][offset:offset+length] 134 | else: 135 | assert offset == 0 136 | assert length == 1 137 | if 'insert' in op: 138 | result_op['insert'] = op['insert'] 139 | 140 | return result_op 141 | 142 | __next__ = next 143 | 144 | def __length__(self): 145 | return len(self.ops) 146 | 147 | def __iter__(self): 148 | return self 149 | 150 | def peek(self): 151 | try: 152 | return self.ops[self.index] 153 | except IndexError: 154 | return None 155 | 156 | def peek_length(self): 157 | next_op = self.peek() 158 | if next_op is None: 159 | return None 160 | return length_of(next_op) - self.offset 161 | 162 | def peek_type(self): 163 | op = self.peek() 164 | if op is None: 165 | return 'retain' 166 | return type_of(op) 167 | 168 | length = length_of 169 | type = type_of 170 | iterator = lambda x: Iterator(x) -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Quill Playground 5 | 6 | 7 | 8 | 9 | 10 | 47 | 48 | 96 | 97 | 98 |
99 |
100 |
101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 |
145 |
146 | 147 |
148 |
149 |
150 |
151 |
152 |
153 | HI 154 |
155 |
156 |
157 | 158 | -------------------------------------------------------------------------------- /playground/quill.core.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Quill Editor v1.3.2 3 | * https://quilljs.com/ 4 | * Copyright (c) 2014, Jason Chen 5 | * Copyright (c) 2013, salesforce.com 6 | */ 7 | .ql-container { 8 | box-sizing: border-box; 9 | font-family: Helvetica, Arial, sans-serif; 10 | font-size: 13px; 11 | height: 100%; 12 | margin: 0px; 13 | position: relative; 14 | } 15 | .ql-container.ql-disabled .ql-tooltip { 16 | visibility: hidden; 17 | } 18 | .ql-container.ql-disabled .ql-editor ul[data-checked] > li::before { 19 | pointer-events: none; 20 | } 21 | .ql-clipboard { 22 | left: -100000px; 23 | height: 1px; 24 | overflow-y: hidden; 25 | position: absolute; 26 | top: 50%; 27 | } 28 | .ql-clipboard p { 29 | margin: 0; 30 | padding: 0; 31 | } 32 | .ql-editor { 33 | box-sizing: border-box; 34 | line-height: 1.42; 35 | height: 100%; 36 | outline: none; 37 | overflow-y: auto; 38 | padding: 12px 15px; 39 | tab-size: 4; 40 | -moz-tab-size: 4; 41 | text-align: left; 42 | white-space: pre-wrap; 43 | word-wrap: break-word; 44 | } 45 | .ql-editor > * { 46 | cursor: text; 47 | } 48 | .ql-editor p, 49 | .ql-editor ol, 50 | .ql-editor ul, 51 | .ql-editor pre, 52 | .ql-editor blockquote, 53 | .ql-editor h1, 54 | .ql-editor h2, 55 | .ql-editor h3, 56 | .ql-editor h4, 57 | .ql-editor h5, 58 | .ql-editor h6 { 59 | margin: 0; 60 | padding: 0; 61 | counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 62 | } 63 | .ql-editor ol, 64 | .ql-editor ul { 65 | padding-left: 1.5em; 66 | } 67 | .ql-editor ol > li, 68 | .ql-editor ul > li { 69 | list-style-type: none; 70 | } 71 | .ql-editor ul > li::before { 72 | content: '\2022'; 73 | } 74 | .ql-editor ul[data-checked=true], 75 | .ql-editor ul[data-checked=false] { 76 | pointer-events: none; 77 | } 78 | .ql-editor ul[data-checked=true] > li *, 79 | .ql-editor ul[data-checked=false] > li * { 80 | pointer-events: all; 81 | } 82 | .ql-editor ul[data-checked=true] > li::before, 83 | .ql-editor ul[data-checked=false] > li::before { 84 | color: #777; 85 | cursor: pointer; 86 | pointer-events: all; 87 | } 88 | .ql-editor ul[data-checked=true] > li::before { 89 | content: '\2611'; 90 | } 91 | .ql-editor ul[data-checked=false] > li::before { 92 | content: '\2610'; 93 | } 94 | .ql-editor li::before { 95 | display: inline-block; 96 | white-space: nowrap; 97 | width: 1.2em; 98 | } 99 | .ql-editor li:not(.ql-direction-rtl)::before { 100 | margin-left: -1.5em; 101 | margin-right: 0.3em; 102 | text-align: right; 103 | } 104 | .ql-editor li.ql-direction-rtl::before { 105 | margin-left: 0.3em; 106 | margin-right: -1.5em; 107 | } 108 | .ql-editor ol li:not(.ql-direction-rtl), 109 | .ql-editor ul li:not(.ql-direction-rtl) { 110 | padding-left: 1.5em; 111 | } 112 | .ql-editor ol li.ql-direction-rtl, 113 | .ql-editor ul li.ql-direction-rtl { 114 | padding-right: 1.5em; 115 | } 116 | .ql-editor ol li { 117 | counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 118 | counter-increment: list-0; 119 | } 120 | .ql-editor ol li:before { 121 | content: counter(list-0, decimal) '. '; 122 | } 123 | .ql-editor ol li.ql-indent-1 { 124 | counter-increment: list-1; 125 | } 126 | .ql-editor ol li.ql-indent-1:before { 127 | content: counter(list-1, lower-alpha) '. '; 128 | } 129 | .ql-editor ol li.ql-indent-1 { 130 | counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 131 | } 132 | .ql-editor ol li.ql-indent-2 { 133 | counter-increment: list-2; 134 | } 135 | .ql-editor ol li.ql-indent-2:before { 136 | content: counter(list-2, lower-roman) '. '; 137 | } 138 | .ql-editor ol li.ql-indent-2 { 139 | counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9; 140 | } 141 | .ql-editor ol li.ql-indent-3 { 142 | counter-increment: list-3; 143 | } 144 | .ql-editor ol li.ql-indent-3:before { 145 | content: counter(list-3, decimal) '. '; 146 | } 147 | .ql-editor ol li.ql-indent-3 { 148 | counter-reset: list-4 list-5 list-6 list-7 list-8 list-9; 149 | } 150 | .ql-editor ol li.ql-indent-4 { 151 | counter-increment: list-4; 152 | } 153 | .ql-editor ol li.ql-indent-4:before { 154 | content: counter(list-4, lower-alpha) '. '; 155 | } 156 | .ql-editor ol li.ql-indent-4 { 157 | counter-reset: list-5 list-6 list-7 list-8 list-9; 158 | } 159 | .ql-editor ol li.ql-indent-5 { 160 | counter-increment: list-5; 161 | } 162 | .ql-editor ol li.ql-indent-5:before { 163 | content: counter(list-5, lower-roman) '. '; 164 | } 165 | .ql-editor ol li.ql-indent-5 { 166 | counter-reset: list-6 list-7 list-8 list-9; 167 | } 168 | .ql-editor ol li.ql-indent-6 { 169 | counter-increment: list-6; 170 | } 171 | .ql-editor ol li.ql-indent-6:before { 172 | content: counter(list-6, decimal) '. '; 173 | } 174 | .ql-editor ol li.ql-indent-6 { 175 | counter-reset: list-7 list-8 list-9; 176 | } 177 | .ql-editor ol li.ql-indent-7 { 178 | counter-increment: list-7; 179 | } 180 | .ql-editor ol li.ql-indent-7:before { 181 | content: counter(list-7, lower-alpha) '. '; 182 | } 183 | .ql-editor ol li.ql-indent-7 { 184 | counter-reset: list-8 list-9; 185 | } 186 | .ql-editor ol li.ql-indent-8 { 187 | counter-increment: list-8; 188 | } 189 | .ql-editor ol li.ql-indent-8:before { 190 | content: counter(list-8, lower-roman) '. '; 191 | } 192 | .ql-editor ol li.ql-indent-8 { 193 | counter-reset: list-9; 194 | } 195 | .ql-editor ol li.ql-indent-9 { 196 | counter-increment: list-9; 197 | } 198 | .ql-editor ol li.ql-indent-9:before { 199 | content: counter(list-9, decimal) '. '; 200 | } 201 | .ql-editor .ql-indent-1:not(.ql-direction-rtl) { 202 | padding-left: 3em; 203 | } 204 | .ql-editor li.ql-indent-1:not(.ql-direction-rtl) { 205 | padding-left: 4.5em; 206 | } 207 | .ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right { 208 | padding-right: 3em; 209 | } 210 | .ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right { 211 | padding-right: 4.5em; 212 | } 213 | .ql-editor .ql-indent-2:not(.ql-direction-rtl) { 214 | padding-left: 6em; 215 | } 216 | .ql-editor li.ql-indent-2:not(.ql-direction-rtl) { 217 | padding-left: 7.5em; 218 | } 219 | .ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right { 220 | padding-right: 6em; 221 | } 222 | .ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right { 223 | padding-right: 7.5em; 224 | } 225 | .ql-editor .ql-indent-3:not(.ql-direction-rtl) { 226 | padding-left: 9em; 227 | } 228 | .ql-editor li.ql-indent-3:not(.ql-direction-rtl) { 229 | padding-left: 10.5em; 230 | } 231 | .ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right { 232 | padding-right: 9em; 233 | } 234 | .ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right { 235 | padding-right: 10.5em; 236 | } 237 | .ql-editor .ql-indent-4:not(.ql-direction-rtl) { 238 | padding-left: 12em; 239 | } 240 | .ql-editor li.ql-indent-4:not(.ql-direction-rtl) { 241 | padding-left: 13.5em; 242 | } 243 | .ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right { 244 | padding-right: 12em; 245 | } 246 | .ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right { 247 | padding-right: 13.5em; 248 | } 249 | .ql-editor .ql-indent-5:not(.ql-direction-rtl) { 250 | padding-left: 15em; 251 | } 252 | .ql-editor li.ql-indent-5:not(.ql-direction-rtl) { 253 | padding-left: 16.5em; 254 | } 255 | .ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right { 256 | padding-right: 15em; 257 | } 258 | .ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right { 259 | padding-right: 16.5em; 260 | } 261 | .ql-editor .ql-indent-6:not(.ql-direction-rtl) { 262 | padding-left: 18em; 263 | } 264 | .ql-editor li.ql-indent-6:not(.ql-direction-rtl) { 265 | padding-left: 19.5em; 266 | } 267 | .ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right { 268 | padding-right: 18em; 269 | } 270 | .ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right { 271 | padding-right: 19.5em; 272 | } 273 | .ql-editor .ql-indent-7:not(.ql-direction-rtl) { 274 | padding-left: 21em; 275 | } 276 | .ql-editor li.ql-indent-7:not(.ql-direction-rtl) { 277 | padding-left: 22.5em; 278 | } 279 | .ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right { 280 | padding-right: 21em; 281 | } 282 | .ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right { 283 | padding-right: 22.5em; 284 | } 285 | .ql-editor .ql-indent-8:not(.ql-direction-rtl) { 286 | padding-left: 24em; 287 | } 288 | .ql-editor li.ql-indent-8:not(.ql-direction-rtl) { 289 | padding-left: 25.5em; 290 | } 291 | .ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right { 292 | padding-right: 24em; 293 | } 294 | .ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right { 295 | padding-right: 25.5em; 296 | } 297 | .ql-editor .ql-indent-9:not(.ql-direction-rtl) { 298 | padding-left: 27em; 299 | } 300 | .ql-editor li.ql-indent-9:not(.ql-direction-rtl) { 301 | padding-left: 28.5em; 302 | } 303 | .ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right { 304 | padding-right: 27em; 305 | } 306 | .ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right { 307 | padding-right: 28.5em; 308 | } 309 | .ql-editor .ql-video { 310 | display: block; 311 | max-width: 100%; 312 | } 313 | .ql-editor .ql-video.ql-align-center { 314 | margin: 0 auto; 315 | } 316 | .ql-editor .ql-video.ql-align-right { 317 | margin: 0 0 0 auto; 318 | } 319 | .ql-editor .ql-bg-black { 320 | background-color: #000; 321 | } 322 | .ql-editor .ql-bg-red { 323 | background-color: #e60000; 324 | } 325 | .ql-editor .ql-bg-orange { 326 | background-color: #f90; 327 | } 328 | .ql-editor .ql-bg-yellow { 329 | background-color: #ff0; 330 | } 331 | .ql-editor .ql-bg-green { 332 | background-color: #008a00; 333 | } 334 | .ql-editor .ql-bg-blue { 335 | background-color: #06c; 336 | } 337 | .ql-editor .ql-bg-purple { 338 | background-color: #93f; 339 | } 340 | .ql-editor .ql-color-white { 341 | color: #fff; 342 | } 343 | .ql-editor .ql-color-red { 344 | color: #e60000; 345 | } 346 | .ql-editor .ql-color-orange { 347 | color: #f90; 348 | } 349 | .ql-editor .ql-color-yellow { 350 | color: #ff0; 351 | } 352 | .ql-editor .ql-color-green { 353 | color: #008a00; 354 | } 355 | .ql-editor .ql-color-blue { 356 | color: #06c; 357 | } 358 | .ql-editor .ql-color-purple { 359 | color: #93f; 360 | } 361 | .ql-editor .ql-font-serif { 362 | font-family: Georgia, Times New Roman, serif; 363 | } 364 | .ql-editor .ql-font-monospace { 365 | font-family: Monaco, Courier New, monospace; 366 | } 367 | .ql-editor .ql-size-small { 368 | font-size: 0.75em; 369 | } 370 | .ql-editor .ql-size-large { 371 | font-size: 1.5em; 372 | } 373 | .ql-editor .ql-size-huge { 374 | font-size: 2.5em; 375 | } 376 | .ql-editor .ql-direction-rtl { 377 | direction: rtl; 378 | text-align: inherit; 379 | } 380 | .ql-editor .ql-align-center { 381 | text-align: center; 382 | } 383 | .ql-editor .ql-align-justify { 384 | text-align: justify; 385 | } 386 | .ql-editor .ql-align-right { 387 | text-align: right; 388 | } 389 | .ql-editor.ql-blank::before { 390 | color: rgba(0,0,0,0.6); 391 | content: attr(data-placeholder); 392 | font-style: italic; 393 | left: 15px; 394 | pointer-events: none; 395 | position: absolute; 396 | right: 15px; 397 | } 398 | -------------------------------------------------------------------------------- /playground/quill.snow.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Quill Editor v1.3.2 3 | * https://quilljs.com/ 4 | * Copyright (c) 2014, Jason Chen 5 | * Copyright (c) 2013, salesforce.com 6 | */ 7 | .ql-container { 8 | box-sizing: border-box; 9 | font-family: Helvetica, Arial, sans-serif; 10 | font-size: 13px; 11 | height: 100%; 12 | margin: 0px; 13 | position: relative; 14 | } 15 | .ql-container.ql-disabled .ql-tooltip { 16 | visibility: hidden; 17 | } 18 | .ql-container.ql-disabled .ql-editor ul[data-checked] > li::before { 19 | pointer-events: none; 20 | } 21 | .ql-clipboard { 22 | left: -100000px; 23 | height: 1px; 24 | overflow-y: hidden; 25 | position: absolute; 26 | top: 50%; 27 | } 28 | .ql-clipboard p { 29 | margin: 0; 30 | padding: 0; 31 | } 32 | .ql-editor { 33 | box-sizing: border-box; 34 | line-height: 1.42; 35 | height: 100%; 36 | outline: none; 37 | overflow-y: auto; 38 | padding: 12px 15px; 39 | tab-size: 4; 40 | -moz-tab-size: 4; 41 | text-align: left; 42 | white-space: pre-wrap; 43 | word-wrap: break-word; 44 | } 45 | .ql-editor > * { 46 | cursor: text; 47 | } 48 | .ql-editor p, 49 | .ql-editor ol, 50 | .ql-editor ul, 51 | .ql-editor pre, 52 | .ql-editor blockquote, 53 | .ql-editor h1, 54 | .ql-editor h2, 55 | .ql-editor h3, 56 | .ql-editor h4, 57 | .ql-editor h5, 58 | .ql-editor h6 { 59 | margin: 0; 60 | padding: 0; 61 | counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 62 | } 63 | .ql-editor ol, 64 | .ql-editor ul { 65 | padding-left: 1.5em; 66 | } 67 | .ql-editor ol > li, 68 | .ql-editor ul > li { 69 | list-style-type: none; 70 | } 71 | .ql-editor ul > li::before { 72 | content: '\2022'; 73 | } 74 | .ql-editor ul[data-checked=true], 75 | .ql-editor ul[data-checked=false] { 76 | pointer-events: none; 77 | } 78 | .ql-editor ul[data-checked=true] > li *, 79 | .ql-editor ul[data-checked=false] > li * { 80 | pointer-events: all; 81 | } 82 | .ql-editor ul[data-checked=true] > li::before, 83 | .ql-editor ul[data-checked=false] > li::before { 84 | color: #777; 85 | cursor: pointer; 86 | pointer-events: all; 87 | } 88 | .ql-editor ul[data-checked=true] > li::before { 89 | content: '\2611'; 90 | } 91 | .ql-editor ul[data-checked=false] > li::before { 92 | content: '\2610'; 93 | } 94 | .ql-editor li::before { 95 | display: inline-block; 96 | white-space: nowrap; 97 | width: 1.2em; 98 | } 99 | .ql-editor li:not(.ql-direction-rtl)::before { 100 | margin-left: -1.5em; 101 | margin-right: 0.3em; 102 | text-align: right; 103 | } 104 | .ql-editor li.ql-direction-rtl::before { 105 | margin-left: 0.3em; 106 | margin-right: -1.5em; 107 | } 108 | .ql-editor ol li:not(.ql-direction-rtl), 109 | .ql-editor ul li:not(.ql-direction-rtl) { 110 | padding-left: 1.5em; 111 | } 112 | .ql-editor ol li.ql-direction-rtl, 113 | .ql-editor ul li.ql-direction-rtl { 114 | padding-right: 1.5em; 115 | } 116 | .ql-editor ol li { 117 | counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 118 | counter-increment: list-0; 119 | } 120 | .ql-editor ol li:before { 121 | content: counter(list-0, decimal) '. '; 122 | } 123 | .ql-editor ol li.ql-indent-1 { 124 | counter-increment: list-1; 125 | } 126 | .ql-editor ol li.ql-indent-1:before { 127 | content: counter(list-1, lower-alpha) '. '; 128 | } 129 | .ql-editor ol li.ql-indent-1 { 130 | counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 131 | } 132 | .ql-editor ol li.ql-indent-2 { 133 | counter-increment: list-2; 134 | } 135 | .ql-editor ol li.ql-indent-2:before { 136 | content: counter(list-2, lower-roman) '. '; 137 | } 138 | .ql-editor ol li.ql-indent-2 { 139 | counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9; 140 | } 141 | .ql-editor ol li.ql-indent-3 { 142 | counter-increment: list-3; 143 | } 144 | .ql-editor ol li.ql-indent-3:before { 145 | content: counter(list-3, decimal) '. '; 146 | } 147 | .ql-editor ol li.ql-indent-3 { 148 | counter-reset: list-4 list-5 list-6 list-7 list-8 list-9; 149 | } 150 | .ql-editor ol li.ql-indent-4 { 151 | counter-increment: list-4; 152 | } 153 | .ql-editor ol li.ql-indent-4:before { 154 | content: counter(list-4, lower-alpha) '. '; 155 | } 156 | .ql-editor ol li.ql-indent-4 { 157 | counter-reset: list-5 list-6 list-7 list-8 list-9; 158 | } 159 | .ql-editor ol li.ql-indent-5 { 160 | counter-increment: list-5; 161 | } 162 | .ql-editor ol li.ql-indent-5:before { 163 | content: counter(list-5, lower-roman) '. '; 164 | } 165 | .ql-editor ol li.ql-indent-5 { 166 | counter-reset: list-6 list-7 list-8 list-9; 167 | } 168 | .ql-editor ol li.ql-indent-6 { 169 | counter-increment: list-6; 170 | } 171 | .ql-editor ol li.ql-indent-6:before { 172 | content: counter(list-6, decimal) '. '; 173 | } 174 | .ql-editor ol li.ql-indent-6 { 175 | counter-reset: list-7 list-8 list-9; 176 | } 177 | .ql-editor ol li.ql-indent-7 { 178 | counter-increment: list-7; 179 | } 180 | .ql-editor ol li.ql-indent-7:before { 181 | content: counter(list-7, lower-alpha) '. '; 182 | } 183 | .ql-editor ol li.ql-indent-7 { 184 | counter-reset: list-8 list-9; 185 | } 186 | .ql-editor ol li.ql-indent-8 { 187 | counter-increment: list-8; 188 | } 189 | .ql-editor ol li.ql-indent-8:before { 190 | content: counter(list-8, lower-roman) '. '; 191 | } 192 | .ql-editor ol li.ql-indent-8 { 193 | counter-reset: list-9; 194 | } 195 | .ql-editor ol li.ql-indent-9 { 196 | counter-increment: list-9; 197 | } 198 | .ql-editor ol li.ql-indent-9:before { 199 | content: counter(list-9, decimal) '. '; 200 | } 201 | .ql-editor .ql-indent-1:not(.ql-direction-rtl) { 202 | padding-left: 3em; 203 | } 204 | .ql-editor li.ql-indent-1:not(.ql-direction-rtl) { 205 | padding-left: 4.5em; 206 | } 207 | .ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right { 208 | padding-right: 3em; 209 | } 210 | .ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right { 211 | padding-right: 4.5em; 212 | } 213 | .ql-editor .ql-indent-2:not(.ql-direction-rtl) { 214 | padding-left: 6em; 215 | } 216 | .ql-editor li.ql-indent-2:not(.ql-direction-rtl) { 217 | padding-left: 7.5em; 218 | } 219 | .ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right { 220 | padding-right: 6em; 221 | } 222 | .ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right { 223 | padding-right: 7.5em; 224 | } 225 | .ql-editor .ql-indent-3:not(.ql-direction-rtl) { 226 | padding-left: 9em; 227 | } 228 | .ql-editor li.ql-indent-3:not(.ql-direction-rtl) { 229 | padding-left: 10.5em; 230 | } 231 | .ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right { 232 | padding-right: 9em; 233 | } 234 | .ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right { 235 | padding-right: 10.5em; 236 | } 237 | .ql-editor .ql-indent-4:not(.ql-direction-rtl) { 238 | padding-left: 12em; 239 | } 240 | .ql-editor li.ql-indent-4:not(.ql-direction-rtl) { 241 | padding-left: 13.5em; 242 | } 243 | .ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right { 244 | padding-right: 12em; 245 | } 246 | .ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right { 247 | padding-right: 13.5em; 248 | } 249 | .ql-editor .ql-indent-5:not(.ql-direction-rtl) { 250 | padding-left: 15em; 251 | } 252 | .ql-editor li.ql-indent-5:not(.ql-direction-rtl) { 253 | padding-left: 16.5em; 254 | } 255 | .ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right { 256 | padding-right: 15em; 257 | } 258 | .ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right { 259 | padding-right: 16.5em; 260 | } 261 | .ql-editor .ql-indent-6:not(.ql-direction-rtl) { 262 | padding-left: 18em; 263 | } 264 | .ql-editor li.ql-indent-6:not(.ql-direction-rtl) { 265 | padding-left: 19.5em; 266 | } 267 | .ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right { 268 | padding-right: 18em; 269 | } 270 | .ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right { 271 | padding-right: 19.5em; 272 | } 273 | .ql-editor .ql-indent-7:not(.ql-direction-rtl) { 274 | padding-left: 21em; 275 | } 276 | .ql-editor li.ql-indent-7:not(.ql-direction-rtl) { 277 | padding-left: 22.5em; 278 | } 279 | .ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right { 280 | padding-right: 21em; 281 | } 282 | .ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right { 283 | padding-right: 22.5em; 284 | } 285 | .ql-editor .ql-indent-8:not(.ql-direction-rtl) { 286 | padding-left: 24em; 287 | } 288 | .ql-editor li.ql-indent-8:not(.ql-direction-rtl) { 289 | padding-left: 25.5em; 290 | } 291 | .ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right { 292 | padding-right: 24em; 293 | } 294 | .ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right { 295 | padding-right: 25.5em; 296 | } 297 | .ql-editor .ql-indent-9:not(.ql-direction-rtl) { 298 | padding-left: 27em; 299 | } 300 | .ql-editor li.ql-indent-9:not(.ql-direction-rtl) { 301 | padding-left: 28.5em; 302 | } 303 | .ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right { 304 | padding-right: 27em; 305 | } 306 | .ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right { 307 | padding-right: 28.5em; 308 | } 309 | .ql-editor .ql-video { 310 | display: block; 311 | max-width: 100%; 312 | } 313 | .ql-editor .ql-video.ql-align-center { 314 | margin: 0 auto; 315 | } 316 | .ql-editor .ql-video.ql-align-right { 317 | margin: 0 0 0 auto; 318 | } 319 | .ql-editor .ql-bg-black { 320 | background-color: #000; 321 | } 322 | .ql-editor .ql-bg-red { 323 | background-color: #e60000; 324 | } 325 | .ql-editor .ql-bg-orange { 326 | background-color: #f90; 327 | } 328 | .ql-editor .ql-bg-yellow { 329 | background-color: #ff0; 330 | } 331 | .ql-editor .ql-bg-green { 332 | background-color: #008a00; 333 | } 334 | .ql-editor .ql-bg-blue { 335 | background-color: #06c; 336 | } 337 | .ql-editor .ql-bg-purple { 338 | background-color: #93f; 339 | } 340 | .ql-editor .ql-color-white { 341 | color: #fff; 342 | } 343 | .ql-editor .ql-color-red { 344 | color: #e60000; 345 | } 346 | .ql-editor .ql-color-orange { 347 | color: #f90; 348 | } 349 | .ql-editor .ql-color-yellow { 350 | color: #ff0; 351 | } 352 | .ql-editor .ql-color-green { 353 | color: #008a00; 354 | } 355 | .ql-editor .ql-color-blue { 356 | color: #06c; 357 | } 358 | .ql-editor .ql-color-purple { 359 | color: #93f; 360 | } 361 | .ql-editor .ql-font-serif { 362 | font-family: Georgia, Times New Roman, serif; 363 | } 364 | .ql-editor .ql-font-monospace { 365 | font-family: Monaco, Courier New, monospace; 366 | } 367 | .ql-editor .ql-size-small { 368 | font-size: 0.75em; 369 | } 370 | .ql-editor .ql-size-large { 371 | font-size: 1.5em; 372 | } 373 | .ql-editor .ql-size-huge { 374 | font-size: 2.5em; 375 | } 376 | .ql-editor .ql-direction-rtl { 377 | direction: rtl; 378 | text-align: inherit; 379 | } 380 | .ql-editor .ql-align-center { 381 | text-align: center; 382 | } 383 | .ql-editor .ql-align-justify { 384 | text-align: justify; 385 | } 386 | .ql-editor .ql-align-right { 387 | text-align: right; 388 | } 389 | .ql-editor.ql-blank::before { 390 | color: rgba(0,0,0,0.6); 391 | content: attr(data-placeholder); 392 | font-style: italic; 393 | left: 15px; 394 | pointer-events: none; 395 | position: absolute; 396 | right: 15px; 397 | } 398 | .ql-snow.ql-toolbar:after, 399 | .ql-snow .ql-toolbar:after { 400 | clear: both; 401 | content: ''; 402 | display: table; 403 | } 404 | .ql-snow.ql-toolbar button, 405 | .ql-snow .ql-toolbar button { 406 | background: none; 407 | border: none; 408 | cursor: pointer; 409 | display: inline-block; 410 | float: left; 411 | height: 24px; 412 | padding: 3px 5px; 413 | width: 28px; 414 | } 415 | .ql-snow.ql-toolbar button svg, 416 | .ql-snow .ql-toolbar button svg { 417 | float: left; 418 | height: 100%; 419 | } 420 | .ql-snow.ql-toolbar button:active:hover, 421 | .ql-snow .ql-toolbar button:active:hover { 422 | outline: none; 423 | } 424 | .ql-snow.ql-toolbar input.ql-image[type=file], 425 | .ql-snow .ql-toolbar input.ql-image[type=file] { 426 | display: none; 427 | } 428 | .ql-snow.ql-toolbar button:hover, 429 | .ql-snow .ql-toolbar button:hover, 430 | .ql-snow.ql-toolbar button:focus, 431 | .ql-snow .ql-toolbar button:focus, 432 | .ql-snow.ql-toolbar button.ql-active, 433 | .ql-snow .ql-toolbar button.ql-active, 434 | .ql-snow.ql-toolbar .ql-picker-label:hover, 435 | .ql-snow .ql-toolbar .ql-picker-label:hover, 436 | .ql-snow.ql-toolbar .ql-picker-label.ql-active, 437 | .ql-snow .ql-toolbar .ql-picker-label.ql-active, 438 | .ql-snow.ql-toolbar .ql-picker-item:hover, 439 | .ql-snow .ql-toolbar .ql-picker-item:hover, 440 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected, 441 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected { 442 | color: #06c; 443 | } 444 | .ql-snow.ql-toolbar button:hover .ql-fill, 445 | .ql-snow .ql-toolbar button:hover .ql-fill, 446 | .ql-snow.ql-toolbar button:focus .ql-fill, 447 | .ql-snow .ql-toolbar button:focus .ql-fill, 448 | .ql-snow.ql-toolbar button.ql-active .ql-fill, 449 | .ql-snow .ql-toolbar button.ql-active .ql-fill, 450 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill, 451 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill, 452 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill, 453 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill, 454 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill, 455 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill, 456 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill, 457 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill, 458 | .ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill, 459 | .ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill, 460 | .ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill, 461 | .ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill, 462 | .ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill, 463 | .ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill, 464 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, 465 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, 466 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, 467 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, 468 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, 469 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, 470 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill, 471 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill { 472 | fill: #06c; 473 | } 474 | .ql-snow.ql-toolbar button:hover .ql-stroke, 475 | .ql-snow .ql-toolbar button:hover .ql-stroke, 476 | .ql-snow.ql-toolbar button:focus .ql-stroke, 477 | .ql-snow .ql-toolbar button:focus .ql-stroke, 478 | .ql-snow.ql-toolbar button.ql-active .ql-stroke, 479 | .ql-snow .ql-toolbar button.ql-active .ql-stroke, 480 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke, 481 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke, 482 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke, 483 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke, 484 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke, 485 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke, 486 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke, 487 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke, 488 | .ql-snow.ql-toolbar button:hover .ql-stroke-miter, 489 | .ql-snow .ql-toolbar button:hover .ql-stroke-miter, 490 | .ql-snow.ql-toolbar button:focus .ql-stroke-miter, 491 | .ql-snow .ql-toolbar button:focus .ql-stroke-miter, 492 | .ql-snow.ql-toolbar button.ql-active .ql-stroke-miter, 493 | .ql-snow .ql-toolbar button.ql-active .ql-stroke-miter, 494 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter, 495 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter, 496 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, 497 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter, 498 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter, 499 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter, 500 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter, 501 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter { 502 | stroke: #06c; 503 | } 504 | @media (pointer: coarse) { 505 | .ql-snow.ql-toolbar button:hover:not(.ql-active), 506 | .ql-snow .ql-toolbar button:hover:not(.ql-active) { 507 | color: #444; 508 | } 509 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill, 510 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill, 511 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill, 512 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill { 513 | fill: #444; 514 | } 515 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke, 516 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke, 517 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter, 518 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter { 519 | stroke: #444; 520 | } 521 | } 522 | .ql-snow { 523 | box-sizing: border-box; 524 | } 525 | .ql-snow * { 526 | box-sizing: border-box; 527 | } 528 | .ql-snow .ql-hidden { 529 | display: none; 530 | } 531 | .ql-snow .ql-out-bottom, 532 | .ql-snow .ql-out-top { 533 | visibility: hidden; 534 | } 535 | .ql-snow .ql-tooltip { 536 | position: absolute; 537 | transform: translateY(10px); 538 | } 539 | .ql-snow .ql-tooltip a { 540 | cursor: pointer; 541 | text-decoration: none; 542 | } 543 | .ql-snow .ql-tooltip.ql-flip { 544 | transform: translateY(-10px); 545 | } 546 | .ql-snow .ql-formats { 547 | display: inline-block; 548 | vertical-align: middle; 549 | } 550 | .ql-snow .ql-formats:after { 551 | clear: both; 552 | content: ''; 553 | display: table; 554 | } 555 | .ql-snow .ql-stroke { 556 | fill: none; 557 | stroke: #444; 558 | stroke-linecap: round; 559 | stroke-linejoin: round; 560 | stroke-width: 2; 561 | } 562 | .ql-snow .ql-stroke-miter { 563 | fill: none; 564 | stroke: #444; 565 | stroke-miterlimit: 10; 566 | stroke-width: 2; 567 | } 568 | .ql-snow .ql-fill, 569 | .ql-snow .ql-stroke.ql-fill { 570 | fill: #444; 571 | } 572 | .ql-snow .ql-empty { 573 | fill: none; 574 | } 575 | .ql-snow .ql-even { 576 | fill-rule: evenodd; 577 | } 578 | .ql-snow .ql-thin, 579 | .ql-snow .ql-stroke.ql-thin { 580 | stroke-width: 1; 581 | } 582 | .ql-snow .ql-transparent { 583 | opacity: 0.4; 584 | } 585 | .ql-snow .ql-direction svg:last-child { 586 | display: none; 587 | } 588 | .ql-snow .ql-direction.ql-active svg:last-child { 589 | display: inline; 590 | } 591 | .ql-snow .ql-direction.ql-active svg:first-child { 592 | display: none; 593 | } 594 | .ql-snow .ql-editor h1 { 595 | font-size: 2em; 596 | } 597 | .ql-snow .ql-editor h2 { 598 | font-size: 1.5em; 599 | } 600 | .ql-snow .ql-editor h3 { 601 | font-size: 1.17em; 602 | } 603 | .ql-snow .ql-editor h4 { 604 | font-size: 1em; 605 | } 606 | .ql-snow .ql-editor h5 { 607 | font-size: 0.83em; 608 | } 609 | .ql-snow .ql-editor h6 { 610 | font-size: 0.67em; 611 | } 612 | .ql-snow .ql-editor a { 613 | text-decoration: underline; 614 | } 615 | .ql-snow .ql-editor blockquote { 616 | border-left: 4px solid #ccc; 617 | margin-bottom: 5px; 618 | margin-top: 5px; 619 | padding-left: 16px; 620 | } 621 | .ql-snow .ql-editor code, 622 | .ql-snow .ql-editor pre { 623 | background-color: #f0f0f0; 624 | border-radius: 3px; 625 | } 626 | .ql-snow .ql-editor pre { 627 | white-space: pre-wrap; 628 | margin-bottom: 5px; 629 | margin-top: 5px; 630 | padding: 5px 10px; 631 | } 632 | .ql-snow .ql-editor code { 633 | font-size: 85%; 634 | padding: 2px 4px; 635 | } 636 | .ql-snow .ql-editor pre.ql-syntax { 637 | background-color: #23241f; 638 | color: #f8f8f2; 639 | overflow: visible; 640 | } 641 | .ql-snow .ql-editor img { 642 | max-width: 100%; 643 | } 644 | .ql-snow .ql-picker { 645 | color: #444; 646 | display: inline-block; 647 | float: left; 648 | font-size: 14px; 649 | font-weight: 500; 650 | height: 24px; 651 | position: relative; 652 | vertical-align: middle; 653 | } 654 | .ql-snow .ql-picker-label { 655 | cursor: pointer; 656 | display: inline-block; 657 | height: 100%; 658 | padding-left: 8px; 659 | padding-right: 2px; 660 | position: relative; 661 | width: 100%; 662 | } 663 | .ql-snow .ql-picker-label::before { 664 | display: inline-block; 665 | line-height: 22px; 666 | } 667 | .ql-snow .ql-picker-options { 668 | background-color: #fff; 669 | display: none; 670 | min-width: 100%; 671 | padding: 4px 8px; 672 | position: absolute; 673 | white-space: nowrap; 674 | } 675 | .ql-snow .ql-picker-options .ql-picker-item { 676 | cursor: pointer; 677 | display: block; 678 | padding-bottom: 5px; 679 | padding-top: 5px; 680 | } 681 | .ql-snow .ql-picker.ql-expanded .ql-picker-label { 682 | color: #ccc; 683 | z-index: 2; 684 | } 685 | .ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill { 686 | fill: #ccc; 687 | } 688 | .ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke { 689 | stroke: #ccc; 690 | } 691 | .ql-snow .ql-picker.ql-expanded .ql-picker-options { 692 | display: block; 693 | margin-top: -1px; 694 | top: 100%; 695 | z-index: 1; 696 | } 697 | .ql-snow .ql-color-picker, 698 | .ql-snow .ql-icon-picker { 699 | width: 28px; 700 | } 701 | .ql-snow .ql-color-picker .ql-picker-label, 702 | .ql-snow .ql-icon-picker .ql-picker-label { 703 | padding: 2px 4px; 704 | } 705 | .ql-snow .ql-color-picker .ql-picker-label svg, 706 | .ql-snow .ql-icon-picker .ql-picker-label svg { 707 | right: 4px; 708 | } 709 | .ql-snow .ql-icon-picker .ql-picker-options { 710 | padding: 4px 0px; 711 | } 712 | .ql-snow .ql-icon-picker .ql-picker-item { 713 | height: 24px; 714 | width: 24px; 715 | padding: 2px 4px; 716 | } 717 | .ql-snow .ql-color-picker .ql-picker-options { 718 | padding: 3px 5px; 719 | width: 152px; 720 | } 721 | .ql-snow .ql-color-picker .ql-picker-item { 722 | border: 1px solid transparent; 723 | float: left; 724 | height: 16px; 725 | margin: 2px; 726 | padding: 0px; 727 | width: 16px; 728 | } 729 | .ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg { 730 | position: absolute; 731 | margin-top: -9px; 732 | right: 0; 733 | top: 50%; 734 | width: 18px; 735 | } 736 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=''])::before, 737 | .ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=''])::before, 738 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=''])::before, 739 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=''])::before, 740 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=''])::before, 741 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=''])::before { 742 | content: attr(data-label); 743 | } 744 | .ql-snow .ql-picker.ql-header { 745 | width: 98px; 746 | } 747 | .ql-snow .ql-picker.ql-header .ql-picker-label::before, 748 | .ql-snow .ql-picker.ql-header .ql-picker-item::before { 749 | content: 'Normal'; 750 | } 751 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before, 752 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before { 753 | content: 'Heading 1'; 754 | } 755 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before, 756 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before { 757 | content: 'Heading 2'; 758 | } 759 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before, 760 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before { 761 | content: 'Heading 3'; 762 | } 763 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before, 764 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before { 765 | content: 'Heading 4'; 766 | } 767 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before, 768 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before { 769 | content: 'Heading 5'; 770 | } 771 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before, 772 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before { 773 | content: 'Heading 6'; 774 | } 775 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before { 776 | font-size: 2em; 777 | } 778 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before { 779 | font-size: 1.5em; 780 | } 781 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before { 782 | font-size: 1.17em; 783 | } 784 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before { 785 | font-size: 1em; 786 | } 787 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before { 788 | font-size: 0.83em; 789 | } 790 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before { 791 | font-size: 0.67em; 792 | } 793 | .ql-snow .ql-picker.ql-font { 794 | width: 108px; 795 | } 796 | .ql-snow .ql-picker.ql-font .ql-picker-label::before, 797 | .ql-snow .ql-picker.ql-font .ql-picker-item::before { 798 | content: 'Sans Serif'; 799 | } 800 | .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before, 801 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before { 802 | content: 'Serif'; 803 | } 804 | .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before, 805 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before { 806 | content: 'Monospace'; 807 | } 808 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before { 809 | font-family: Georgia, Times New Roman, serif; 810 | } 811 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before { 812 | font-family: Monaco, Courier New, monospace; 813 | } 814 | .ql-snow .ql-picker.ql-size { 815 | width: 98px; 816 | } 817 | .ql-snow .ql-picker.ql-size .ql-picker-label::before, 818 | .ql-snow .ql-picker.ql-size .ql-picker-item::before { 819 | content: 'Normal'; 820 | } 821 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before, 822 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before { 823 | content: 'Small'; 824 | } 825 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before, 826 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before { 827 | content: 'Large'; 828 | } 829 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before, 830 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before { 831 | content: 'Huge'; 832 | } 833 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before { 834 | font-size: 10px; 835 | } 836 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before { 837 | font-size: 18px; 838 | } 839 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before { 840 | font-size: 32px; 841 | } 842 | .ql-snow .ql-color-picker.ql-background .ql-picker-item { 843 | background-color: #fff; 844 | } 845 | .ql-snow .ql-color-picker.ql-color .ql-picker-item { 846 | background-color: #000; 847 | } 848 | .ql-toolbar.ql-snow { 849 | border: 1px solid #ccc; 850 | box-sizing: border-box; 851 | font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; 852 | padding: 8px; 853 | } 854 | .ql-toolbar.ql-snow .ql-formats { 855 | margin-right: 15px; 856 | } 857 | .ql-toolbar.ql-snow .ql-picker-label { 858 | border: 1px solid transparent; 859 | } 860 | .ql-toolbar.ql-snow .ql-picker-options { 861 | border: 1px solid transparent; 862 | box-shadow: rgba(0,0,0,0.2) 0 2px 8px; 863 | } 864 | .ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label { 865 | border-color: #ccc; 866 | } 867 | .ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options { 868 | border-color: #ccc; 869 | } 870 | .ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected, 871 | .ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover { 872 | border-color: #000; 873 | } 874 | .ql-toolbar.ql-snow + .ql-container.ql-snow { 875 | border-top: 0px; 876 | } 877 | .ql-snow .ql-tooltip { 878 | background-color: #fff; 879 | border: 1px solid #ccc; 880 | box-shadow: 0px 0px 5px #ddd; 881 | color: #444; 882 | padding: 5px 12px; 883 | white-space: nowrap; 884 | } 885 | .ql-snow .ql-tooltip::before { 886 | content: "Visit URL:"; 887 | line-height: 26px; 888 | margin-right: 8px; 889 | } 890 | .ql-snow .ql-tooltip input[type=text] { 891 | display: none; 892 | border: 1px solid #ccc; 893 | font-size: 13px; 894 | height: 26px; 895 | margin: 0px; 896 | padding: 3px 5px; 897 | width: 170px; 898 | } 899 | .ql-snow .ql-tooltip a.ql-preview { 900 | display: inline-block; 901 | max-width: 200px; 902 | overflow-x: hidden; 903 | text-overflow: ellipsis; 904 | vertical-align: top; 905 | } 906 | .ql-snow .ql-tooltip a.ql-action::after { 907 | border-right: 1px solid #ccc; 908 | content: 'Edit'; 909 | margin-left: 16px; 910 | padding-right: 8px; 911 | } 912 | .ql-snow .ql-tooltip a.ql-remove::before { 913 | content: 'Remove'; 914 | margin-left: 8px; 915 | } 916 | .ql-snow .ql-tooltip a { 917 | line-height: 26px; 918 | } 919 | .ql-snow .ql-tooltip.ql-editing a.ql-preview, 920 | .ql-snow .ql-tooltip.ql-editing a.ql-remove { 921 | display: none; 922 | } 923 | .ql-snow .ql-tooltip.ql-editing input[type=text] { 924 | display: inline-block; 925 | } 926 | .ql-snow .ql-tooltip.ql-editing a.ql-action::after { 927 | border-right: 0px; 928 | content: 'Save'; 929 | padding-right: 0px; 930 | } 931 | .ql-snow .ql-tooltip[data-mode=link]::before { 932 | content: "Enter link:"; 933 | } 934 | .ql-snow .ql-tooltip[data-mode=formula]::before { 935 | content: "Enter formula:"; 936 | } 937 | .ql-snow .ql-tooltip[data-mode=video]::before { 938 | content: "Enter video:"; 939 | } 940 | .ql-snow a { 941 | color: #06c; 942 | } 943 | .ql-container.ql-snow { 944 | border: 1px solid #ccc; 945 | } 946 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "quill-delta" 3 | version = "1.0.3" 4 | description = "Python port of the quill.js delta library that enables operational transformation with aditional functionality for rendering html" 5 | authors = ["Brantley Harris "] 6 | license = "MIT" 7 | homepage = "https://github.com/forgeworks/delta-py" 8 | repository = "https://github.com/forgeworks/delta-py" 9 | readme = "README.md" 10 | packages = [ 11 | { include = "delta" }, 12 | ] 13 | classifiers=[ 14 | 'Development Status :: 5 - Production/Stable', 15 | 'Intended Audience :: Developers', 16 | 'License :: OSI Approved :: MIT License', 17 | 'Natural Language :: English', 18 | 'Programming Language :: Python :: 3', 19 | 'Programming Language :: Python :: 3.6', 20 | 'Programming Language :: Python :: 3.7', 21 | 'Programming Language :: Python :: 3.8', 22 | 'Topic :: Text Processing :: Markup' 23 | ] 24 | 25 | [tool.poetry.dependencies] 26 | python = "^3.6" 27 | diff-match-patch = "^20181111.0" 28 | lxml = {version = "^4.3", optional = true} 29 | cssutils = {version = "^1.0", optional = true} 30 | 31 | [tool.poetry.extras] 32 | html = ["lxml", "cssutils"] 33 | 34 | [tool.poetry.dev-dependencies] 35 | pytest = "^4.5" 36 | mock = "^3.0" 37 | 38 | [build-system] 39 | requires = ["poetry>=0.12"] 40 | build-backend = "poetry.masonry.api" 41 | 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forgeworks/quill-delta-python/80c3c3e4a6c63f4b3de5dbe07b05a0837101452f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_compose.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from delta import Delta 3 | 4 | 5 | def test_insert_and_insert(): 6 | a = Delta().insert('A') 7 | b = Delta().insert('B') 8 | expected = Delta().insert('B').insert('A') 9 | 10 | assert a.compose(b) == expected 11 | 12 | 13 | def test_insert_and_retain(): 14 | a = Delta().insert('A') 15 | b = Delta().retain(1, bold=True, color='red', font=None) 16 | expected = Delta().insert('A', bold=True, color='red') 17 | 18 | assert a.compose(b) == expected 19 | 20 | 21 | def test_insert_and_delete(): 22 | a = Delta().insert('A') 23 | b = Delta().delete(1) 24 | expected = Delta() 25 | 26 | assert a.compose(b) == expected 27 | 28 | 29 | def test_delete_and_insert(): 30 | a = Delta().delete(1) 31 | b = Delta().insert('B') 32 | expected = Delta().insert('B').delete(1) 33 | 34 | assert a.compose(b) == expected 35 | 36 | 37 | def test_delete_and_retain(): 38 | a = Delta().delete(1) 39 | b = Delta().retain(1, bold=True, color='red') 40 | expected = Delta().delete(1).retain(1, bold=True, color='red') 41 | 42 | assert a.compose(b) == expected 43 | 44 | 45 | def test_delete_and_delete(): 46 | a = Delta().delete(1) 47 | b = Delta().delete(1) 48 | expected = Delta().delete(2) 49 | 50 | assert a.compose(b) == expected 51 | 52 | 53 | def test_retain_and_insert(): 54 | a = Delta().retain(1, color='blue') 55 | b = Delta().insert('B') 56 | expected = Delta().insert('B').retain(1, color='blue') 57 | 58 | assert a.compose(b) == expected 59 | 60 | 61 | def test_retain_and_retain(): 62 | a = Delta().retain(1, color='blue') 63 | b = Delta().retain(1, bold=True, color='red', font=None) 64 | expected = Delta().retain(1, bold=True, color='red', font=None) 65 | 66 | assert a.compose(b) == expected 67 | 68 | 69 | def test_retain_and_delete(): 70 | a = Delta().retain(1, color='blue') 71 | b = Delta().delete(1) 72 | expected = Delta().delete(1) 73 | 74 | assert a.compose(b) == expected 75 | 76 | 77 | def test_insert_in_middle_of_text(): 78 | a = Delta().insert('Hello') 79 | b = Delta().retain(3).insert('X') 80 | expected = Delta().insert('HelXlo') 81 | 82 | assert a.compose(b) == expected 83 | 84 | 85 | def test_insert_and_delete_ordering(): 86 | a = Delta().insert('Hello') 87 | b = Delta().insert('Hello') 88 | insertFirst = Delta().retain(3).insert('X').delete(1) 89 | deleteFirst = Delta().retain(3).delete(1).insert('X') 90 | expected = Delta().insert('HelXo') 91 | 92 | assert a.compose(insertFirst) == expected 93 | assert a.compose(deleteFirst) == expected 94 | 95 | 96 | def test_insert_embed(): 97 | a = Delta().insert(1, src='http://quilljs.com/image.png') 98 | b = Delta().retain(1, alt='logo') 99 | expected = Delta().insert(1, src='http://quilljs.com/image.png', alt='logo') 100 | 101 | assert a.compose(b) == expected 102 | 103 | 104 | def test_delete_entire_text(): 105 | a = Delta().retain(4).insert('Hello') 106 | b = Delta().delete(9) 107 | expected = Delta().delete(4) 108 | 109 | assert a.compose(b) == expected 110 | 111 | 112 | def test_retain_more_than_length_of_text(): 113 | a = Delta().insert('Hello') 114 | b = Delta().retain(10) 115 | expected = Delta().insert('Hello') 116 | 117 | assert a.compose(b) == expected 118 | 119 | 120 | def test_retain_empty_embed(): 121 | a = Delta().insert(1) 122 | b = Delta().retain(1) 123 | expected = Delta().insert(1) 124 | 125 | assert a.compose(b) == expected 126 | 127 | 128 | def test_remove_all_attributes(): 129 | a = Delta().insert('A', bold=True) 130 | b = Delta().retain(1, bold=None) 131 | expected = Delta().insert('A') 132 | 133 | assert a.compose(b) == expected 134 | 135 | 136 | def test_remove_all_embed_attributes(): 137 | a = Delta().insert(2, bold=True) 138 | b = Delta().retain(1, bold=None) 139 | expected = Delta().insert(2) 140 | 141 | assert a.compose(b) == expected 142 | 143 | 144 | def test_immutability(): 145 | attr1 = { 'bold': True }; 146 | attr2 = { 'bold': True }; 147 | a1 = Delta().insert('Test', **attr1); 148 | a2 = Delta().insert('Test', **attr1); 149 | b1 = Delta().retain(1, color='red').delete(2); 150 | b2 = Delta().retain(1, color='red').delete(2); 151 | expected = Delta().insert('T', color='red', bold=True).insert('t', **attr1) 152 | 153 | assert a1.compose(b1) == expected 154 | assert a1 == a2 155 | assert b1 == b2 156 | assert attr1 == attr2 157 | 158 | -------------------------------------------------------------------------------- /tests/test_delta.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | from delta.base import Delta 5 | 6 | 7 | def test_creation(): 8 | d = Delta() 9 | d = Delta([]) 10 | d = Delta(d) 11 | 12 | -------------------------------------------------------------------------------- /tests/test_diff.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from delta import Delta 3 | 4 | 5 | def test_insert(): 6 | a = Delta() 7 | a.insert('A') 8 | 9 | b = Delta() 10 | b.insert('AB') 11 | 12 | expected = Delta().retain(1).insert('B') 13 | assert a.diff(b) == expected 14 | 15 | 16 | def test_delete(): 17 | a = Delta() 18 | a.insert('AB') 19 | 20 | b = Delta() 21 | b.insert('A') 22 | 23 | expected = Delta().retain(1).delete(1) 24 | assert a.diff(b) == expected 25 | 26 | 27 | def test_retain(): 28 | a = Delta() 29 | a.insert('A') 30 | 31 | b = Delta() 32 | b.insert('A') 33 | 34 | expected = Delta() 35 | assert a.diff(b) == expected 36 | 37 | 38 | def test_format(): 39 | a = Delta() 40 | a.insert('A') 41 | 42 | b = Delta() 43 | b.insert('A', bold=True) 44 | 45 | expected = Delta().retain(1, bold=True) 46 | assert a.diff(b) == expected 47 | 48 | 49 | def test_attributes(): 50 | a = Delta().insert('A', font={'family': 'Helvetica', 'size': '15px'}) 51 | b = Delta().insert('A', font={'family': 'Helvetica', 'size': '15px'}) 52 | 53 | assert a.diff(b) == Delta() 54 | 55 | 56 | def test_embed(): 57 | # Same 58 | a = Delta().insert(1) 59 | b = Delta().insert(1) 60 | assert a.diff(b) == Delta() 61 | 62 | # Different 63 | a = Delta().insert(1) 64 | b = Delta().insert(2) 65 | assert a.diff(b) == Delta().delete(1).insert(2) 66 | 67 | # Object 68 | a = Delta().insert({ 'image': 'http://quilljs.com' }) 69 | b = Delta().insert({ 'image': 'http://quilljs.com' }) 70 | assert a.diff(b) == Delta() 71 | 72 | # Different Object 73 | a = Delta().insert({ 'image': 'http://quilljs.com', 'alt': 'Overwrite' }) 74 | b = Delta().insert({ 'image': 'http://quilljs.com' }) 75 | assert a.diff(b) == Delta().delete(1).insert({ 'image': 'http://quilljs.com' }) 76 | 77 | # Object change 78 | embed = { 'image': 'http://quilljs.com' } 79 | a = Delta().insert(embed) 80 | embed['image'] = "http://github.com" 81 | b = Delta().insert(embed) 82 | expected = Delta().insert({ 'image': 'http://github.com' }).delete(1) 83 | assert a.diff(b) == expected 84 | 85 | # False Positive 86 | a = Delta().insert(1) 87 | b = Delta().insert(chr(0)) #Placeholder char for embed 88 | expected = Delta().insert(chr(0)).delete(1) 89 | 90 | 91 | def test_document(): 92 | a = Delta().insert("AB").insert("C") 93 | assert a.document() == "ABC" 94 | 95 | with pytest.raises(ValueError): 96 | Delta().retain(1).insert('B').document() 97 | 98 | 99 | def test_inconvenient_indexes(): 100 | a = Delta().insert('12', bold=True).insert('34', italic=True) 101 | b = Delta().insert('123', color="red") 102 | expected = Delta().retain(2, bold=None, color="red").retain(1, italic=None, color="red").delete(1) 103 | assert a.diff(b) == expected 104 | 105 | 106 | def test_combination(): 107 | a = Delta().insert('Bad', color='red').insert('cat', color='blue') 108 | b = Delta().insert('Good', bold=True).insert('dog', italic=True) 109 | expected = Delta() \ 110 | .insert('Good', bold=True) \ 111 | .delete(2) \ 112 | .retain(1, italic=True, color=None) \ 113 | .delete(3) \ 114 | .insert('og', italic=True) 115 | print(a.document(), b.document(), expected) 116 | assert a.diff(b) == expected 117 | 118 | 119 | def test_same_document(): 120 | a = Delta().insert('A').insert('B', bold=True) 121 | expected = Delta() 122 | assert a.diff(a) == expected 123 | 124 | 125 | def test_immutability(): 126 | attr1 = { 'color': 'red' }; 127 | attr2 = { 'color': 'red' }; 128 | a1 = Delta().insert('A', **attr1); 129 | a2 = Delta().insert('A', **attr1); 130 | b1 = Delta().insert('A', bold=True).insert('B'); 131 | b2 = Delta().insert('A', bold=True).insert('B'); 132 | 133 | expected = Delta().retain(1, bold=True, color=None).insert('B'); 134 | 135 | assert a1.diff(b1) == expected; 136 | assert a1 == a2 137 | assert b2 == b2 138 | assert attr1 == attr2 139 | 140 | 141 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from delta import Delta 3 | try: 4 | import mock 5 | except ImportError: 6 | from unittest import mock 7 | 8 | 9 | def get_args(mock, index): 10 | args, kwargs = mock.call_args_list[index] 11 | return args 12 | 13 | 14 | def test_each_line(): 15 | # Expected 16 | delta = Delta().insert('Hello\n\n') \ 17 | .insert('World', bold=True) \ 18 | .insert({ 'image': 'octocat.png' }) \ 19 | .insert('\n', align='right') \ 20 | .insert('!') 21 | 22 | fn = mock.Mock() 23 | delta.each_line(fn) 24 | 25 | assert fn.call_count == 4 26 | assert get_args(fn, 0) == (Delta().insert('Hello'), {}, 0) 27 | assert get_args(fn, 1) == (Delta(), {}, 1) 28 | assert get_args(fn, 2) == (Delta().insert('World', bold=True).insert({ 'image': 'octocat.png' }), 29 | {'align': 'right'}, 30 | 2) 31 | assert get_args(fn, 3) == ( Delta().insert('!'), {}, 3 ) 32 | 33 | 34 | # Trailing newline 35 | delta = Delta().insert('Hello\nWorld!\n') 36 | fn = mock.Mock() 37 | 38 | delta.each_line(fn) 39 | assert fn.call_count == 2 40 | assert get_args(fn, 0) == (Delta().insert("Hello"), {}, 0) 41 | assert get_args(fn, 1) == (Delta().insert("World!"), {}, 1) 42 | 43 | 44 | # Non Document 45 | delta = Delta().retain(1).delete(2) 46 | fn = mock.Mock() 47 | 48 | delta.each_line(fn); 49 | assert fn.call_count == 0 50 | 51 | 52 | # Early Return 53 | state = {'count': 0} 54 | def counter(*args): 55 | if state['count'] == 1: 56 | return False 57 | state['count'] += 1 58 | 59 | delta = Delta().insert('Hello\nNew\nWorld!') 60 | fn = mock.Mock(side_effect=counter) 61 | 62 | delta.each_line(fn) 63 | assert fn.call_count == 2 64 | 65 | 66 | def test_concat(): 67 | # empty delta 68 | delta = Delta().insert('Test') 69 | concat = Delta() 70 | expected = Delta().insert('Test') 71 | 72 | assert delta.concat(concat) == expected 73 | 74 | # unmergeable 75 | delta = Delta().insert('Test') 76 | original = Delta(delta.ops) 77 | concat = Delta().insert('!', bold=True) 78 | expected = Delta().insert('Test').insert('!', bold=True) 79 | 80 | assert delta.concat(concat) == expected 81 | assert delta == original 82 | 83 | # mergeable 84 | delta = Delta().insert('Test', bold=True) 85 | original = Delta(delta.ops) 86 | concat = Delta().insert('!', bold=True).insert('\n') 87 | expected = Delta().insert('Test!', bold=True).insert('\n') 88 | 89 | assert delta.concat(concat) == expected 90 | assert delta == original 91 | 92 | 93 | def test_slice(): 94 | # start 95 | delta = Delta().retain(2).insert('A') 96 | expected = Delta().insert('A') 97 | 98 | assert delta[2:] == expected 99 | 100 | # end 101 | delta = Delta().retain(2).insert('A') 102 | expected = Delta().retain(2) 103 | 104 | assert delta[:2] == expected 105 | 106 | # start and end chop 107 | delta = Delta().insert('0123456789') 108 | expected = Delta().insert('23456') 109 | 110 | assert delta[2:7] == expected 111 | 112 | # start and end multiple chop 113 | delta = Delta().insert('0123', bold=True).insert('4567') 114 | expected = Delta().insert('3', bold=True).insert('4') 115 | 116 | assert delta[3:5] == expected 117 | 118 | # start and end 119 | delta = Delta().retain(2).insert('A', bold=True).insert('B') 120 | expected = Delta().insert('A', bold=True) 121 | 122 | assert delta[2:3] == expected 123 | 124 | # no params 125 | delta = Delta().retain(2).insert('A', bold=True).insert('B') 126 | 127 | assert delta[:] == delta 128 | 129 | # split ops 130 | delta = Delta().insert('AB', bold=True).insert('C') 131 | expected = Delta().insert('B', bold=True) 132 | 133 | assert delta[1:2] == expected 134 | 135 | # split ops multiple times 136 | delta = Delta().insert('ABC', bold=True).insert('D') 137 | expected = Delta().insert('B', bold=True) 138 | 139 | assert delta[1:2] == expected 140 | 141 | # Single 142 | delta = Delta().insert('ABC', bold=True) 143 | assert delta[0] == Delta().insert('A', bold=True) 144 | 145 | 146 | def test_chop(): 147 | # Retain 148 | a = Delta().insert('Test').retain(4) 149 | expected = Delta().insert('Test') 150 | 151 | assert a.chop() == expected 152 | 153 | # Insert 154 | a = Delta().insert('Test') 155 | expected = Delta().insert('Test') 156 | 157 | assert a.chop() == expected 158 | 159 | # Formatted 160 | a = Delta().insert('Test').retain(4, bold=True) 161 | expected = Delta().insert('Test').retain(4, bold=True) 162 | 163 | assert a.chop() == expected 164 | 165 | 166 | def test_length(): 167 | assert len(Delta().insert('Test')) == 4 168 | assert len(Delta().insert(1)) == 1 169 | assert len(Delta().retain(2)) == 2 170 | assert len(Delta().retain(2).delete(1)) == 3 171 | -------------------------------------------------------------------------------- /tests/test_html.py: -------------------------------------------------------------------------------- 1 | from delta import html 2 | from delta.base import Delta 3 | 4 | def test_basics(): 5 | ops = [ 6 | { "insert":"Quill\nEditor\n\n" }, 7 | { "insert": "bold", 8 | "attributes": {"bold": True}}, 9 | { "insert":" and the " }, 10 | { "insert":"italic", 11 | "attributes": { "italic": True }}, 12 | { "insert":"\n\nNormal\n" }, 13 | ] 14 | 15 | source = '

Quill

Editor


bold and the italic


Normal

' 16 | 17 | assert html.render(ops) == source 18 | 19 | def test_empty(): 20 | ops = [ {"insert": "\n"} ] 21 | source = "


" 22 | assert html.render(ops) == source 23 | 24 | def test_link(): 25 | ops = [ 26 | { 'insert': "example.com", "attributes": {"link": "http://example.com"} } 27 | ] 28 | source = '

example.com

' 29 | assert html.render(ops) == source 30 | 31 | def test_video(): 32 | ops = [ 33 | {"insert":{"video":"https://www.youtube.com/embed/NAb9V08zcBE"}}, 34 | {"insert": "\n"} 35 | ] 36 | source = '


' 37 | assert html.render(ops) == source 38 | 39 | def test_video_alignment(): 40 | ops = [ 41 | {"insert":{"video":"https://www.youtube.com/embed/NAb9V08zcBE"}, "attributes": {"align": "right"}}, 42 | {"insert": "\n"} 43 | ] 44 | source = '


' 45 | assert html.render(ops) == source 46 | 47 | def test_colors(): 48 | ops = [ 49 | {"insert": "quill", "attributes": {"background": "#000000"}} 50 | ] 51 | 52 | source = '

quill

' 53 | assert html.render(ops) == source 54 | 55 | ops = [ 56 | {"insert": "quill", "attributes": {"background": "#000000", "color": "#FFFFFF"}} 57 | ] 58 | 59 | source = '

quill

' 60 | assert html.render(ops) == source 61 | 62 | def test_classes(): 63 | ops = [ 64 | {"insert":"Quill", "attributes": {"font": "monospace", "size": "huge"}} 65 | ] 66 | source = '

Quill

' 67 | assert html.render(ops) == source 68 | 69 | ops = [ 70 | {"insert":"Quill", "attributes": {"font": "serif", "size": "large"}} 71 | ] 72 | source = '

Quill

' 73 | assert html.render(ops) == source 74 | 75 | ops = [ 76 | {"insert":"Quill", "attributes": {"font": "sans-serif", "size": "small"}} 77 | ] 78 | source = '

Quill

' 79 | assert html.render(ops) == source 80 | 81 | def test_strong(): 82 | ops = [ 83 | {"insert":"Quill", "attributes": {"bold": True}} 84 | ] 85 | source = '

Quill

' 86 | assert html.render(ops) == source 87 | 88 | ops = [ 89 | {"insert":"Quill", "attributes": {"strong": True}} 90 | ] 91 | source = '

Quill

' 92 | assert html.render(ops) == source 93 | 94 | def test_em(): 95 | ops = [ 96 | {"insert":"Quill", "attributes": {"italic": True}} 97 | ] 98 | source = '

Quill

' 99 | assert html.render(ops) == source 100 | 101 | ops = [ 102 | {"insert":"Quill", "attributes": {"em": True}} 103 | ] 104 | source = '

Quill

' 105 | assert html.render(ops) == source 106 | 107 | def test_underline(): 108 | ops = [ 109 | {"insert":"Quill", "attributes": {"underline": True}} 110 | ] 111 | source = '

Quill

' 112 | assert html.render(ops) == source 113 | 114 | def test_strike(): 115 | ops = [ 116 | {"insert":"Quill", "attributes": {"strike": True}} 117 | ] 118 | source = '

Quill

' 119 | assert html.render(ops) == source 120 | 121 | def test_script(): 122 | ops = [ 123 | {"insert":"Quill", "attributes": {"script": "sub"}} 124 | ] 125 | source = '

Quill

' 126 | assert html.render(ops) == source 127 | 128 | ops = [ 129 | {"insert":"Quill", "attributes": {"script": "super"}} 130 | ] 131 | source = '

Quill

' 132 | assert html.render(ops) == source 133 | 134 | def test_header(): 135 | ops = [ 136 | {"insert":"Quill", "attributes": {"header": 1}} 137 | ] 138 | source = '

Quill

' 139 | assert html.render(ops) == source 140 | 141 | ops = [ 142 | {"insert":"Quill", "attributes": {"header": 5}} 143 | ] 144 | source = '
Quill
' 145 | assert html.render(ops) == source 146 | 147 | ops = [ 148 | {"insert":"Quill"}, 149 | {"insert":"\n", "attributes": {"header": 2}} 150 | ] 151 | source = '

Quill

' 152 | 153 | ops = [{"insert": "Hello"}, {"attributes": {"header": 2}}] 154 | 155 | source = '

Hello

' 156 | assert html.render(ops) == source 157 | 158 | def test_blockquote(): 159 | ops = [ 160 | {'insert': 'One\nTwo\nQuote'}, 161 | {'insert': '\n', 'attributes': {'blockquote': True}} 162 | ] 163 | source = '

One

Two

Quote
' 164 | assert html.render(ops) == source 165 | 166 | def test_codeblock(): 167 | from delta.html import CODE_BLOCK_CLASS 168 | ops = [ 169 | {"insert":"Quill", "attributes": {"code-block": True}} 170 | ] 171 | source = '
Quill
' % CODE_BLOCK_CLASS 172 | assert html.render(ops) == source 173 | 174 | def test_lists(): 175 | ops = [ 176 | {"insert": "item 1"}, 177 | {"insert": "\n", "attributes": {"list":"ordered", "indent": 1}}, 178 | {"insert": "item 2"}, 179 | {"insert": "\n", "attributes": {"list": "ordered"}}, 180 | {"insert":"item 3"}, 181 | {"insert": "\n", "attributes": {"list": "ordered"}} 182 | ] 183 | source = "
  1. item 1
  2. item 2
  3. item 3
" 184 | assert html.render(ops) == source 185 | 186 | ops = [ 187 | {"insert": "item 1"}, 188 | {"insert": "\n", "attributes": {"list":"bullet", "indent": 2}}, 189 | {"insert": "item 2"}, 190 | {"insert": "\n", "attributes": {"list": "bullet"}}, 191 | {"insert":"item 3"}, 192 | {"insert": "\n", "attributes": {"list": "bullet"}} 193 | ] 194 | source = "" 195 | assert html.render(ops) == source 196 | 197 | def test_indent(): 198 | for i in range(1, 9): 199 | ops = [ 200 | {"insert": "quill"}, 201 | {"attributes":{"indent":i},"insert":"\n"} 202 | ] 203 | source = '

quill

' % i 204 | assert html.render(ops) == source 205 | 206 | def test_direction(): 207 | ops = [ 208 | {"insert": "quill"}, 209 | {"insert": "\n", "attributes": {"direction": "rtl"}} 210 | ] 211 | source = '

quill

' 212 | assert html.render(ops) == source 213 | 214 | def test_align(): 215 | ops = [ 216 | {"insert": "quill"}, 217 | {"insert": "\n", "attributes": {"align": "center"}} 218 | ] 219 | source = '

quill

' 220 | assert html.render(ops) == source 221 | 222 | def test_image(): 223 | ops = [ 224 | { 'insert': {'image': 'https://i.imgur.com/ZMSUFEU.gif'} } 225 | ] 226 | source = '

' 227 | assert html.render(ops) == source 228 | 229 | 230 | def test_image_width(): 231 | ops = [ 232 | { 'insert': {'image': 'https://i.imgur.com/ZMSUFEU.gif'}, 'attributes': {'width': '196', 'height': '200'}} 233 | ] 234 | source = '

' 235 | assert html.render(ops) == source 236 | 237 | 238 | def test_error(): 239 | ops = [ 240 | { 'insert': {'image': True} } 241 | ] 242 | source = '

' 243 | assert html.render(ops) == source 244 | -------------------------------------------------------------------------------- /tests/test_op.py: -------------------------------------------------------------------------------- 1 | from delta import op 2 | 3 | 4 | def test_length(): 5 | assert op.length({'delete': 5}) == 5 6 | assert op.length({'retain': 4}) == 4 7 | assert op.length({'insert': 'python'}) == 6 8 | assert op.length({'insert': 2}) == 1 9 | 10 | def test_type(): 11 | assert op.type({'delete': 5}) == 'delete' 12 | assert op.type({'retain': 5}) == 'retain' 13 | assert op.type({'insert': 'text'}) == 'insert' 14 | assert op.type({'insert': 1}) == 'insert' 15 | assert op.type({}) == None 16 | 17 | 18 | def test_compose(): 19 | attributes = { 'bold': True, 'color': 'red' } 20 | 21 | assert op.compose(None, attributes) == attributes 22 | assert op.compose(attributes, None) == attributes 23 | assert op.compose(None, None) is None 24 | 25 | assert op.compose(attributes, {'italic': True}) == { 'bold': True, 'color': 'red', 'italic': True } 26 | assert op.compose(attributes, {'color': 'blue', 'bold': False}) == { 'bold': False, 'color': 'blue' } 27 | assert op.compose(attributes, {'bold': None}) == { 'color': 'red' } 28 | 29 | assert op.compose(attributes, {'bold': None, 'color': None}) is None 30 | assert op.compose(attributes, {'italic': None}) == attributes 31 | 32 | def test_diff(): 33 | format = { 'bold': True, 'color': 'red' } 34 | 35 | assert op.diff(None, format) == format 36 | assert op.diff(format, None) == {'bold': None, 'color': None} 37 | assert op.diff(format, format) == None 38 | 39 | assert op.diff(format, { 'bold': True, 'italic': True, 'color': 'red' }) == {'italic': True} 40 | assert op.diff(format, { 'bold': True }) == {'color': None} 41 | assert op.diff(format, { 'bold': True, 'color': 'blue' }) == {'color': 'blue'} 42 | 43 | def test_transform(): 44 | left = { 'bold': True, 'color': 'red', 'font': None } 45 | right = { 'color': 'blue', 'font': 'serif', 'italic': True }; 46 | 47 | assert op.transform(None, left, False) == left 48 | assert op.transform(left, None, False) is None 49 | assert op.transform(None, None, False) is None 50 | 51 | assert op.transform(left, right, True) == {'italic': True} 52 | assert op.transform(left, right, False) == right 53 | 54 | 55 | def test_iterator(): 56 | ops = [ 57 | {'insert': 'Hello', 'attributes': {'bold': True}}, 58 | {'retain': 3}, 59 | {'insert': 2, 'attributes': {'src': 'http://quilljs.com/'}}, 60 | {'delete': 4}, 61 | ] 62 | 63 | iterator = op.iterator(ops) 64 | assert iterator.offset == 0 65 | assert iterator.index == 0 66 | assert iterator.ops == ops 67 | 68 | assert iterator.has_next() 69 | assert iterator.peek() == {'insert': 'Hello', 'attributes': {'bold': True}} 70 | assert iterator.peek_length() == 5 71 | assert iterator.peek_type() == 'insert' 72 | 73 | iterator.next() 74 | assert iterator.peek_type() == 'retain' 75 | assert iterator.peek_length() == 3 76 | 77 | iterator.next() 78 | assert iterator.peek_type() == 'insert' 79 | assert iterator.peek_length() == 1 80 | 81 | iterator.next() 82 | assert iterator.peek_type() == 'delete' 83 | assert iterator.peek_length() == 4 84 | 85 | for i in range(10): 86 | iterator.next() 87 | assert iterator.peek_type() == 'retain' 88 | assert iterator.peek_length() is None 89 | 90 | iterator.reset() 91 | for operator, next in zip(ops, iterator): 92 | assert operator == next 93 | 94 | 95 | def test_iterator_next_length(): 96 | ops = [ 97 | {'insert': 'Hello', 'attributes': {'bold': True}}, 98 | {'retain': 3}, 99 | {'insert': 2, 'attributes': {'src': 'http://quilljs.com/'}}, 100 | {'delete': 4}, 101 | ] 102 | 103 | iterator = op.iterator(ops) 104 | iterator.next(2) 105 | assert iterator.peek_length() == 5 - 2 106 | 107 | assert iterator.next(2)['insert'] == 'll' 108 | assert iterator.next()['insert'] == 'o' 109 | 110 | assert iterator.next()['retain'] 111 | assert iterator.next()['insert'] 112 | assert iterator.next()['delete'] 113 | 114 | for i in range(10): 115 | assert iterator.next()['retain'] is None 116 | 117 | 118 | def test_empty_iterator(): 119 | iterator = op.iterator([]) 120 | assert iterator.offset == 0 121 | assert iterator.index == 0 122 | assert iterator.ops == [] 123 | assert iterator.has_next() is False 124 | assert iterator.peek() is None 125 | assert iterator.peek_length() is None 126 | assert iterator.peek_type() is 'retain' 127 | 128 | 129 | def test_next(): 130 | ops = [ 131 | {'insert': 'Bad'}, 132 | {'insert': 'cat'}, 133 | ] 134 | 135 | iterator = op.iterator(ops) 136 | iterator.next(2) 137 | iterator.next(1) 138 | assert iterator.index == 1 139 | assert iterator.peek() == ops[1] 140 | -------------------------------------------------------------------------------- /tests/test_transform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from delta import Delta 3 | 4 | 5 | def test_insert_and_insert(): 6 | a1 = Delta().insert('A') 7 | b1 = Delta().insert('B') 8 | a2 = Delta(a1) 9 | b2 = Delta(b1) 10 | 11 | expected1 = Delta().retain(1).insert('B') 12 | expected2 = Delta().insert('B') 13 | 14 | assert a1.transform(b1, True) == expected1 15 | assert a1.transform(b2, False) == expected2 16 | 17 | 18 | def test_insert_and_retain(): 19 | a = Delta().insert('A') 20 | b = Delta().retain(1, bold=True, color="red") 21 | expected = Delta().retain(1).retain(1, bold=True, color="red") 22 | assert a.transform(b, True) == expected 23 | 24 | 25 | def test_insert_and_delete(): 26 | a = Delta().insert('A') 27 | b = Delta().delete(1) 28 | expected = Delta().retain(1).delete(1) 29 | assert a.transform(b, True) == expected 30 | 31 | 32 | def test_delete_and_insert(): 33 | a = Delta().delete(1) 34 | b = Delta().insert('B') 35 | expected = Delta().insert('B') 36 | assert a.transform(b, True) == expected 37 | 38 | 39 | def test_delete_and_retain(): 40 | a = Delta().delete(1) 41 | b = Delta().retain(1, bold=True, color='red') 42 | expected = Delta() 43 | assert a.transform(b, True) == expected 44 | 45 | 46 | def test_delete_and_delete(): 47 | a = Delta().delete(1) 48 | b = Delta().delete(1) 49 | expected = Delta() 50 | assert a.transform(b, True) == expected 51 | 52 | 53 | def test_retain_and_insert(): 54 | a = Delta().retain(1, color='blue') 55 | b = Delta().insert('B') 56 | expected = Delta().insert('B') 57 | assert a.transform(b, True) == expected 58 | 59 | 60 | def test_retain_and_retain(): 61 | a1 = Delta().retain(1, color='blue') 62 | b1 = Delta().retain(1, bold=True, color='red') 63 | a2 = Delta().retain(1, color='blue') 64 | b2 = Delta().retain(1, bold=True, color='red') 65 | expected1 = Delta().retain(1, bold=True) 66 | expected2 = Delta() 67 | 68 | assert a1.transform(b1, True) == expected1 69 | assert b2.transform(a2, True) == expected2 70 | 71 | 72 | def test_retain_and_retain_without_priority(): 73 | a1 = Delta().retain(1, color='blue') 74 | b1 = Delta().retain(1, bold=True, color='red') 75 | a2 = Delta().retain(1, color='blue') 76 | b2 = Delta().retain(1, bold=True, color='red') 77 | expected1 = Delta().retain(1, bold=True, color='red') 78 | expected2 = Delta().retain(1, color='blue') 79 | 80 | assert a1.transform(b1, False) == expected1 81 | assert b2.transform(a2, False) == expected2 82 | 83 | 84 | def test_retain_and_delete(): 85 | a = Delta().retain(1, color='blue') 86 | b = Delta().delete(1) 87 | expected = Delta().delete(1) 88 | assert a.transform(b, True) == expected 89 | 90 | 91 | def test_alternating_edits(): 92 | a1 = Delta().retain(2).insert('si').delete(5) 93 | b1 = Delta().retain(1).insert('e').delete(5).retain(1).insert('ow') 94 | a2 = Delta(a1) 95 | b2 = Delta(b1) 96 | expected1 = Delta().retain(1).insert('e').delete(1).retain(2).insert('ow') 97 | expected2 = Delta().retain(2).insert('si').delete(1) 98 | 99 | assert a1.transform(b1, False) == expected1 100 | assert b2.transform(a2, False) == expected2 101 | 102 | 103 | def test_conflicting_appends(): 104 | a1 = Delta().retain(3).insert('aa') 105 | b1 = Delta().retain(3).insert('bb') 106 | a2 = Delta(a1) 107 | b2 = Delta(b1) 108 | expected1 = Delta().retain(5).insert('bb') 109 | expected2 = Delta().retain(3).insert('aa') 110 | 111 | assert a1.transform(b1, True) == expected1 112 | assert b2.transform(a2, False) == expected2 113 | 114 | 115 | def test_prepend_and_append(): 116 | a1 = Delta().insert('aa') 117 | b1 = Delta().retain(3).insert('bb') 118 | expected1 = Delta().retain(5).insert('bb') 119 | 120 | a2 = Delta(a1) 121 | b2 = Delta(b1) 122 | expected2 = Delta().insert('aa') 123 | 124 | assert a1.transform(b1, False) == expected1 125 | assert b2.transform(a2, False) == expected2 126 | 127 | 128 | def test_trailing_deletes_with_differing_lengths(): 129 | a1 = Delta().retain(2).delete(1) 130 | b1 = Delta().delete(3) 131 | expected1 = Delta().delete(2) 132 | 133 | a2 = Delta(a1) 134 | b2 = Delta(b1) 135 | expected2 = Delta() 136 | 137 | assert a1.transform(b1, False) == expected1 138 | assert b2.transform(a2, False) == expected2 139 | 140 | 141 | def test_immutability(): 142 | a1 = Delta().insert('A') 143 | a2 = Delta().insert('A') 144 | b1 = Delta().insert('B') 145 | b2 = Delta().insert('B') 146 | expected = Delta().retain(1).insert('B') 147 | assert a1.transform(b1, True) == expected 148 | assert a1 == a2 149 | assert b1 == b2 150 | 151 | 152 | ### Transform Positions ### 153 | 154 | def test_insert_before_position(): 155 | delta = Delta().insert('A') 156 | assert delta.transform(2) == 3 157 | 158 | def test_insert_after_position(): 159 | delta = Delta().retain(2).insert('A') 160 | assert delta.transform(1) == 1 161 | 162 | def test_insert_at_position(): 163 | delta = Delta().retain(2).insert('A') 164 | assert delta.transform(2, True) == 2 165 | assert delta.transform(2, False) == 3 166 | 167 | def test_delete_before_position(): 168 | delta = Delta().delete(2) 169 | assert delta.transform(4) == 2 170 | 171 | def test_delete_after_position(): 172 | delta = Delta().retain(4).delete(2) 173 | assert delta.transform(2) == 2 174 | 175 | def test_delete_across_position(): 176 | delta = Delta().retain(1).delete(4) 177 | assert delta.transform(2) == 1 178 | 179 | def test_insert_and_delete_before_position(): 180 | delta = Delta().retain(2).insert('A').delete(2) 181 | assert delta.transform(4) == 3 182 | 183 | def test_insert_before_and_delete_across_position(): 184 | delta = Delta().retain(2).insert('A').delete(4) 185 | assert delta.transform(4) == 3 186 | 187 | def test_delete_before_and_delete_across_position(): 188 | delta = Delta().delete(1).retain(1).delete(4) 189 | assert delta.transform(4) == 1 190 | 191 | 192 | 193 | --------------------------------------------------------------------------------