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.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- 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"), {""": '"'})
|
|
344
|
+
if old not in remap:
|
|
345
|
+
return m.group(0)
|
|
346
|
+
new = escape(remap[old], {'"': """}).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"), {""": '"'}) 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)
|