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,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)