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
doc_control/ops/text.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Text views: ``text.normalized``.
|
|
2
|
+
|
|
3
|
+
Two inputs, exactly one of which must be given (otherwise ``SCHEMA_INVALID``):
|
|
4
|
+
|
|
5
|
+
* ``doc`` (a ``.docx`` path), with an optional ``at`` (paragraph address or range; the
|
|
6
|
+
whole document when absent). The result lists, per paragraph, its address, the
|
|
7
|
+
normalised text and the offset map. For a range, the first and last paragraph are cut
|
|
8
|
+
at the range offsets; ``start`` is the visible-text offset where the cut text begins,
|
|
9
|
+
and the map's original offsets are relative to it (add ``start`` for paragraph offsets).
|
|
10
|
+
* ``text``, a string, which is normalised on its own (offsets count from its start).
|
|
11
|
+
|
|
12
|
+
The reply is paged like every list (:mod:`doc_control.ops.paging`): at most about 8,000
|
|
13
|
+
bytes and ``limit`` paragraphs per call, ``next`` holding the cursor to continue with the
|
|
14
|
+
same ``at`` (or the same ``text``). A paragraph whose map would be large is split
|
|
15
|
+
into several entries (see the result description in the operation's schema), so a reply
|
|
16
|
+
stays within the cap even for one long paragraph.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from doc_control.envelope import Reply, success
|
|
27
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
28
|
+
from doc_control.model.document import Document
|
|
29
|
+
from doc_control.model.index import DocumentIndex, Paragraph
|
|
30
|
+
from doc_control.model.normalized import NormalizedText, encode_runs, normalize
|
|
31
|
+
from doc_control.ops.paging import (
|
|
32
|
+
MAX_LIMIT,
|
|
33
|
+
PAGING_PROPERTIES,
|
|
34
|
+
RESPONSE_BUDGET,
|
|
35
|
+
make_cursor,
|
|
36
|
+
read_cursor,
|
|
37
|
+
size,
|
|
38
|
+
)
|
|
39
|
+
from doc_control.registry import operation
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _invalid(message: str, **details: Any) -> DocControlError:
|
|
43
|
+
return DocControlError(ErrorCode.SCHEMA_INVALID, message, **details)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _selection(index: DocumentIndex, at: str | None) -> list[tuple[Paragraph, int, int]]:
|
|
47
|
+
"""Paragraphs to report with the cut ``[start, end)`` of each."""
|
|
48
|
+
if at is None:
|
|
49
|
+
return [(p, 0, len(p.text)) for p in index.paragraphs]
|
|
50
|
+
resolved = index.resolve_range(at)
|
|
51
|
+
first, last = resolved.first, resolved.last
|
|
52
|
+
start = resolved.start_offset or 0
|
|
53
|
+
end = len(last.text) if resolved.end_offset is None else resolved.end_offset
|
|
54
|
+
if first is last:
|
|
55
|
+
return [(first, start, end)]
|
|
56
|
+
middle = index.paragraphs[first.number : last.number - 1]
|
|
57
|
+
return [
|
|
58
|
+
(first, start, len(first.text)),
|
|
59
|
+
*((p, 0, len(p.text)) for p in middle),
|
|
60
|
+
(last, 0, end),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
WINDOW_BYTES = 3000 # an entry bigger than this is split, so one never nears the 8,000 cap
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _window(
|
|
68
|
+
result: NormalizedText, start: int, at: str | None, a: int, b: int, last: bool
|
|
69
|
+
) -> list[dict[str, Any]]:
|
|
70
|
+
"""Entries for normalised boundaries ``[a, b)`` (``[a, len]`` for the last window).
|
|
71
|
+
|
|
72
|
+
A window lists the original boundaries from ``to_original[a]`` up to (not including)
|
|
73
|
+
``to_original[b]``, so the windows of a paragraph partition both maps. It is split in
|
|
74
|
+
halves while its JSON is over :data:`WINDOW_BYTES`.
|
|
75
|
+
"""
|
|
76
|
+
n = len(result.text)
|
|
77
|
+
top = n + 1 if last else b
|
|
78
|
+
o_lo = result.to_original[a]
|
|
79
|
+
o_hi = result.original_length + 1 if last else result.to_original[b]
|
|
80
|
+
entry: dict[str, Any] = {} if at is None else {"at": at}
|
|
81
|
+
entry |= {
|
|
82
|
+
"start": start,
|
|
83
|
+
"from": a,
|
|
84
|
+
"text": result.text[a : n if last else b],
|
|
85
|
+
"offset_map": {
|
|
86
|
+
"to_original": encode_runs(result.to_original, a, top),
|
|
87
|
+
"to_normalized": encode_runs(result.to_normalized, o_lo, o_hi),
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
size = len(json.dumps(entry, ensure_ascii=False).encode("utf-8"))
|
|
91
|
+
if size <= WINDOW_BYTES or b - a < 2:
|
|
92
|
+
return [entry]
|
|
93
|
+
mid = (a + b) // 2
|
|
94
|
+
return _window(result, start, at, a, mid, False) + _window(result, start, at, mid, b, last)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _entries(text: str, start: int, at: str | None) -> list[dict[str, Any]]:
|
|
98
|
+
result = normalize(text)
|
|
99
|
+
return _window(result, start, at, 0, len(result.text), True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
#: A cursor offset is ``paragraph * _STRIDE + entry``: the paragraph (row) to resume at and
|
|
103
|
+
#: which of its entries comes first, so a page builds only the paragraphs it shows.
|
|
104
|
+
_STRIDE = 1_000_000
|
|
105
|
+
|
|
106
|
+
Row = tuple[str, int, str | None] # text, start, address
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _page(op: str, doc: Document, request: dict[str, Any], rows: Sequence[Row]) -> Reply:
|
|
110
|
+
"""Like :func:`doc_control.ops.paging.paged`, but builds entries only as the page needs."""
|
|
111
|
+
row, skip = divmod(read_cursor(op, doc, request), _STRIDE)
|
|
112
|
+
if row > len(rows) or (row == len(rows) and rows):
|
|
113
|
+
raise _invalid("cursor is past the end", path="$.cursor")
|
|
114
|
+
limit = request.get("limit", MAX_LIMIT)
|
|
115
|
+
head: dict[str, Any] = {"total": len(rows), "paragraphs": []}
|
|
116
|
+
placeholder = make_cursor(op, doc, request, 10**12)
|
|
117
|
+
room = RESPONSE_BUDGET - size(
|
|
118
|
+
success(op, head, doc=doc.envelope_doc(), next_cursor=placeholder)
|
|
119
|
+
)
|
|
120
|
+
taken: list[dict[str, Any]] = []
|
|
121
|
+
used = 0
|
|
122
|
+
resume: int | None = None
|
|
123
|
+
while row < len(rows) and resume is None:
|
|
124
|
+
entries = _entries(*rows[row])
|
|
125
|
+
for k in range(skip, len(entries)):
|
|
126
|
+
cost = len(json.dumps(entries[k], ensure_ascii=False).encode("utf-8")) + 2
|
|
127
|
+
if taken and (used + cost > room or len(taken) >= limit):
|
|
128
|
+
resume = row * _STRIDE + k
|
|
129
|
+
break
|
|
130
|
+
taken.append(entries[k])
|
|
131
|
+
used += cost
|
|
132
|
+
row, skip = row + 1, 0
|
|
133
|
+
head["paragraphs"] = taken
|
|
134
|
+
return Reply(
|
|
135
|
+
head,
|
|
136
|
+
doc=doc.envelope_doc(),
|
|
137
|
+
next_cursor=None if resume is None else make_cursor(op, doc, request, resume),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _TextSource(Document):
|
|
142
|
+
"""Stands in for a document so a `text` request pages and takes cursors the same way."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, text: str) -> None:
|
|
145
|
+
self.path = "-"
|
|
146
|
+
self._sha256 = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
147
|
+
self._length = len(text)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def sha256(self) -> str:
|
|
151
|
+
return self._sha256
|
|
152
|
+
|
|
153
|
+
def envelope_doc(self) -> dict[str, Any]:
|
|
154
|
+
return {"source": "text", "sha256": self._sha256, "characters": self._length}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_RESULT = (
|
|
158
|
+
"Result: `paragraphs`, each {at (absent for `text`), start, from, text, offset_map}. "
|
|
159
|
+
"`start` is where the entry's text begins in the paragraph's visible text (non-zero "
|
|
160
|
+
"for a range cut); a long paragraph comes as several entries, `from` being the "
|
|
161
|
+
"normalised offset where each entry's `text` begins. offset_map.to_original maps "
|
|
162
|
+
"normalised offsets to original ones and offset_map.to_normalized the reverse, both "
|
|
163
|
+
"counted in code points from `start`, as runs: [index, value, count] means entry "
|
|
164
|
+
"index+k is value+k for k < count, and [index, value, count, 0] means entry index+k "
|
|
165
|
+
"is value. Each entry lists its own normalised offsets (to_original) and original "
|
|
166
|
+
"offsets (to_normalized); the entries of a paragraph together list every offset once."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
OP = "text.normalized"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@operation(
|
|
174
|
+
OP,
|
|
175
|
+
"read",
|
|
176
|
+
"Normalised text (NFC, soft hyphens and zero-width characters removed, whitespace "
|
|
177
|
+
"collapsed) with a two-way offset map to the original code points, for a document "
|
|
178
|
+
"(whole, or the paragraph/range `at`) or for a `text` string.",
|
|
179
|
+
{
|
|
180
|
+
"type": "object",
|
|
181
|
+
"description": _RESULT,
|
|
182
|
+
"properties": {
|
|
183
|
+
"doc": {"type": "string", "minLength": 1, "description": "Path to a .docx."},
|
|
184
|
+
"text": {"type": "string", "description": "Text to normalise instead of `doc`."},
|
|
185
|
+
"at": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"description": "Paragraph address or range, e.g. p142#a7b2a7b2:10-15.",
|
|
188
|
+
},
|
|
189
|
+
**PAGING_PROPERTIES,
|
|
190
|
+
},
|
|
191
|
+
"additionalProperties": False,
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
def text_normalized(request: dict[str, Any]) -> dict[str, Any] | Reply:
|
|
195
|
+
has_doc, has_text = "doc" in request, "text" in request
|
|
196
|
+
if has_doc == has_text:
|
|
197
|
+
raise _invalid("give exactly one of `doc` and `text`")
|
|
198
|
+
if has_text:
|
|
199
|
+
if "at" in request:
|
|
200
|
+
raise _invalid("`at` needs `doc`")
|
|
201
|
+
return _page(OP, _TextSource(request["text"]), request, [(request["text"], 0, None)])
|
|
202
|
+
doc = Document(request["doc"])
|
|
203
|
+
rows = [
|
|
204
|
+
(para.text[start:end], start, str(para.address))
|
|
205
|
+
for para, start, end in _selection(doc.index, request.get("at"))
|
|
206
|
+
]
|
|
207
|
+
return _page(OP, doc, request, rows)
|