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,370 @@
|
|
|
1
|
+
"""``term.rename``: rename a term everywhere, as one tracked batch of ``text.replace`` edits.
|
|
2
|
+
|
|
3
|
+
The body text is searched with the same matcher as ``find`` (accent and case folding,
|
|
4
|
+
whole word), every match becomes one ``text.replace`` and all of them go through
|
|
5
|
+
:func:`doc_control.ops.tracked.apply_edits` in a single verified write. Matches that
|
|
6
|
+
span several runs are replaced like any other range. ``dry_run`` plans everything and
|
|
7
|
+
writes nothing; the result carries counts and a sample of the changes.
|
|
8
|
+
|
|
9
|
+
Options
|
|
10
|
+
``whole_word`` (default true): no letter, digit or mark on either side.
|
|
11
|
+
``case_sensitive`` (default false): when true only the exact spelling matches and
|
|
12
|
+
``to`` is used as written.
|
|
13
|
+
``preserve_case`` (default true, ignored when case sensitive): a match written in
|
|
14
|
+
capitals gets ``to`` in capitals (``LUCÍA`` becomes ``ELENA``), one in lower case gets
|
|
15
|
+
it in lower case, one with a capital first letter gets ``to`` with a capital first
|
|
16
|
+
letter, the exact spelling of ``from`` gets ``to`` as written, any other mix gets
|
|
17
|
+
``to`` as written.
|
|
18
|
+
``ignore_accents`` (default false): ``Lucia`` also matches ``Lucía``.
|
|
19
|
+
``inflections``: suffixes that follow the term. A string ``"s"`` renames ``Lucías``
|
|
20
|
+
to ``Elenas``; an object ``{"from": "o", "to": "a"}`` renames ``Lucíao`` to
|
|
21
|
+
``Elenaa``. Each form is matched as a whole word too. Where forms overlap, the
|
|
22
|
+
longest wins.
|
|
23
|
+
|
|
24
|
+
Deferred: a nested ``{"op": "term.rename"}`` inside ``batch`` (it needs the document to
|
|
25
|
+
expand); ``term.rename`` is its own write operation for now.
|
|
26
|
+
|
|
27
|
+
Not renamed, never corrupted
|
|
28
|
+
Hidden text (runs with ``w:vanish``) and field instructions are not part of the
|
|
29
|
+
visible text, so they are not searched and stay as they are. A match with a character
|
|
30
|
+
in a field result, a hyperlink, content control or other structure the editor does not
|
|
31
|
+
cut through is skipped and counted (``skipped_unsupported``), as is a match that would
|
|
32
|
+
split a character from its combining marks (``skipped_boundary``). Everything else in
|
|
33
|
+
the paragraph is still renamed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from doc_control.envelope import Reply
|
|
43
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
44
|
+
from doc_control.model.document import Document
|
|
45
|
+
from doc_control.model.index import DocumentIndex
|
|
46
|
+
from doc_control.model.search import Matcher, SearchOptions
|
|
47
|
+
from doc_control.ooxml.revisions import (
|
|
48
|
+
DEFAULT_AUTHOR,
|
|
49
|
+
ParagraphEditor,
|
|
50
|
+
RevisionContext,
|
|
51
|
+
format_date,
|
|
52
|
+
used_ids,
|
|
53
|
+
)
|
|
54
|
+
from doc_control.ooxml.zipio import Package
|
|
55
|
+
from doc_control.ops.tracked import ReplaceText, TrackedResult, apply_edits, boundary_ok
|
|
56
|
+
from doc_control.registry import operation
|
|
57
|
+
|
|
58
|
+
DEFAULT_PREVIEW = 20
|
|
59
|
+
MAX_PREVIEW = 200
|
|
60
|
+
MAX_SUFFIX = 50
|
|
61
|
+
CONTEXT = 30
|
|
62
|
+
|
|
63
|
+
_SCHEMA: dict[str, Any] = {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"doc": {"type": "string", "minLength": 1, "description": "Path to the .docx."},
|
|
67
|
+
"from": {"type": "string", "minLength": 1, "description": "Term to rename."},
|
|
68
|
+
"to": {"type": "string", "minLength": 1, "description": "New term."},
|
|
69
|
+
"whole_word": {"type": "boolean", "default": True, "description": "Whole words only."},
|
|
70
|
+
"case_sensitive": {
|
|
71
|
+
"type": "boolean",
|
|
72
|
+
"default": False,
|
|
73
|
+
"description": "Exact spelling only; `to` is used as written.",
|
|
74
|
+
},
|
|
75
|
+
"preserve_case": {
|
|
76
|
+
"type": "boolean",
|
|
77
|
+
"default": True,
|
|
78
|
+
"description": "LUCÍA becomes ELENA, lucía becomes elena, Lucía becomes Elena.",
|
|
79
|
+
},
|
|
80
|
+
"ignore_accents": {
|
|
81
|
+
"type": "boolean",
|
|
82
|
+
"default": False,
|
|
83
|
+
"description": "`Lucia` also matches `Lucía`.",
|
|
84
|
+
},
|
|
85
|
+
"inflections": {
|
|
86
|
+
"type": "array",
|
|
87
|
+
"description": (
|
|
88
|
+
'Suffixes that follow the term: "s" renames Lucías to Elenas; '
|
|
89
|
+
'{"from": "o", "to": "a"} renames Lucíao to Elenaa.'
|
|
90
|
+
),
|
|
91
|
+
"items": {"type": ["string", "object"]},
|
|
92
|
+
},
|
|
93
|
+
"expect": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {"sha256": {"type": "string", "minLength": 1}},
|
|
96
|
+
"additionalProperties": False,
|
|
97
|
+
},
|
|
98
|
+
"dry_run": {"type": "boolean", "description": "Plan and preview only; write nothing."},
|
|
99
|
+
"preview_limit": {
|
|
100
|
+
"type": "integer",
|
|
101
|
+
"minimum": 0,
|
|
102
|
+
"maximum": MAX_PREVIEW,
|
|
103
|
+
"default": DEFAULT_PREVIEW,
|
|
104
|
+
"description": "Most changes echoed back in `preview`.",
|
|
105
|
+
},
|
|
106
|
+
"author": {"type": "string", "minLength": 1, "description": "Revision author."},
|
|
107
|
+
"tag": {"type": "string", "minLength": 1, "description": "Appended to the author."},
|
|
108
|
+
},
|
|
109
|
+
"required": ["doc", "from", "to"],
|
|
110
|
+
"additionalProperties": False,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Counts:
|
|
116
|
+
matches: int = 0
|
|
117
|
+
replaced: int = 0
|
|
118
|
+
unchanged: int = 0
|
|
119
|
+
paragraphs: int = 0
|
|
120
|
+
skipped_unsupported: int = 0
|
|
121
|
+
skipped_boundary: int = 0
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, int]:
|
|
124
|
+
return dict(vars(self))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class _Change:
|
|
129
|
+
at: str
|
|
130
|
+
paragraph: int
|
|
131
|
+
start: int
|
|
132
|
+
end: int
|
|
133
|
+
before: str
|
|
134
|
+
after: str
|
|
135
|
+
context: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class RenamePlan:
|
|
140
|
+
edits: list[ReplaceText] = field(default_factory=list)
|
|
141
|
+
changes: list[_Change] = field(default_factory=list)
|
|
142
|
+
counts: Counts = field(default_factory=Counts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _bad(i: int, why: str) -> DocControlError:
|
|
146
|
+
return DocControlError(
|
|
147
|
+
ErrorCode.SCHEMA_INVALID, f"inflections[{i}]: {why}", path="$.inflections"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _forms(inflections: list[Any]) -> list[tuple[str, str]]:
|
|
152
|
+
"""``(suffix on the old term, suffix on the new term)``, the bare term first."""
|
|
153
|
+
forms = [("", "")]
|
|
154
|
+
for i, item in enumerate(inflections):
|
|
155
|
+
if isinstance(item, str):
|
|
156
|
+
old = new = item
|
|
157
|
+
elif isinstance(item, dict) and set(item) <= {"from", "to"} and "from" in item:
|
|
158
|
+
raw_old, raw_new = item["from"], item.get("to", item["from"])
|
|
159
|
+
if not isinstance(raw_old, str) or not isinstance(raw_new, str):
|
|
160
|
+
raise _bad(i, "'from' and 'to' must be strings")
|
|
161
|
+
old, new = raw_old, raw_new
|
|
162
|
+
else:
|
|
163
|
+
raise _bad(i, 'use a suffix string or {"from": suffix, "to": suffix}')
|
|
164
|
+
if not old or len(old) > MAX_SUFFIX or len(new) > MAX_SUFFIX:
|
|
165
|
+
raise _bad(i, f"a suffix must have 1 to {MAX_SUFFIX} characters")
|
|
166
|
+
if (old, new) not in forms:
|
|
167
|
+
forms.append((old, new))
|
|
168
|
+
return forms
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cased(c: str) -> bool:
|
|
172
|
+
return c.lower() != c.upper()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def match_case(matched: str, exact: str, target: str) -> str:
|
|
176
|
+
"""``target`` dressed in the capitalisation of ``matched``.
|
|
177
|
+
|
|
178
|
+
The exact spelling of the old term, and any odd mix, give ``target`` as written. All
|
|
179
|
+
capitals (two letters or more) give ``target.upper()``. Lower case gives
|
|
180
|
+
``target.lower()`` unless ``target`` has a capital after its first letter (``McDonald``).
|
|
181
|
+
A capital first letter followed by lower case capitalises the first letter of
|
|
182
|
+
``target``, unless ``target`` is not all lower case or title case (``iPhone``).
|
|
183
|
+
"""
|
|
184
|
+
if matched == exact:
|
|
185
|
+
return target
|
|
186
|
+
letters = [c for c in matched if _cased(c)]
|
|
187
|
+
if not letters:
|
|
188
|
+
return target
|
|
189
|
+
if all(c == c.lower() for c in letters):
|
|
190
|
+
inner = [c for c in target if _cased(c)][1:]
|
|
191
|
+
return target if any(c != c.lower() for c in inner) else target.lower()
|
|
192
|
+
if len(letters) > 1 and all(c == c.upper() for c in letters):
|
|
193
|
+
return target.upper()
|
|
194
|
+
if letters[0] == letters[0].upper() and all(c == c.lower() for c in letters[1:]):
|
|
195
|
+
shape = [c for c in target if _cased(c)]
|
|
196
|
+
plain = all(c == c.lower() for c in shape[1:]) # all lower case or title case
|
|
197
|
+
i = next((i for i, c in enumerate(target) if _cased(c)), None)
|
|
198
|
+
if i is None or not plain:
|
|
199
|
+
return target
|
|
200
|
+
return target[:i] + target[i].upper() + target[i + 1 :]
|
|
201
|
+
return target
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def plan_rename(
|
|
205
|
+
index: DocumentIndex,
|
|
206
|
+
old: str,
|
|
207
|
+
new: str,
|
|
208
|
+
*,
|
|
209
|
+
whole_word: bool = True,
|
|
210
|
+
case_sensitive: bool = False,
|
|
211
|
+
preserve_case: bool = True,
|
|
212
|
+
ignore_accents: bool = False,
|
|
213
|
+
inflections: list[Any] | None = None,
|
|
214
|
+
) -> RenamePlan:
|
|
215
|
+
"""Every match of ``old`` in the body as a ``text.replace``; nothing is written."""
|
|
216
|
+
matchers = [
|
|
217
|
+
(
|
|
218
|
+
Matcher(
|
|
219
|
+
SearchOptions(
|
|
220
|
+
old + sfx_old,
|
|
221
|
+
ignore_case=not case_sensitive,
|
|
222
|
+
ignore_accents=ignore_accents,
|
|
223
|
+
whole_word=whole_word,
|
|
224
|
+
)
|
|
225
|
+
),
|
|
226
|
+
old + sfx_old,
|
|
227
|
+
new + sfx_new,
|
|
228
|
+
)
|
|
229
|
+
for sfx_old, sfx_new in _forms(inflections or [])
|
|
230
|
+
]
|
|
231
|
+
dress = preserve_case and not case_sensitive
|
|
232
|
+
plan = RenamePlan()
|
|
233
|
+
counts = plan.counts
|
|
234
|
+
for para in index.paragraphs:
|
|
235
|
+
text = para.text
|
|
236
|
+
if not text:
|
|
237
|
+
continue
|
|
238
|
+
found: list[tuple[int, int, str, str]] = []
|
|
239
|
+
for matcher, exact, target in matchers:
|
|
240
|
+
found += [(s, e, exact, target) for s, e in matcher.spans(text)]
|
|
241
|
+
if not found:
|
|
242
|
+
continue
|
|
243
|
+
found.sort(key=lambda m: (m[0], -m[1]))
|
|
244
|
+
in_field = any(p.in_field for p in para.pieces)
|
|
245
|
+
edge = 0
|
|
246
|
+
counted = False
|
|
247
|
+
for s, e, exact, target in found:
|
|
248
|
+
if s < edge:
|
|
249
|
+
continue # inside a longer form already taken
|
|
250
|
+
edge = e
|
|
251
|
+
counts.matches += 1
|
|
252
|
+
if not (boundary_ok(text, s) and boundary_ok(text, e)):
|
|
253
|
+
counts.skipped_boundary += 1
|
|
254
|
+
continue
|
|
255
|
+
if in_field and any(p.in_field for p in para.pieces_in(s, e)):
|
|
256
|
+
counts.skipped_unsupported += 1
|
|
257
|
+
continue
|
|
258
|
+
matched = text[s:e]
|
|
259
|
+
after = match_case(matched, exact, target) if dress else target
|
|
260
|
+
if after == matched:
|
|
261
|
+
counts.unchanged += 1
|
|
262
|
+
continue
|
|
263
|
+
at = f"{para.address}:{s}-{e}"
|
|
264
|
+
lo, hi = max(0, s - CONTEXT), min(len(text), e + CONTEXT)
|
|
265
|
+
context = ("…" if lo else "") + text[lo:hi] + ("…" if hi < len(text) else "")
|
|
266
|
+
plan.edits.append(ReplaceText(at, after))
|
|
267
|
+
plan.changes.append(_Change(at, para.number, s, e, matched, after, context))
|
|
268
|
+
counts.replaced += 1
|
|
269
|
+
if not counted:
|
|
270
|
+
counts.paragraphs += 1
|
|
271
|
+
counted = True
|
|
272
|
+
return plan
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _drop_unsupported(pkg: Package, index: DocumentIndex, plan: RenamePlan) -> RenamePlan:
|
|
276
|
+
"""Keep only the edits the editor can make (each tried on a throwaway copy)."""
|
|
277
|
+
ctx = RevisionContext(used_ids(pkg), "check", format_date(None))
|
|
278
|
+
kept = RenamePlan(counts=plan.counts)
|
|
279
|
+
for edit, change in zip(plan.edits, plan.changes, strict=True):
|
|
280
|
+
para = index.paragraph(change.paragraph)
|
|
281
|
+
elem = index.spans.parse_paragraph(para.span_index)
|
|
282
|
+
try:
|
|
283
|
+
ParagraphEditor(elem, para, ctx).replace(change.start, change.end, change.after)
|
|
284
|
+
except DocControlError as err:
|
|
285
|
+
if err.code is not ErrorCode.UNSUPPORTED_CONTENT:
|
|
286
|
+
raise
|
|
287
|
+
plan.counts.skipped_unsupported += 1
|
|
288
|
+
plan.counts.replaced -= 1
|
|
289
|
+
continue
|
|
290
|
+
kept.edits.append(edit)
|
|
291
|
+
kept.changes.append(change)
|
|
292
|
+
kept.counts.paragraphs = len({c.paragraph for c in kept.changes})
|
|
293
|
+
return kept
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@operation(
|
|
297
|
+
"term.rename",
|
|
298
|
+
"write",
|
|
299
|
+
"Rename a term everywhere as one tracked batch: whole word, case, preserve case and "
|
|
300
|
+
"inflections; dry_run previews with counts.",
|
|
301
|
+
_SCHEMA,
|
|
302
|
+
)
|
|
303
|
+
def term_rename(request: dict[str, Any]) -> Reply:
|
|
304
|
+
path = Path(request["doc"])
|
|
305
|
+
old, new = request["from"], request["to"]
|
|
306
|
+
pkg = Package(path)
|
|
307
|
+
index = DocumentIndex.from_package(pkg)
|
|
308
|
+
plan = plan_rename(
|
|
309
|
+
index,
|
|
310
|
+
old,
|
|
311
|
+
new,
|
|
312
|
+
whole_word=request.get("whole_word", True),
|
|
313
|
+
case_sensitive=request.get("case_sensitive", False),
|
|
314
|
+
preserve_case=request.get("preserve_case", True),
|
|
315
|
+
ignore_accents=request.get("ignore_accents", False),
|
|
316
|
+
inflections=request.get("inflections"),
|
|
317
|
+
)
|
|
318
|
+
dry_run = request.get("dry_run", False)
|
|
319
|
+
expect = request.get("expect", {}).get("sha256") or pkg.sha256
|
|
320
|
+
author = request.get("author", DEFAULT_AUTHOR)
|
|
321
|
+
tag = request.get("tag")
|
|
322
|
+
|
|
323
|
+
def apply(edits: list[ReplaceText]) -> TrackedResult:
|
|
324
|
+
return apply_edits(
|
|
325
|
+
path, edits, expect_sha256=expect, author=author, tag=tag, dry_run=dry_run
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
applied: TrackedResult | None = None
|
|
329
|
+
if plan.edits:
|
|
330
|
+
try:
|
|
331
|
+
applied = apply(plan.edits)
|
|
332
|
+
except DocControlError as err:
|
|
333
|
+
if err.code is not ErrorCode.UNSUPPORTED_CONTENT:
|
|
334
|
+
raise
|
|
335
|
+
plan = _drop_unsupported(pkg, index, plan)
|
|
336
|
+
if plan.edits:
|
|
337
|
+
applied = apply(plan.edits)
|
|
338
|
+
counts = plan.counts
|
|
339
|
+
limit = request.get("preview_limit", DEFAULT_PREVIEW)
|
|
340
|
+
result: dict[str, Any] = {
|
|
341
|
+
"from": old,
|
|
342
|
+
"to": new,
|
|
343
|
+
"dry_run": dry_run,
|
|
344
|
+
"counts": counts.to_dict(),
|
|
345
|
+
"preview": [
|
|
346
|
+
{"at": c.at, "before": c.before, "after": c.after, "context": c.context}
|
|
347
|
+
for c in plan.changes[:limit]
|
|
348
|
+
],
|
|
349
|
+
}
|
|
350
|
+
warnings: list[str] = []
|
|
351
|
+
if applied is not None:
|
|
352
|
+
body = applied.to_dict()
|
|
353
|
+
keys = ("address_map", "footprint", "revision_ids", "author", "date", "sha256", "backup")
|
|
354
|
+
for key in keys:
|
|
355
|
+
if key in body:
|
|
356
|
+
result[key] = body[key]
|
|
357
|
+
warnings += applied.warnings
|
|
358
|
+
elif not dry_run:
|
|
359
|
+
result["sha256"] = pkg.sha256
|
|
360
|
+
if counts.skipped_unsupported:
|
|
361
|
+
warnings.append(
|
|
362
|
+
f"{counts.skipped_unsupported} match(es) inside a field, link or other structure"
|
|
363
|
+
" were left unchanged"
|
|
364
|
+
)
|
|
365
|
+
if counts.skipped_boundary:
|
|
366
|
+
warnings.append(
|
|
367
|
+
f"{counts.skipped_boundary} match(es) would split a character from its marks"
|
|
368
|
+
" and were left unchanged"
|
|
369
|
+
)
|
|
370
|
+
return Reply(result, doc=Document(str(path)).envelope_doc(), warnings=warnings)
|