python-pages 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pages/cli.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from zipfile import ZipFile
7
+
8
+ from .pages import PagesDocument
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(prog="python-pages")
13
+ sub = parser.add_subparsers(dest="command", required=True)
14
+
15
+ inspect = sub.add_parser("inspect", help="parse and summarize a .pages file")
16
+ inspect.add_argument("input", type=Path)
17
+
18
+ bold = sub.add_parser("bold", help="make a body-text substring bold")
19
+ bold.add_argument("input", type=Path)
20
+ bold.add_argument("output", type=Path)
21
+ bold.add_argument("substring")
22
+ bold.add_argument("--occurrence", type=int, default=1)
23
+
24
+ args = parser.parse_args()
25
+ document = PagesDocument.load(args.input)
26
+ if args.command == "inspect":
27
+ storage = document.body_storage()
28
+ result = document.validate() | {
29
+ "storage_id": storage.segment.identifier,
30
+ "body_codepoints": len(document.text(storage)),
31
+ }
32
+ else:
33
+ result = document.make_substring_bold(args.substring, occurrence=args.occurrence)
34
+ document.save(args.output)
35
+ with ZipFile(args.output) as archive:
36
+ bad_member = archive.testzip()
37
+ reparsed = PagesDocument.load(args.output)
38
+ result |= reparsed.validate() | {"zip_test": "pass" if bad_member is None else bad_member}
39
+ print(json.dumps(result, ensure_ascii=False, indent=2))
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
pages/comments.py ADDED
@@ -0,0 +1,168 @@
1
+ """Middle-layer access to Pages text comments and their replies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ import struct
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .protobuf import Message
11
+ from .stylesheet import RGBColor, decode_color
12
+ from .text import LocatedObject, parse_reference
13
+
14
+ if TYPE_CHECKING:
15
+ from .pages import PagesDocument
16
+
17
+
18
+ TYPE_ANNOTATION_AUTHOR = 212
19
+ TYPE_HIGHLIGHT = 2013
20
+ TYPE_COMMENT_STORAGE = 3056
21
+
22
+ STORAGE_TABLE_OVERLAPPING_HIGHLIGHT = 25
23
+ HIGHLIGHT_COMMENT_STORAGE = 1
24
+ COMMENT_TEXT = 1
25
+ COMMENT_CREATION_DATE = 2
26
+ COMMENT_AUTHOR = 3
27
+ COMMENT_REPLIES = 4
28
+ AUTHOR_NAME = 1
29
+
30
+ _APPLE_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
31
+
32
+
33
+ def _reference(message: Message, number: int) -> int | None:
34
+ field = message.one(number)
35
+ return None if field is None else parse_reference(bytes(field.value))
36
+
37
+
38
+ def _date(message: Message) -> datetime | None:
39
+ field = message.one(COMMENT_CREATION_DATE)
40
+ if field is None:
41
+ return None
42
+ value = Message.parse(bytes(field.value)).one(1)
43
+ if value is None or value.wire_type != 1:
44
+ return None
45
+ seconds = struct.unpack("<d", int(value.value).to_bytes(8, "little"))[0]
46
+ return _APPLE_EPOCH + timedelta(seconds=seconds)
47
+
48
+
49
+ @dataclass
50
+ class CommentStore:
51
+ """One type-3056 comment storage, optionally attached to a text range."""
52
+
53
+ document: "PagesDocument"
54
+ object: LocatedObject
55
+ highlight: LocatedObject | None = None
56
+ start_utf16: int | None = None
57
+ end_utf16: int | None = None
58
+
59
+ @property
60
+ def text(self) -> str:
61
+ field = self.object.message.one(COMMENT_TEXT)
62
+ return "" if field is None else bytes(field.value).decode("utf-8", "replace")
63
+
64
+ @text.setter
65
+ def text(self, value: str) -> None:
66
+ if not isinstance(value, str):
67
+ raise TypeError("comment text must be a string")
68
+ field = self.object.message.one(COMMENT_TEXT)
69
+ if field is None:
70
+ self.object.message.add_bytes(COMMENT_TEXT, value.encode("utf-8"))
71
+ else:
72
+ field.set_bytes(value.encode("utf-8"))
73
+
74
+ @property
75
+ def author_object(self) -> LocatedObject | None:
76
+ identifier = _reference(self.object.message, COMMENT_AUTHOR)
77
+ author = self.document.objects().get(identifier) if identifier is not None else None
78
+ if author is None or author.info.type_id != TYPE_ANNOTATION_AUTHOR:
79
+ return None
80
+ return author
81
+
82
+ @property
83
+ def author(self) -> str:
84
+ author = self.author_object
85
+ if author is None:
86
+ return ""
87
+ field = author.message.one(AUTHOR_NAME)
88
+ return "" if field is None else bytes(field.value).decode("utf-8", "replace")
89
+
90
+ @property
91
+ def annotation_color(self) -> RGBColor | None:
92
+ author = self.author_object
93
+ if author is None:
94
+ return None
95
+ field = author.message.one(2)
96
+ return None if field is None else decode_color(bytes(field.value))
97
+
98
+ @property
99
+ def timestamp(self) -> datetime | None:
100
+ return _date(self.object.message)
101
+
102
+ @property
103
+ def identifier(self) -> int:
104
+ return self.object.segment.identifier
105
+
106
+ @property
107
+ def comment_id(self) -> int:
108
+ return (
109
+ self.highlight.segment.identifier
110
+ if self.highlight is not None
111
+ else self.object.segment.identifier
112
+ )
113
+
114
+ @property
115
+ def replies(self) -> list["CommentStore"]:
116
+ objects = self.document.objects()
117
+ result: list[CommentStore] = []
118
+ for field in self.object.message.get(COMMENT_REPLIES):
119
+ identifier = parse_reference(bytes(field.value))
120
+ reply = objects.get(identifier) if identifier is not None else None
121
+ if reply is not None and reply.info.type_id == TYPE_COMMENT_STORAGE:
122
+ result.append(CommentStore(self.document, reply))
123
+ return result
124
+
125
+
126
+ def storage_comments(
127
+ document: "PagesDocument", storage: LocatedObject
128
+ ) -> list[CommentStore]:
129
+ """Return comments anchored by storage field 25 in document order."""
130
+ table_field = storage.message.one(STORAGE_TABLE_OVERLAPPING_HIGHLIGHT)
131
+ if table_field is None:
132
+ return []
133
+ objects = document.objects()
134
+ result: list[CommentStore] = []
135
+ table = Message.parse(bytes(table_field.value))
136
+ for raw in table.get(1):
137
+ entry = Message.parse(bytes(raw.value))
138
+ range_field = entry.one(1)
139
+ highlight_ref = entry.one(2)
140
+ if range_field is None or highlight_ref is None:
141
+ continue
142
+ range_message = Message.parse(bytes(range_field.value))
143
+ location = range_message.one(1)
144
+ length = range_message.one(2)
145
+ highlight_id = parse_reference(bytes(highlight_ref.value))
146
+ highlight = objects.get(highlight_id) if highlight_id is not None else None
147
+ if (
148
+ location is None
149
+ or length is None
150
+ or highlight is None
151
+ or highlight.info.type_id != TYPE_HIGHLIGHT
152
+ ):
153
+ continue
154
+ comment_id = _reference(highlight.message, HIGHLIGHT_COMMENT_STORAGE)
155
+ comment = objects.get(comment_id) if comment_id is not None else None
156
+ if comment is None or comment.info.type_id != TYPE_COMMENT_STORAGE:
157
+ continue
158
+ start = int(location.value)
159
+ result.append(
160
+ CommentStore(
161
+ document,
162
+ comment,
163
+ highlight=highlight,
164
+ start_utf16=start,
165
+ end_utf16=start + int(length.value),
166
+ )
167
+ )
168
+ return sorted(result, key=lambda item: (item.start_utf16 or 0, item.comment_id))
pages/components.py ADDED
@@ -0,0 +1,403 @@
1
+ """Schema-light cloning for complete iWork component subgraphs.
2
+
3
+ The high-level table and image insertion APIs use this module to duplicate a
4
+ Pages-authored object graph. Shared stylesheet objects remain shared while
5
+ owned archive objects, standalone component locators, UUID registrations, and
6
+ cross-component references receive fresh identities.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ import uuid
13
+ from zipfile import ZipInfo
14
+
15
+ from .iwa import ArchiveSegment, IWAFile, MessageInfo
16
+ from .metadata import (
17
+ COMPONENT_EXTERNAL_REFERENCES,
18
+ COMPONENT_IDENTIFIER,
19
+ COMPONENT_LOCATOR,
20
+ COMPONENT_OBJECT_UUID_MAP,
21
+ COMPONENT_PREFERRED_LOCATOR,
22
+ PACKAGE_COMPONENTS,
23
+ component_name,
24
+ object_uuid_map,
25
+ package_revision_identifier,
26
+ register_component_object,
27
+ )
28
+ from .pages import PagesDocument, StoredEntry
29
+ from .protobuf import Message, ProtobufError, encode_varint, read_varint
30
+ from .text import (
31
+ AttributeRun,
32
+ LocatedObject,
33
+ add_object_reference,
34
+ encode_attribute_table,
35
+ parse_attribute_table,
36
+ unpack_packed_varints,
37
+ )
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class GraphClone:
42
+ """The old-to-new identity map and cloned root object."""
43
+
44
+ root: LocatedObject
45
+ identifiers: dict[int, int]
46
+ component_identifiers: dict[int, int]
47
+
48
+
49
+ def _set_varint(message: Message, number: int, value: int) -> None:
50
+ field = message.one(number)
51
+ if field is None:
52
+ message.add_varint(number, value)
53
+ else:
54
+ field.set_varint(value)
55
+
56
+
57
+ def _set_bytes(message: Message, number: int, value: bytes) -> None:
58
+ field = message.one(number)
59
+ if field is None:
60
+ message.add_bytes(number, value)
61
+ else:
62
+ field.set_bytes(value)
63
+
64
+
65
+ def _pack_varints(values: list[int]) -> bytes:
66
+ return b"".join(encode_varint(value) for value in values)
67
+
68
+
69
+ def _info_references(obj: LocatedObject) -> list[int]:
70
+ result: list[int] = []
71
+ for field in obj.info.message.get(5):
72
+ result.extend(unpack_packed_varints(bytes(field.value)))
73
+ return result
74
+
75
+
76
+ def _remap_nested_references(
77
+ message: Message,
78
+ object_identifiers: dict[int, int],
79
+ data_identifiers: dict[int, int] | None = None,
80
+ ) -> bool:
81
+ """Remap embedded TSP.Reference/DataReference messages recursively."""
82
+ changed = False
83
+ data_identifiers = data_identifiers or {}
84
+ for field in message.fields:
85
+ if field.wire_type != 2:
86
+ continue
87
+ try:
88
+ child = Message.parse(bytes(field.value))
89
+ except ProtobufError:
90
+ continue
91
+ first = child.one(1)
92
+ reference_shape = (
93
+ first is not None
94
+ and first.wire_type == 0
95
+ and all(item.wire_type == 0 and item.number in (1, 2, 3) for item in child.fields)
96
+ )
97
+ if reference_shape and int(first.value) in object_identifiers:
98
+ first.set_varint(object_identifiers[int(first.value)])
99
+ field.set_bytes(child.encode())
100
+ changed = True
101
+ continue
102
+ if reference_shape and int(first.value) in data_identifiers:
103
+ first.set_varint(data_identifiers[int(first.value)])
104
+ field.set_bytes(child.encode())
105
+ changed = True
106
+ continue
107
+ if _remap_nested_references(child, object_identifiers, data_identifiers):
108
+ field.set_bytes(child.encode())
109
+ changed = True
110
+ return changed
111
+
112
+
113
+ def _remap_message_info(
114
+ info: Message,
115
+ object_identifiers: dict[int, int],
116
+ data_identifiers: dict[int, int] | None = None,
117
+ ) -> None:
118
+ data_identifiers = data_identifiers or {}
119
+ for field in info.get(5):
120
+ values = unpack_packed_varints(bytes(field.value))
121
+ field.set_bytes(_pack_varints([object_identifiers.get(value, value) for value in values]))
122
+ for field in info.get(6):
123
+ values = unpack_packed_varints(bytes(field.value))
124
+ field.set_bytes(_pack_varints([data_identifiers.get(value, value) for value in values]))
125
+
126
+
127
+ def remap_payload_data_references(
128
+ obj: LocatedObject, data_identifiers: dict[int, int]
129
+ ) -> None:
130
+ """Update data references in an already-cloned payload and MessageInfo."""
131
+ _remap_nested_references(obj.message, {}, data_identifiers)
132
+ _remap_message_info(obj.info.message, {}, data_identifiers)
133
+
134
+
135
+ def _clone_segment(
136
+ source: LocatedObject,
137
+ identifier: int,
138
+ object_identifiers: dict[int, int],
139
+ ) -> ArchiveSegment:
140
+ if len(source.segment.infos) != 1:
141
+ raise ValueError("component graph templates must use one-message archive segments")
142
+ payload = Message.parse(source.message.encode())
143
+ _remap_nested_references(payload, object_identifiers)
144
+
145
+ header = Message.parse(source.segment.header.encode())
146
+ header_identifier = header.one(1)
147
+ if header_identifier is None:
148
+ raise ValueError("component graph ArchiveInfo lacks an identifier")
149
+ header_identifier.set_varint(identifier)
150
+ info_fields = header.get(2)
151
+ if len(info_fields) != 1:
152
+ raise ValueError("component graph ArchiveInfo must contain one MessageInfo")
153
+ info_field = info_fields[0]
154
+ info_message = Message.parse(bytes(info_field.value))
155
+ _remap_message_info(info_message, object_identifiers)
156
+ length = info_message.one(3)
157
+ type_field = info_message.one(1)
158
+ if length is None or type_field is None:
159
+ raise ValueError("component graph MessageInfo is incomplete")
160
+ info = MessageInfo(info_field, info_message, int(type_field.value), int(length.value))
161
+ info.update_length(len(payload.encode()))
162
+ return ArchiveSegment(header, b"", identifier, [info], [payload], [b""])
163
+
164
+
165
+ def _component_records(package: Message) -> dict[int, tuple[object, Message]]:
166
+ result: dict[int, tuple[object, Message]] = {}
167
+ for field in package.get(PACKAGE_COMPONENTS):
168
+ component = Message.parse(bytes(field.value))
169
+ identifier = component.one(COMPONENT_IDENTIFIER)
170
+ if identifier is not None:
171
+ result[int(identifier.value)] = (field, component)
172
+ return result
173
+
174
+
175
+ def _component_for_filename(
176
+ filename: str, components: dict[int, tuple[object, Message]]
177
+ ) -> int | None:
178
+ locator = filename.removeprefix("Index/").removesuffix(".iwa")
179
+ for identifier, (_, component) in components.items():
180
+ if component_name(component) == locator:
181
+ return identifier
182
+ return None
183
+
184
+
185
+ def _remap_external_reference(
186
+ reference: Message,
187
+ object_identifiers: dict[int, int],
188
+ component_identifiers: dict[int, int],
189
+ ) -> bool:
190
+ changed = False
191
+ component = reference.one(1)
192
+ if component is not None and int(component.value) in component_identifiers:
193
+ component.set_varint(component_identifiers[int(component.value)])
194
+ changed = True
195
+ obj = reference.one(2)
196
+ if obj is not None and int(obj.value) in object_identifiers:
197
+ obj.set_varint(object_identifiers[int(obj.value)])
198
+ changed = True
199
+ return changed
200
+
201
+
202
+ def _append_external_reference(component: Message, reference: Message) -> None:
203
+ encoded = reference.encode()
204
+ if all(bytes(field.value) != encoded for field in component.get(COMPONENT_EXTERNAL_REFERENCES)):
205
+ component.add_bytes(COMPONENT_EXTERNAL_REFERENCES, encoded)
206
+
207
+
208
+ def clone_object_graph(
209
+ document: PagesDocument,
210
+ root_identifier: int,
211
+ *,
212
+ include,
213
+ ) -> GraphClone:
214
+ """Clone the reference closure selected by ``include``.
215
+
216
+ ``include`` receives each referenced :class:`LocatedObject`. It is used
217
+ to retain shared styles while selecting all objects owned by the template
218
+ table or drawable.
219
+ """
220
+ source_objects = document.objects()
221
+ root = source_objects.get(root_identifier)
222
+ if root is None:
223
+ raise KeyError(f"component graph root {root_identifier} was not found")
224
+
225
+ selected: dict[int, LocatedObject] = {}
226
+ queue = [root]
227
+ while queue:
228
+ obj = queue.pop(0)
229
+ identifier = obj.segment.identifier
230
+ if identifier in selected:
231
+ continue
232
+ selected[identifier] = obj
233
+ for reference in _info_references(obj):
234
+ candidate = source_objects.get(reference)
235
+ if candidate is not None and include(candidate):
236
+ queue.append(candidate)
237
+
238
+ object_identifiers = {
239
+ old: document.allocate_object_identifier() for old in sorted(selected)
240
+ }
241
+ package = document.package_metadata()
242
+ components = _component_records(package.message)
243
+ standalone_components: dict[int, int] = {}
244
+ for old, obj in selected.items():
245
+ component_id = _component_for_filename(obj.filename, components)
246
+ if component_id == old:
247
+ standalone_components[old] = object_identifiers[old]
248
+
249
+ # Clone standalone component records first so their new IWA locators are
250
+ # available when archive segments are placed.
251
+ new_locators: dict[int, str] = {}
252
+ for old_component, new_component in standalone_components.items():
253
+ source_component = components[old_component][1]
254
+ component = Message.parse(source_component.encode())
255
+ _set_varint(component, COMPONENT_IDENTIFIER, new_component)
256
+ preferred = component.one(COMPONENT_PREFERRED_LOCATOR)
257
+ if preferred is None:
258
+ raise ValueError(f"component {old_component} has no preferred locator")
259
+ base = bytes(preferred.value).decode("utf-8")
260
+ locator = f"{base}-{new_component}-2"
261
+ _set_bytes(component, COMPONENT_LOCATOR, locator.encode("utf-8"))
262
+ for field in component.get(COMPONENT_EXTERNAL_REFERENCES):
263
+ reference = Message.parse(bytes(field.value))
264
+ _remap_external_reference(reference, object_identifiers, standalone_components)
265
+ field.set_bytes(reference.encode())
266
+ for field in component.get(COMPONENT_OBJECT_UUID_MAP):
267
+ entry = Message.parse(bytes(field.value))
268
+ identifier = entry.one(1)
269
+ if identifier is not None and int(identifier.value) in object_identifiers:
270
+ identifier.set_varint(object_identifiers[int(identifier.value)])
271
+ field.set_bytes(entry.encode())
272
+ package.message.add_bytes(PACKAGE_COMPONENTS, component.encode())
273
+ new_locators[old_component] = locator
274
+
275
+ cloned: dict[int, LocatedObject] = {}
276
+ for old, source in selected.items():
277
+ new = object_identifiers[old]
278
+ segment = _clone_segment(source, new, object_identifiers)
279
+ if old in standalone_components:
280
+ filename = "Index/" + new_locators[old] + ".iwa"
281
+ document.entries.append(
282
+ StoredEntry(ZipInfo(filename), IWAFile([segment], []))
283
+ )
284
+ else:
285
+ filename = source.filename
286
+ document.iwa_files[filename].segments.append(segment)
287
+ cloned[new] = LocatedObject(
288
+ filename, segment, segment.infos[0], segment.payloads[0]
289
+ )
290
+
291
+ # Mirror cross-component links from existing owning components. Links in
292
+ # newly cloned standalone records were handled above.
293
+ for component_id, (raw_component, source_component) in components.items():
294
+ if component_id in standalone_components:
295
+ continue
296
+ component = Message.parse(bytes(raw_component.value))
297
+ for raw_reference in source_component.get(COMPONENT_EXTERNAL_REFERENCES):
298
+ reference = Message.parse(bytes(raw_reference.value))
299
+ if not _remap_external_reference(
300
+ reference, object_identifiers, standalone_components
301
+ ):
302
+ continue
303
+ _append_external_reference(component, reference)
304
+ raw_component.set_bytes(component.encode())
305
+
306
+ # Existing components use an explicit UUID map only for addressable top
307
+ # objects. Mirror precisely the source map's membership.
308
+ revision = package_revision_identifier(package.message)
309
+ for component_id, (raw_component, source_component) in components.items():
310
+ if component_id in standalone_components:
311
+ continue
312
+ registered = object_uuid_map(source_component)
313
+ locator = component_name(source_component)
314
+ for old in selected:
315
+ if old not in registered:
316
+ continue
317
+ register_component_object(
318
+ package.message,
319
+ locator,
320
+ object_identifiers[old],
321
+ uuid_seed=f"python-pages:{revision}:clone:{old}:{object_identifiers[old]}",
322
+ )
323
+
324
+ return GraphClone(
325
+ cloned[object_identifiers[root_identifier]],
326
+ object_identifiers,
327
+ standalone_components,
328
+ )
329
+
330
+
331
+ def replace_uuid_identities(message: Message, *, seed: str) -> None:
332
+ """Give cloned table UUID/CFUUID identities deterministic fresh values."""
333
+ uuid_pairs: dict[tuple[int, int], tuple[int, int]] = {}
334
+ cfuuid_parts: dict[tuple[int, int, int, int], tuple[int, int, int, int]] = {}
335
+
336
+ def visit(current: Message, path: str) -> None:
337
+ fields = current.fields
338
+ if (
339
+ len(fields) == 2
340
+ and {field.number for field in fields} == {1, 2}
341
+ and all(field.wire_type == 0 for field in fields)
342
+ ):
343
+ first, second = current.one(1), current.one(2)
344
+ key = (int(first.value), int(second.value))
345
+ if min(key) > 0xFFFFFFFF:
346
+ replacement = uuid_pairs.get(key)
347
+ if replacement is None:
348
+ generated = uuid.uuid5(uuid.NAMESPACE_URL, f"{seed}:uuid:{key}")
349
+ replacement = (
350
+ generated.int >> 64,
351
+ generated.int & ((1 << 64) - 1),
352
+ )
353
+ uuid_pairs[key] = replacement
354
+ first.set_varint(replacement[0])
355
+ second.set_varint(replacement[1])
356
+ return
357
+ if (
358
+ len(fields) == 4
359
+ and {field.number for field in fields} == {2, 3, 4, 5}
360
+ and all(field.wire_type == 0 for field in fields)
361
+ ):
362
+ key = tuple(int(current.one(number).value) for number in (2, 3, 4, 5))
363
+ replacement = cfuuid_parts.get(key)
364
+ if replacement is None:
365
+ raw = uuid.uuid5(uuid.NAMESPACE_URL, f"{seed}:cfuuid:{key}").bytes
366
+ replacement = tuple(
367
+ int.from_bytes(raw[offset : offset + 4], "big")
368
+ for offset in range(0, 16, 4)
369
+ )
370
+ cfuuid_parts[key] = replacement
371
+ for number, value in zip((2, 3, 4, 5), replacement):
372
+ current.one(number).set_varint(value)
373
+ return
374
+ for index, field in enumerate(fields):
375
+ if field.wire_type != 2:
376
+ continue
377
+ try:
378
+ child = Message.parse(bytes(field.value))
379
+ except ProtobufError:
380
+ continue
381
+ before = child.encode()
382
+ visit(child, f"{path}.{field.number}[{index}]")
383
+ after = child.encode()
384
+ if after != before:
385
+ field.set_bytes(after)
386
+
387
+ visit(message, "root")
388
+
389
+
390
+ def append_attachment_run(
391
+ storage: LocatedObject, position_utf16: int, attachment_identifier: int
392
+ ) -> None:
393
+ """Add an attachment boundary to StorageArchive field 9."""
394
+ field = storage.message.one(9)
395
+ if field is None:
396
+ field = storage.message.add_bytes(9, b"")
397
+ runs = parse_attribute_table(bytes(field.value))
398
+ if any(run.character_index == position_utf16 for run in runs):
399
+ raise ValueError(f"attachment boundary already exists at UTF-16 {position_utf16}")
400
+ runs.append(AttributeRun(position_utf16, attachment_identifier))
401
+ runs.sort(key=lambda run: run.character_index)
402
+ field.set_bytes(encode_attribute_table(runs))
403
+ add_object_reference(storage.info, attachment_identifier)