doc-control 0.0.1.dev0__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.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,573 @@
1
+ """Style write operations: ``style.create``, ``style.update``, ``style.rename``, ``style.merge``.
2
+
3
+ They edit ``word/styles.xml`` (one verified write, backup first) and, for a rename of the
4
+ style id or a merge, the style references (``w:pStyle``, ``w:rStyle``, ``w:tblStyle``) in
5
+ every other part: the document's paragraphs, headers, footers, footnotes, endnotes,
6
+ comments and numbering. ``basedOn``, ``next`` and ``link`` inside the styles part follow.
7
+ Each operation is also a ``batch`` kind, but a style batch holds only style operations
8
+ and its definition changes are not tracked changes (they are covered by the dry run, the
9
+ backup and the undo log). Latent styles are left alone: they are defaults for built-in
10
+ styles that are not instantiated, keyed by name.
11
+
12
+ A style is named by its id or by its display name wherever a request says ``style``,
13
+ ``from`` or ``into``; the stored references always use ids.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from collections.abc import Callable, Mapping
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Any
23
+ from xml.sax.saxutils import escape, unescape
24
+
25
+ from doc_control.envelope import Reply
26
+ from doc_control.errors import DocControlError, ErrorCode
27
+ from doc_control.model.document import Document
28
+ from doc_control.model.index import build_index
29
+ from doc_control.model.parts import DOCUMENT_RELS, OFFICE_REL, W, related_part
30
+ from doc_control.ooxml.styledef import (
31
+ STYLES_CONTENT_TYPE,
32
+ Rec,
33
+ StylesText,
34
+ apply_properties,
35
+ invalid,
36
+ new_style,
37
+ set_child,
38
+ )
39
+ from doc_control.ooxml.zipio import Package
40
+ from doc_control.registry import operation
41
+ from doc_control.safety.lock import check_changed, check_unlocked
42
+ from doc_control.safety.verify import DOCUMENT_PART, Footprint, write_verified
43
+
44
+ STYLES_PART = "word/styles.xml"
45
+ STYLE_KINDS = ("style.create", "style.update", "style.rename", "style.merge")
46
+ TYPES = ("paragraph", "character", "table", "numbering")
47
+ _REF = re.compile(rb'(<(?:[\w.-]+:)?(?:pStyle|rStyle|tblStyle)\b[^>]*?\bval=")([^"]*)(")')
48
+
49
+ _DOC = {"type": "string", "minLength": 1, "description": "Path to the .docx."}
50
+ _FIELDS: dict[str, dict[str, Any]] = {
51
+ "id": {"type": "string", "minLength": 1, "description": "Style id (default: from the name)."},
52
+ "name": {"type": "string", "minLength": 1, "description": "Display name."},
53
+ "type": {"enum": list(TYPES), "description": "Kind of style (default paragraph)."},
54
+ "based_on": {"type": "string", "description": "Parent style id or name; '' for none."},
55
+ "next": {"type": "string", "description": "Style of the next paragraph; '' for none."},
56
+ "link": {"type": "string", "description": "Linked paragraph/character style; '' for none."},
57
+ "quick": {"type": "boolean", "description": "Show in the quick-style gallery."},
58
+ "default": {"type": "boolean", "description": "The default style of its type."},
59
+ "custom": {"type": "boolean", "description": "A user-defined (not built-in) style."},
60
+ "run": {
61
+ "type": "object",
62
+ "description": "Character formatting: bold, italic, small_caps, strike, underline, "
63
+ "font, size_pt, color, highlight, vertical_align; null removes a key.",
64
+ },
65
+ "paragraph": {
66
+ "type": "object",
67
+ "description": "Paragraph formatting (twips): alignment, indent{left,right,first_line,"
68
+ "hanging}, spacing{before,after,line,line_rule}, keep_next, keep_lines, "
69
+ "page_break_before, outline_level; null removes a key.",
70
+ },
71
+ }
72
+ _STYLE_REF = {"type": "string", "minLength": 1, "description": "Style id or name."}
73
+ _OPS: dict[str, tuple[dict[str, dict[str, Any]], list[str], str]] = {
74
+ "style.create": (_FIELDS, ["name"], "Add a style."),
75
+ "style.update": (
76
+ {
77
+ "style": _STYLE_REF,
78
+ **{k: v for k, v in _FIELDS.items() if k in ("based_on", "next", "link", "quick")},
79
+ "run": _FIELDS["run"],
80
+ "paragraph": _FIELDS["paragraph"],
81
+ },
82
+ ["style"],
83
+ "Change a style's parent, next/link styles, quick flag or formatting.",
84
+ ),
85
+ "style.rename": (
86
+ {
87
+ "style": _STYLE_REF,
88
+ "name": _FIELDS["name"],
89
+ "id": _FIELDS["id"],
90
+ },
91
+ ["style"],
92
+ "Change a style's display name and/or id; references follow.",
93
+ ),
94
+ "style.merge": (
95
+ {
96
+ "from": {
97
+ "description": "Style id or name, or a list of them (they are removed).",
98
+ },
99
+ "into": _STYLE_REF,
100
+ },
101
+ ["from", "into"],
102
+ "Re-point every use of the 'from' styles to 'into' and delete them.",
103
+ ),
104
+ }
105
+
106
+
107
+ def op_schema(kind: str) -> dict[str, Any]:
108
+ """The batch-item schema of one style operation."""
109
+ fields, required, _ = _OPS[kind]
110
+ return {
111
+ "type": "object",
112
+ "properties": {"op": {"enum": [kind]}, **fields},
113
+ "required": ["op", *required],
114
+ "additionalProperties": False,
115
+ }
116
+
117
+
118
+ # -- the editing session ------------------------------------------------------
119
+
120
+
121
+ class StyleSession:
122
+ def __init__(self, pkg: Package) -> None:
123
+ self.pkg = pkg
124
+ self.part = related_part(pkg, OFFICE_REL + "styles", STYLES_PART)
125
+ self.styles = StylesText(pkg.read(self.part) if self.part else None)
126
+ self.original = self.styles.text
127
+ self.remap: dict[str, str] = {}
128
+ self.touched: set[str] = set()
129
+
130
+ def records(self) -> dict[str, Rec]:
131
+ return self.styles.records()
132
+
133
+ def resolve(self, key: str) -> Rec:
134
+ recs = self.records()
135
+ found = recs.get(key) or next(
136
+ (r for r in recs.values() if r.name.lower() == key.lower()), None
137
+ )
138
+ if found is None:
139
+ raise DocControlError(ErrorCode.STYLE_NOT_FOUND, f"no style {key!r}", style=key)
140
+ return found
141
+
142
+ def ref_id(self, key: str) -> str:
143
+ """A style reference as an id: by id, else by name, else as written (checked later)."""
144
+ recs = self.records()
145
+ if key in recs:
146
+ return key
147
+ return next((r.id for r in recs.values() if r.name.lower() == key.lower()), key)
148
+
149
+ def remap_ref(self, old: str, new: str) -> None:
150
+ for k, v in self.remap.items():
151
+ if v == old:
152
+ self.remap[k] = new
153
+ self.remap[old] = new
154
+
155
+ def check(self) -> None:
156
+ """Every reference of a touched style resolves and nothing is based on itself."""
157
+ recs = self.records()
158
+ for sid in self.touched:
159
+ rec = recs.get(sid)
160
+ if rec is None:
161
+ continue
162
+ for label, target in (
163
+ ("based_on", rec.based_on),
164
+ ("next", rec.next),
165
+ ("link", rec.link),
166
+ ):
167
+ if target is not None and target not in recs:
168
+ raise DocControlError(
169
+ ErrorCode.STYLE_NOT_FOUND,
170
+ f"style {sid!r} {label} names {target!r}, which does not exist",
171
+ style=target,
172
+ )
173
+ seen = {sid}
174
+ parent = rec.based_on
175
+ while parent is not None:
176
+ if parent in seen:
177
+ raise DocControlError(
178
+ ErrorCode.STYLE_CONFLICT,
179
+ f"style {sid!r} would be based on itself through {parent!r}",
180
+ style=sid,
181
+ )
182
+ seen.add(parent)
183
+ parent = recs[parent].based_on if parent in recs else None
184
+
185
+
186
+ def _conflict(message: str, **details: Any) -> DocControlError:
187
+ return DocControlError(ErrorCode.STYLE_CONFLICT, message, **details)
188
+
189
+
190
+ def apply_refs(el: Any, spec: dict[str, Any], session: StyleSession) -> None:
191
+ for key, tag in (("based_on", "basedOn"), ("next", "next"), ("link", "link")):
192
+ if key in spec:
193
+ value = spec[key]
194
+ if not isinstance(value, str):
195
+ raise invalid(f"{key} must be a string")
196
+ set_child(el, tag, session.ref_id(value) if value else None)
197
+ if "quick" in spec:
198
+ flag = spec["quick"]
199
+ if not isinstance(flag, bool):
200
+ raise invalid("quick must be true or false")
201
+ set_child(el, "qFormat", "1" if flag else None, flag=True)
202
+
203
+
204
+ def create_style(s: StyleSession, spec: dict[str, Any]) -> dict[str, Any]:
205
+ name = spec.get("name")
206
+ if not isinstance(name, str) or not name:
207
+ raise invalid("name must be a non-empty string")
208
+ kind = spec.get("type", "paragraph")
209
+ if kind not in TYPES:
210
+ raise invalid(f"type must be one of {', '.join(TYPES)}")
211
+ recs = s.records()
212
+ sid = spec.get("id")
213
+ if sid is None:
214
+ base = re.sub(r"[^A-Za-z0-9]", "", name) or "Style"
215
+ sid, n = base, 1
216
+ while sid in recs:
217
+ n += 1
218
+ sid = f"{base}{n}"
219
+ elif not isinstance(sid, str) or not sid:
220
+ raise invalid("id must be a non-empty string")
221
+ if sid in recs:
222
+ raise _conflict(f"a style with id {sid!r} already exists", style=sid)
223
+ if any(r.name.lower() == name.lower() for r in recs.values()):
224
+ raise _conflict(f"a style named {name!r} already exists", name=name)
225
+ default = bool(spec.get("default", False))
226
+ if default and any(r.default and r.type == kind for r in recs.values()):
227
+ raise _conflict(f"a default {kind} style already exists")
228
+ el = new_style(sid, name, kind, default=default, custom=bool(spec.get("custom", not default)))
229
+ apply_refs(el, spec, s)
230
+ apply_properties(el, spec.get("run"), spec.get("paragraph"), replace=False)
231
+ s.styles.put(el)
232
+ s.touched.add(sid)
233
+ return {"op": "style.create", "style": sid, "name": name, "type": kind}
234
+
235
+
236
+ def _update(s: StyleSession, spec: dict[str, Any], *, replace: bool = False) -> dict[str, Any]:
237
+ rec = s.resolve(_need(spec, "style"))
238
+ el = s.styles.element(rec.id)
239
+ apply_refs(el, spec, s)
240
+ apply_properties(el, spec.get("run"), spec.get("paragraph"), replace=replace)
241
+ s.styles.put(el)
242
+ s.touched.add(rec.id)
243
+ return {"op": "style.update", "style": rec.id, "name": rec.name}
244
+
245
+
246
+ def _need(spec: dict[str, Any], key: str) -> str:
247
+ value = spec.get(key)
248
+ if not isinstance(value, str) or not value:
249
+ raise invalid(f"{key} must be a non-empty string")
250
+ return value
251
+
252
+
253
+ def _rename(s: StyleSession, spec: dict[str, Any]) -> dict[str, Any]:
254
+ rec = s.resolve(_need(spec, "style"))
255
+ if "name" not in spec and "id" not in spec:
256
+ raise invalid("give a new name and/or a new id")
257
+ el = s.styles.element(rec.id)
258
+ recs = s.records()
259
+ new_name = spec.get("name", rec.name)
260
+ new_id = spec.get("id", rec.id)
261
+ for key, value in (("name", new_name), ("id", new_id)):
262
+ if not isinstance(value, str) or not value:
263
+ raise invalid(f"{key} must be a non-empty string")
264
+ if any(r.id != rec.id and r.name.lower() == new_name.lower() for r in recs.values()):
265
+ raise _conflict(f"a style named {new_name!r} already exists", name=new_name)
266
+ if new_id != rec.id and new_id in recs:
267
+ raise _conflict(f"a style with id {new_id!r} already exists", style=new_id)
268
+ set_child(el, "name", new_name)
269
+ if new_id != rec.id:
270
+ el.set(W + "styleId", new_id)
271
+ s.styles.put(el, replacing=rec.id)
272
+ if new_id != rec.id:
273
+ s.styles.retarget(rec.id, new_id)
274
+ s.remap_ref(rec.id, new_id)
275
+ s.touched.discard(rec.id)
276
+ s.touched.add(new_id)
277
+ return {
278
+ "op": "style.rename",
279
+ "style": new_id,
280
+ "name": new_name,
281
+ "was": {"id": rec.id, "name": rec.name},
282
+ }
283
+
284
+
285
+ def _merge(s: StyleSession, spec: dict[str, Any]) -> dict[str, Any]:
286
+ sources = spec.get("from")
287
+ if isinstance(sources, str):
288
+ sources = [sources]
289
+ if (
290
+ not isinstance(sources, list)
291
+ or not sources
292
+ or not all(isinstance(x, str) and x for x in sources)
293
+ ):
294
+ raise invalid("from must be a style id/name or a non-empty list of them")
295
+ target = s.resolve(_need(spec, "into"))
296
+ merged: list[str] = []
297
+ before = s.records()
298
+ for key in sources:
299
+ src = s.resolve(key)
300
+ if src.id == target.id:
301
+ raise _conflict(f"cannot merge {src.id!r} into itself", style=src.id)
302
+ if src.type != target.type:
303
+ raise _conflict(
304
+ f"cannot merge {src.type} style {src.id!r} into {target.type} style {target.id!r}"
305
+ )
306
+ if src.type in ("table", "numbering"):
307
+ raise DocControlError(
308
+ ErrorCode.UNSUPPORTED_CONTENT, f"merging {src.type} styles is not supported"
309
+ )
310
+ if src.default:
311
+ raise _conflict(f"{src.id!r} is the default {src.type} style and cannot be merged away")
312
+ s.styles.retarget(src.id, target.id, "basedOn|next")
313
+ s.styles.retarget(src.id, None, "link")
314
+ s.styles.remove(src.id)
315
+ s.remap_ref(src.id, target.id)
316
+ s.touched.discard(src.id)
317
+ merged.append(src.id)
318
+ base = before[target.id].based_on
319
+ if base in merged:
320
+ # Based on a merged-away style: inherit from that style's own parent instead.
321
+ while base in merged:
322
+ base = before[base].based_on
323
+ el = s.styles.element(target.id)
324
+ set_child(el, "basedOn", None if base == target.id else base)
325
+ s.styles.put(el)
326
+ s.touched.add(target.id)
327
+ return {"op": "style.merge", "style": target.id, "merged": merged}
328
+
329
+
330
+ _HANDLERS: dict[str, Callable[[StyleSession, dict[str, Any]], dict[str, Any]]] = {
331
+ "style.create": create_style,
332
+ "style.update": _update,
333
+ "style.rename": _rename,
334
+ "style.merge": _merge,
335
+ }
336
+
337
+
338
+ # -- references in the other parts --------------------------------------------
339
+
340
+
341
+ def _rewriter(remap: Mapping[str, str]) -> Callable[[bytes], bytes]:
342
+ def sub(m: re.Match[bytes]) -> bytes:
343
+ old = unescape(m.group(2).decode("utf-8"), {"&quot;": '"'})
344
+ if old not in remap:
345
+ return m.group(0)
346
+ new = escape(remap[old], {'"': "&quot;"}).encode("utf-8")
347
+ return m.group(1) + new + m.group(3)
348
+
349
+ return lambda data: _REF.sub(sub, data)
350
+
351
+
352
+ def _rewrite_parts(
353
+ pkg: Package, styles_part: str | None, remap: dict[str, str]
354
+ ) -> tuple[dict[str, bytes], frozenset[int], int]:
355
+ """New bytes for every part that references a remapped style, and the paragraphs changed."""
356
+ rewrite = _rewriter(remap)
357
+ replacements: dict[str, bytes] = {}
358
+ count = 0
359
+ for name in pkg.names:
360
+ if name in (DOCUMENT_PART, styles_part) or not (
361
+ name.startswith("word/") and name.endswith(".xml")
362
+ ):
363
+ continue
364
+ data = pkg.read(name)
365
+ if b"Style" not in data:
366
+ continue
367
+ new = rewrite(data)
368
+ if new != data:
369
+ replacements[name] = new
370
+ count += _hits(data, remap)
371
+ data = pkg.read(DOCUMENT_PART)
372
+ if b"Style" not in data:
373
+ return replacements, frozenset(), count
374
+ wanted = _hits(data, remap)
375
+ if not wanted:
376
+ return replacements, frozenset(), count
377
+ spans = build_index(data).spans.paragraphs
378
+ changed: set[int] = set()
379
+ pieces: list[bytes] = []
380
+ pos = 0
381
+ done = 0
382
+ for i, span in enumerate(spans):
383
+ if span.start < pos:
384
+ continue # nested in the previous paragraph: rewritten with it
385
+ chunk = data[span.start : span.end]
386
+ new = rewrite(chunk)
387
+ if new != chunk:
388
+ changed.add(i)
389
+ done += _hits(chunk, remap)
390
+ pieces.append(data[pos : span.start])
391
+ pieces.append(new)
392
+ pos = span.end
393
+ if done < wanted:
394
+ raise DocControlError(
395
+ ErrorCode.UNSUPPORTED_CONTENT,
396
+ "a style being renamed or merged is referenced outside any paragraph "
397
+ "(a table style?), which a verified write cannot rewrite",
398
+ )
399
+ pieces.append(data[pos:])
400
+ replacements[DOCUMENT_PART] = b"".join(pieces)
401
+ return replacements, frozenset(changed), count + done
402
+
403
+
404
+ def _hits(data: bytes, remap: Mapping[str, str]) -> int:
405
+ return sum(
406
+ 1
407
+ for m in _REF.finditer(data)
408
+ if unescape(m.group(2).decode("utf-8"), {"&quot;": '"'}) in remap
409
+ )
410
+
411
+
412
+ def _register_part(pkg: Package, replacements: dict[str, bytes]) -> None:
413
+ """Content type and document relationship for a styles part that did not exist."""
414
+ types = pkg.read("[Content_Types].xml").decode("utf-8")
415
+ entry = f'<Override PartName="/{STYLES_PART}" ContentType="{STYLES_CONTENT_TYPE}"/>'
416
+ replacements["[Content_Types].xml"] = types.replace("</Types>", entry + "</Types>").encode()
417
+ if DOCUMENT_RELS in pkg:
418
+ rels = pkg.read(DOCUMENT_RELS).decode("utf-8")
419
+ used = {int(n) for n in re.findall(r'Id="rId(\d+)"', rels)}
420
+ rid = f"rId{max(used, default=0) + 1}"
421
+ rel = f'<Relationship Id="{rid}" Type="{OFFICE_REL}styles" Target="styles.xml"/>'
422
+ replacements[DOCUMENT_RELS] = rels.replace(
423
+ "</Relationships>", rel + "</Relationships>"
424
+ ).encode()
425
+ else:
426
+ replacements[DOCUMENT_RELS] = (
427
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
428
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
429
+ f'<Relationship Id="rId1" Type="{OFFICE_REL}styles" Target="styles.xml"/>'
430
+ "</Relationships>"
431
+ ).encode()
432
+
433
+
434
+ # -- the write ------------------------------------------------------------------
435
+
436
+
437
+ def run_style_ops(
438
+ session: StyleSession, ops: list[dict[str, Any]], handlers: Mapping[str, Any] | None = None
439
+ ) -> list[dict[str, Any]]:
440
+ out = []
441
+ for i, raw in enumerate(ops):
442
+ kind = raw.get("op")
443
+ handler = (handlers or _HANDLERS).get(kind) if isinstance(kind, str) else None
444
+ if handler is None:
445
+ raise invalid(f"ops[{i}]: not a style operation: {kind!r}", op_index=i)
446
+ spec = {k: v for k, v in raw.items() if k != "op"}
447
+ try:
448
+ out.append(handler(session, spec))
449
+ except DocControlError as err:
450
+ raise DocControlError(
451
+ err.code, f"ops[{i}]: {err.message}", op_index=i, **err.details
452
+ ) from err
453
+ session.check()
454
+ return out
455
+
456
+
457
+ def commit_styles(
458
+ doc_path: Path,
459
+ prepare: Callable[[StyleSession], list[dict[str, Any]]],
460
+ *,
461
+ expect_sha256: str | None = None,
462
+ dry_run: bool = False,
463
+ now: datetime | None = None,
464
+ env: Mapping[str, str] | None = None,
465
+ stage: Callable[[str], None] | None = None,
466
+ ) -> dict[str, Any]:
467
+ """Run ``prepare`` on a session over ``doc_path`` and write the result in one verified write."""
468
+ if not doc_path.is_file():
469
+ raise DocControlError(
470
+ ErrorCode.DOC_NOT_FOUND, f"no such file: {doc_path}", path=str(doc_path)
471
+ )
472
+ if not dry_run:
473
+ check_unlocked(doc_path)
474
+ check_changed(doc_path, expect_sha256)
475
+ pkg = Package(doc_path)
476
+ session = StyleSession(pkg)
477
+ applied = prepare(session)
478
+ session.check()
479
+ replacements: dict[str, bytes] = {}
480
+ changed_paras: frozenset[int] = frozenset()
481
+ references = 0
482
+ if session.remap:
483
+ replacements, changed_paras, references = _rewrite_parts(
484
+ pkg, session.part, {k: v for k, v in session.remap.items() if k != v}
485
+ )
486
+ if session.styles.text != session.original or session.part is None:
487
+ target = session.part or STYLES_PART
488
+ replacements[target] = session.styles.data()
489
+ if session.part is None:
490
+ _register_part(pkg, replacements)
491
+ result: dict[str, Any] = {
492
+ "applied": applied,
493
+ "dry_run": dry_run,
494
+ "tracked": False,
495
+ "changed_parts": sorted(replacements),
496
+ "paragraphs_changed": len(changed_paras),
497
+ "references_rewritten": references,
498
+ }
499
+ if dry_run or not replacements:
500
+ if not dry_run:
501
+ result["sha256"] = pkg.sha256
502
+ return result
503
+ written = write_verified(
504
+ doc_path,
505
+ replacements,
506
+ Footprint(replaced=changed_paras, parts=frozenset(replacements)),
507
+ pkg.sha256,
508
+ now=now,
509
+ env=env,
510
+ stage=stage,
511
+ )
512
+ result["sha256"] = written.after_sha256
513
+ result["backup"] = str(written.backup)
514
+ return result
515
+
516
+
517
+ def apply_style_ops(
518
+ doc_path: Path,
519
+ ops: list[dict[str, Any]],
520
+ *,
521
+ expect_sha256: str | None = None,
522
+ dry_run: bool = False,
523
+ now: datetime | None = None,
524
+ env: Mapping[str, str] | None = None,
525
+ stage: Callable[[str], None] | None = None,
526
+ ) -> dict[str, Any]:
527
+ """Apply style operations (``ops`` as in a batch) in one verified write."""
528
+ if not ops:
529
+ raise invalid("no style operations given")
530
+ return commit_styles(
531
+ doc_path,
532
+ lambda session: run_style_ops(session, ops),
533
+ expect_sha256=expect_sha256,
534
+ dry_run=dry_run,
535
+ now=now,
536
+ env=env,
537
+ stage=stage,
538
+ )
539
+
540
+
541
+ def _register(kind: str) -> None:
542
+ fields, required, summary = _OPS[kind]
543
+ schema = {
544
+ "type": "object",
545
+ "properties": {
546
+ "doc": _DOC,
547
+ **fields,
548
+ "expect": {
549
+ "type": "object",
550
+ "properties": {"sha256": {"type": "string", "minLength": 1}},
551
+ "additionalProperties": False,
552
+ },
553
+ "dry_run": {"type": "boolean", "description": "Preview only; write nothing."},
554
+ },
555
+ "required": ["doc", *required],
556
+ "additionalProperties": False,
557
+ }
558
+
559
+ def handler(request: dict[str, Any]) -> Reply:
560
+ spec = {k: v for k, v in request.items() if k not in ("doc", "expect", "dry_run")}
561
+ result = apply_style_ops(
562
+ Path(request["doc"]),
563
+ [{"op": kind, **spec}],
564
+ expect_sha256=request.get("expect", {}).get("sha256"),
565
+ dry_run=request.get("dry_run", False),
566
+ )
567
+ return Reply(result, doc=Document(request["doc"]).envelope_doc())
568
+
569
+ operation(kind, "write", summary, schema)(handler)
570
+
571
+
572
+ for _kind in STYLE_KINDS:
573
+ _register(_kind)