├── .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 |