Rich Text Delta (Python)
Python implementation of Rich Text Delta, a
fork of quill-delta with support for nested
attribute maps — attribute values may themselves be maps, and compose, diff,
invert and transform recurse into them instead of treating them as scalar values.
This is a port of the TypeScript implementation in
languages/typescript; the two agree operation for operation. The
root README introduces the delta format and covers the repository
itself.
Install
pip install rich-text-delta
No runtime dependencies. Requires Python 3.9+.
from rich_text_delta import Delta
Usage
Build a document:
doc = Delta().insert('Hello ').insert('World', {'bold': True}).insert('\n')
# [ {'insert': 'Hello '},
# {'insert': 'World', 'attributes': {'bold': True}},
# {'insert': '\n'} ]
Apply a change with compose:
change = Delta().retain(6).retain(5, {'italic': True})
doc.compose(change)
# [ {'insert': 'Hello '},
# {'insert': 'World', 'attributes': {'italic': True, 'bold': True}},
# {'insert': '\n'} ]
Undo it with invert, which produces the change that reverses change against doc:
undo = change.invert(doc)
# [ {'retain': 6}, {'retain': 5, 'attributes': {'italic': None}} ]
doc.compose(change).compose(undo) == doc # True
Reconcile concurrent edits with transform. Given two changes made against the same
document, a.transform(b, priority) rewrites b so it can be applied after a:
a = Delta().insert('A')
b = Delta().insert('B')
a.transform(b, True) # [ {'retain': 1}, {'insert': 'B'} ]
Nested attributes
Map-valued attributes are merged key by key rather than replaced wholesale:
base = Delta().insert('x', {'style': {'color': 'red', 'size': 12}})
change = Delta().retain(1, {'style': {'color': 'blue'}})
base.compose(change)
# [ {'insert': 'x', 'attributes': {'style': {'color': 'blue', 'size': 12}}} ]
API
Every method below mirrors the TypeScript API under a snake_case name; see
Differences from the TypeScript API for the full
list of spelling changes. An Op is a plain dict, and an attribute map is a plain
dict of str to anything.
Operations
Construction
Documents
These methods called on or with non-document Deltas will result in undefined behavior.
Utility
Operational Transform
Operations
Insert Operation
Insert operations have an insert key defined. A str value represents inserting text. Any other type represents inserting an embed (however only one level of dict comparison will be performed for equality).
In both cases of text and embeds, an optional attributes key can be defined with a dict to describe additional formatting information. Formats can be changed by the retain operation.
# Insert a bolded "Text"
{'insert': 'Text', 'attributes': {'bold': True}}
# Insert a link
{'insert': 'Google', 'attributes': {'link': 'https://www.google.com'}}
# Insert an embed
{
'insert': {'image': 'https://octodex.github.com/images/labtocat.png'},
'attributes': {'alt': 'Lab Octocat'},
}
# Insert another embed
{
'insert': {'video': 'https://www.youtube.com/watch?v=dMH0bHeiRNg'},
'attributes': {
'width': 420,
'height': 315,
},
}
Delete Operation
Delete operations have a numeric delete key defined representing the number of characters to delete. All embeds have a length of 1.
# Delete the next 10 characters
{'delete': 10}
Retain Operation
Retain operations have a numeric retain key defined representing the number of characters to keep (other libraries might use the name keep or skip). An optional attributes key can be defined with a dict to describe formatting changes to the character range. A value of None in the attributes dict represents removal of that key.
Note: It is not necessary to retain the last characters of a document as this is implied.
# Keep the next 5 characters
{'retain': 5}
# Keep and bold the next 5 characters
{'retain': 5, 'attributes': {'bold': True}}
# Keep and unbold the next 5 characters
# More specifically, remove the bold key in the attributes dict
# in the next 5 characters
{'retain': 5, 'attributes': {'bold': None}}
Note: lengths are counted in UTF-16 code units — see Text and UTF-16.
Construction
constructor
Creates a new Delta object.
Methods
Delta()Delta(ops)Delta(delta)
Parameters
ops- list of operationsdelta- aDelta, or a dict with anopskey set to a list of operations
Note: No validity/sanity check is performed when constructed with ops or delta. The new delta's internal ops list will also be assigned from ops or delta.ops without deep copying.
Example
import json
delta = Delta([
{'insert': 'Hello World'},
{'insert': '!', 'attributes': {'bold': True}},
])
packet = json.dumps(delta.ops)
other = Delta(json.loads(packet))
chained = Delta().insert('Hello World').insert('!', {'bold': True})
insert()
Appends an insert operation. Returns self for chainability.
Methods
insert(text, attributes=None)insert(embed, attributes=None)
Parameters
text-strrepresenting text to insertembed- dict representing embed type to insertattributes- Optional attributes to apply
Example
delta.insert('Text', {'bold': True, 'color': '#ccc'})
delta.insert({'image': 'https://octodex.github.com/images/labtocat.png'})
delete()
Appends a delete operation. Returns self for chainability.
Methods
delete(length)
Parameters
length- Number of characters to delete
Example
delta.delete(5)
retain()
Appends a retain operation. Returns self for chainability.
Methods
retain(length, attributes=None)
Parameters
length- Number of characters to retainattributes- Optional attributes to apply
Example
delta.retain(4).retain(5, {'color': '#0c6'})
Documents
concat()
Returns a new Delta representing the concatenation of this and another document Delta's operations.
Methods
concat(other)
Parameters
other- Document Delta to concatenate
Returns
Delta- Concatenated document Delta
Example
a = Delta().insert('Hello')
b = Delta().insert('!', {'bold': True})
concat = a.concat(b)
# Delta([{'insert': 'Hello'}, {'insert': '!', 'attributes': {'bold': True}}])
diff()
Returns a Delta representing the difference between two documents. Optionally, accepts a suggested index where change took place, often representing a cursor position before change.
Methods
diff(other)diff(other, cursor)
Parameters
other- Document Delta to diff againstcursor- Suggested index where change took place, or a dict{'oldRange': ..., 'newRange': ...}where each range is{'index': ..., 'length': ...}. The keys stay camelCase because they are part of the wire format.
Returns
Delta- difference between the two documents
Example
a = Delta().insert('Hello')
b = Delta().insert('Hello!')
diff = a.diff(b) # Delta([{'retain': 5}, {'insert': '!'}])
# a.compose(diff) == b
each_line()
Iterates through document Delta, calling a given function with a Delta and attributes dict, representing the line segment.
Methods
each_line(predicate, newline='\n')
Parameters
predicate- function to call on each line group, receiving(line, attributes, index)newline- newline character, defaults to\n
Example
delta = (
Delta()
.insert('Hello\n\n')
.insert('World')
.insert({'image': 'octocat.png'})
.insert('\n', {'align': 'right'})
.insert('!')
)
def log(line, attributes, i):
print(repr(line), attributes, i)
# Can return False to exit loop early
delta.each_line(log)
# Should print:
# Delta([{'insert': 'Hello'}]) {} 0
# Delta([]) {} 1
# Delta([{'insert': 'World'}, {'insert': {'image': 'octocat.png'}}]) {'align': 'right'} 2
# Delta([{'insert': '!'}]) {} 3
invert()
Returns an inverted delta that has the opposite effect of against a base document delta. That is base.compose(delta).compose(inverted) == base.
Methods
invert(base)
Parameters
base- Document delta to invert against
Returns
Delta- inverted delta against the base delta
Example
base = Delta().insert('Hello\n').insert('World')
delta = Delta().retain(6, {'bold': True}).insert('!').delete(5)
inverted = delta.invert(base)
# Delta([{'retain': 6, 'attributes': {'bold': None}},
# {'insert': 'World'},
# {'delete': 1}])
# base.compose(delta).compose(inverted) == base
Utility
filter()
Returns a list of operations that passes a given function.
Methods
filter(predicate)
Parameters
predicate- Function to test each operation against, receiving(op, index). ReturnTrueto keep the operation,Falseotherwise.
Returns
list- Filtered resulting list
Example
delta = (
Delta()
.insert('Hello', {'bold': True})
.insert({'image': 'https://octodex.github.com/images/labtocat.png'})
.insert('World!')
)
text = ''.join(
operation['insert']
for operation in delta.filter(
lambda operation, index: isinstance(operation.get('insert'), str)
)
)
for_each()
Iterates through operations, calling the provided function for each operation.
Methods
for_each(predicate)
Parameters
predicate- Function to call during iteration, receiving(op, index).
Example
delta.for_each(lambda operation, index: print(operation))
length()
Returns length of a Delta, which is the sum of the lengths of its operations, in UTF-16 code units.
Methods
length()
Example
Delta().insert('Hello').length() # Returns 5
Delta().insert('A').retain(2).delete(1).length() # Returns 4
The length of a single operation is op.length(operation), from the
rich_text_delta.op module.
map()
Returns a new list with the results of calling the provided function on each operation.
Methods
map(predicate)
Parameters
predicate- Function to call, receiving(op, index)and returning an element of the new list to be returned
Returns
list- A new list with each element being the result of the given function.
Example
delta = (
Delta()
.insert('Hello', {'bold': True})
.insert({'image': 'https://octodex.github.com/images/labtocat.png'})
.insert('World!')
)
text = ''.join(
delta.map(
lambda operation, index: operation['insert']
if isinstance(operation.get('insert'), str)
else ''
)
)
partition()
Create a tuple of two lists, the first with operations that pass the given function, the other that failed.
Methods
partition(predicate)
Parameters
predicate- Function to call, receiving(op), returning whether that operation passed
Returns
tuple- A tuple of two lists, the first with passed operations, the other with failed operations
Example
delta = (
Delta()
.insert('Hello', {'bold': True})
.insert({'image': 'https://octodex.github.com/images/labtocat.png'})
.insert('World!')
)
passed, failed = delta.partition(
lambda operation: isinstance(operation.get('insert'), str)
)
# passed == [{'insert': 'Hello', 'attributes': {'bold': True}},
# {'insert': 'World!'}]
# failed == [{'insert': {'image': 'https://octodex.github.com/images/labtocat.png'}}]
reduce()
Applies given function against an accumulator and each operation to reduce to a single value.
Methods
reduce(predicate, initial_value)
Parameters
predicate- Function to call per iteration, receiving(accumulator, op, index)and returning an accumulated valueinitial_value- Initial value to pass to the first call to predicate
Returns
- the accumulated value
Example
from rich_text_delta import Delta, op
delta = (
Delta()
.insert('Hello', {'bold': True})
.insert({'image': 'https://octodex.github.com/images/labtocat.png'})
.insert('World!')
)
length = delta.reduce(
lambda length, operation, index: length + op.length(operation), 0
) # 12
slice()
Returns a copy of the delta with a subset of operations.
Methods
slice()slice(start)slice(start, end)
Parameters
start- Start index of subset, defaults to 0end- End index of subset, defaults to rest of operations (math.inf)
Example
delta = Delta().insert('Hello', {'bold': True}).insert(' World')
copy = delta.slice()
# Delta([{'attributes': {'bold': True}, 'insert': 'Hello'}, {'insert': ' World'}])
world = delta.slice(6) # Delta([{'insert': 'World'}])
space = delta.slice(5, 6) # Delta([{'insert': ' '}])
Operational Transform
compose()
Returns a Delta that is equivalent to applying the operations of own Delta, followed by another Delta.
Methods
compose(other)
Parameters
other- Delta to compose
Example
a = Delta().insert('abc')
b = Delta().retain(1).delete(1)
composed = a.compose(b) # composed == Delta().insert('ac')
transform()
Transform given Delta against own operations.
Methods
transform(other, priority=False)transform(index, priority=False)- Alias fortransform_position
Parameters
other- Delta to transformpriority- Boolean used to break ties. IfTrue, thenselftakes priority overother, that is, its actions are considered to happen "first."
Returns
Delta- transformed Delta
Example
a = Delta().insert('a')
b = Delta().insert('b').retain(5).insert('c')
a.transform(b, True) # Delta().retain(1).insert('b').retain(5).insert('c')
a.transform(b, False) # Delta().insert('b').retain(6).insert('c')
transform_position()
Transform an index against the delta. Useful for representing cursor/selection positions.
Methods
transform_position(index, priority=False)
Parameters
index- index to transform
Returns
- the transformed index
Example
delta = Delta().retain(5).insert('a')
delta.transform_position(4) # 4
delta.transform_position(5) # 6
Differences from the TypeScript API
The port follows the TypeScript source line for line. What differs is spelling and the handful of places where JavaScript has no Python equivalent:
| TypeScript | Python |
|---|---|
delta.eachLine, changeLength, transformPosition, forEach |
each_line, change_length, transform_position, for_each |
Delta.registerEmbed / unregisterEmbed |
Delta.register_embed / unregister_embed |
namespace Op / namespace AttributeMap |
modules rich_text_delta.op / rich_text_delta.attribute_map (so Op.length(op) is op.length(op)) |
null attribute value (a removal) |
None |
undefined (absent) |
key missing from the map |
Infinity |
math.inf |
new Error(...) |
ValueError |
delta.diff(other, {oldRange, newRange}) |
the same camelCase keys, since they are part of the wire format |
structuredClone |
copy.deepcopy |
isEqual from es-toolkit |
an internal deep_equal with JavaScript's type rules |
Callbacks are called with every argument the TypeScript signature declares:
filter, for_each and map receive (op, index), reduce receives
(accumulator, op, index), each_line receives (line, attributes, index) and
partition receives (op).
Embed handlers are any object with compose(a, b, keep_null), invert(a, b) and
transform(a, b, priority) methods — see the EmbedHandler protocol.
Two Deltas compare equal when their ops do (a == b), which is what the TypeScript tests
express as toEqual. Defining __eq__ makes Delta unhashable, as a mutable value type
should be.
The character diff behind Delta.diff is a vendored port of
fast-diff, so the package has no runtime
dependencies.
Text and UTF-16
Text is measured and indexed in UTF-16 code units, as the reference implementation and the
wire format are: op.length({'insert': '😀'}) is 2, not 1. So are Delta.length,
change_length, the lengths given to delete and retain, the bounds of slice, the index
transform_position takes and returns, every retain and delete count in an emitted op, and
the cursor / oldRange / newRange indices of diff. A character outside the Basic
Multilingual Plane — an emoji, a flag, an astral CJK ideograph — spans two of them, so
len(op['insert']) is not the op's length for such text; op.length(op) is.
Insert text is an ordinary str, kept maximally composed, so op['insert'] reads as text:
>>> Delta().insert('a😀b').length()
4
>>> Delta().insert('a😀b').ops
[{'insert': 'a😀b'}]
A boundary landing inside a surrogate pair splits it, yielding a lone surrogate on each side, exactly as the reference does:
>>> Delta().insert('😀').slice(0, 1).ops
[{'insert': '\ud83d'}]
That string is well-formed UTF-16 but not valid Unicode. The halves reassemble into the
character as soon as they are adjacent again — when push merges two ops, through concat, or
on a JSON round-trip — so a lone surrogate only survives while the two sides really are apart.
Serializing is safe by default: json.dumps escapes a lone surrogate as \ud83d, and
json.loads reads it back, so ops round-trip through JSON exactly. Two things to know:
json.dumps(ops, ensure_ascii=False).encode('utf-8')raisesUnicodeEncodeErroron a lone surrogate. Passerrors='surrogatepass'if you need that path, and be aware the bytes it produces are not strict UTF-8 and many decoders will reject them.print(op['insert'])hits the same problem on a UTF-8 stream.repr()is safe.
Develop
Uses uv. From the repository root, just recipes suffixed
-py cover everything (just test-py, just ci-py, ...). Directly:
uv sync # create .venv from uv.lock
uv run pytest # run the tests
uv run ruff check . # lint
uv run ruff format . # format in place
uv run mypy # type check
Tests live in tests/ and mirror the TypeScript suite in
languages/typescript/src/__tests__/ file for file — every case carries over except the
prototype-pollution ones that assert Object.prototype was left alone, which have no
meaning for Python dicts. The __proto__ key is still filtered out of attribute maps, and
the cases covering that are ported.
License
BSD-3-Clause. See LICENSE and NOTICE.txt. Derived from
quill-delta by Jason Chen. Bundles a port of
fast-diff (Apache-2.0) as
rich_text_delta/_fast_diff.py.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rich_text_delta-0.1.0.tar.gz.
File metadata
- Download URL: rich_text_delta-0.1.0.tar.gz
- Upload date:
- Size: 37.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b56ccfc52abe9a72d95e9ddb68295aa0c7b0fb838890a212abfd8e567e61ec89
|
|
| MD5 |
2d0baa41ec6a5b159e5ee9bbd27428cc
|
|
| BLAKE2b-256 |
3ecd4042777d9e2e853e86806a6b388db4abca38215f26928d29cc4dc05fd5a1
|
File details
Details for the file rich_text_delta-0.1.0-py3-none-any.whl.
File metadata
- Download URL: rich_text_delta-0.1.0-py3-none-any.whl
- Upload date:
- Size: 33.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c1708ea35b747583664d3445adff929aacf668ba7974e4943ad73b7c73865806
|
|
| MD5 |
2520df7507ef6573eae2b1275e8606bd
|
|
| BLAKE2b-256 |
19c54d3f1447db21d4c430c4470d0b9e1581e4a0e370d4ecbe7c85b338c561ed
|