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,353 @@
|
|
|
1
|
+
"""The paragraph index of ``word/document.xml``: visible text mapped back to runs.
|
|
2
|
+
|
|
3
|
+
:func:`build_index` takes the paragraph spans from :func:`doc_control.ooxml.splice.scan`
|
|
4
|
+
and, for each paragraph of the main story, computes its *visible text* and a list
|
|
5
|
+
of :class:`Piece` records saying which source element produced which characters.
|
|
6
|
+
|
|
7
|
+
Main story
|
|
8
|
+
Numbering follows the spec: paragraphs in document order, table-cell and
|
|
9
|
+
content-control paragraphs included. A paragraph inside another paragraph
|
|
10
|
+
(a text box) belongs to a different story: it is not numbered and its text
|
|
11
|
+
is not part of the enclosing paragraph.
|
|
12
|
+
|
|
13
|
+
Visible text
|
|
14
|
+
Included: ``w:t`` text, ``w:tab`` as ``\\t``, ``w:br`` and ``w:cr`` as ``\\n``
|
|
15
|
+
(page and column breaks add nothing), ``w:noBreakHyphen`` as U+2011,
|
|
16
|
+
``w:softHyphen`` as U+00AD; text inside ``w:ins``/``w:moveTo``, hyperlinks,
|
|
17
|
+
smart tags, content controls and field results.
|
|
18
|
+
Excluded: ``w:delText`` and everything in ``w:del``/``w:moveFrom``, field
|
|
19
|
+
instructions (between a field's ``begin`` and ``separate``, nested fields
|
|
20
|
+
included, and across paragraphs for a field that spans them), and runs whose
|
|
21
|
+
own ``w:rPr`` has ``w:vanish`` switched on. Hidden text that comes from a
|
|
22
|
+
character style is not resolved here (that needs ``styles.xml``).
|
|
23
|
+
Also shown: ``w:sym`` as its one ``w:char`` character, ``w:ptab`` as ``\\t``,
|
|
24
|
+
the base text of ``w:ruby`` (not the ``w:rt`` annotation) and, in a
|
|
25
|
+
paragraph-level ``mc:AlternateContent``, exactly one branch: the first
|
|
26
|
+
``mc:Choice`` whose ``Requires`` prefixes resolve (in the paragraph's
|
|
27
|
+
namespace scope) to a Word namespace, otherwise the ``mc:Fallback``.
|
|
28
|
+
Not read: equations (``m:oMath``), which are not prose, and anything in a
|
|
29
|
+
text box, drawing or picture.
|
|
30
|
+
|
|
31
|
+
Offsets
|
|
32
|
+
Offsets are indexes into the visible text as a Python ``str``, that is code
|
|
33
|
+
points, not user-perceived characters: a combining mark is its own offset, so
|
|
34
|
+
a range can split it from its base letter. What a write does about that is
|
|
35
|
+
decided by the edit operations (``doc_control.ops.tracked``), not here.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import bisect
|
|
41
|
+
import xml.etree.ElementTree as ET
|
|
42
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
|
|
45
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
46
|
+
from doc_control.model.address import Address, AddressRange, fingerprint, parse_address, parse_range
|
|
47
|
+
from doc_control.model.segment import DEFAULT_LANGUAGE, sentence_span
|
|
48
|
+
from doc_control.ooxml.splice import MC_NS, W_NS, DocumentSpans, scan
|
|
49
|
+
from doc_control.ooxml.zipio import Package
|
|
50
|
+
|
|
51
|
+
W = f"{{{W_NS}}}"
|
|
52
|
+
MC = f"{{{MC_NS}}}"
|
|
53
|
+
DOCUMENT_PART = "word/document.xml"
|
|
54
|
+
|
|
55
|
+
# Containers whose children never carry visible body text.
|
|
56
|
+
_SKIP = frozenset(
|
|
57
|
+
W + name
|
|
58
|
+
for name in ("pPr", "rPr", "sdtPr", "sdtEndPr", "del", "moveFrom", "txbxContent", "delText")
|
|
59
|
+
)
|
|
60
|
+
# Namespaces whose markup Word understands, for choosing an mc:Choice branch.
|
|
61
|
+
_WORD_NAMESPACES = (W_NS, "http://schemas.microsoft.com/office/word/")
|
|
62
|
+
_VANISH_OFF = frozenset({"0", "false", "off"})
|
|
63
|
+
_BREAKS_WITHOUT_TEXT = frozenset({"page", "column"})
|
|
64
|
+
_FIXED_TEXT = {
|
|
65
|
+
W + "tab": "\t",
|
|
66
|
+
W + "cr": "\n",
|
|
67
|
+
W + "noBreakHyphen": "\N{NON-BREAKING HYPHEN}",
|
|
68
|
+
W + "softHyphen": "\N{SOFT HYPHEN}",
|
|
69
|
+
W + "ptab": "\t",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_Emit = Callable[[str, tuple[int, ...]], None]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Piece:
|
|
77
|
+
"""Visible characters ``[start, end)`` of a paragraph and where they come from.
|
|
78
|
+
|
|
79
|
+
``path`` lists child indexes from the ``w:p`` element (as returned by
|
|
80
|
+
:meth:`DocumentSpans.parse_paragraph`, comments counted) down to the source
|
|
81
|
+
element: a ``w:t``, ``w:tab``, ``w:br``… ``path[:-1]`` is its run. For a
|
|
82
|
+
``w:t``, character ``i`` of the paragraph is character ``i - start`` of the
|
|
83
|
+
element's text. ``in_field`` is true for text inside a field's result (also a
|
|
84
|
+
field that began in an earlier paragraph).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
start: int
|
|
88
|
+
end: int
|
|
89
|
+
path: tuple[int, ...]
|
|
90
|
+
in_field: bool = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Paragraph:
|
|
95
|
+
"""One indexed paragraph of the main story."""
|
|
96
|
+
|
|
97
|
+
number: int # 1-based
|
|
98
|
+
span_index: int # position in DocumentSpans.paragraphs
|
|
99
|
+
text: str
|
|
100
|
+
fp: str
|
|
101
|
+
pieces: tuple[Piece, ...] = field(repr=False)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def address(self) -> Address:
|
|
105
|
+
return Address(self.number, self.fp)
|
|
106
|
+
|
|
107
|
+
def pieces_in(self, start: int, end: int) -> list[Piece]:
|
|
108
|
+
"""The pieces overlapping visible characters ``[start, end)``."""
|
|
109
|
+
if start >= end:
|
|
110
|
+
return []
|
|
111
|
+
first = max(bisect.bisect_right(self.pieces, start, key=lambda p: p.start) - 1, 0)
|
|
112
|
+
out: list[Piece] = []
|
|
113
|
+
for piece in self.pieces[first:]:
|
|
114
|
+
if piece.start >= end:
|
|
115
|
+
break
|
|
116
|
+
if piece.end > start:
|
|
117
|
+
out.append(piece)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class ResolvedRange:
|
|
123
|
+
"""A range checked against the document, with offsets filled in.
|
|
124
|
+
|
|
125
|
+
A whole-paragraph range covers ``0`` to the end of the last paragraph. A sentence
|
|
126
|
+
range (``.s<k>``) gets the offsets of that sentence, found by segmentation.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
range: AddressRange
|
|
130
|
+
first: Paragraph
|
|
131
|
+
last: Paragraph
|
|
132
|
+
start_offset: int | None
|
|
133
|
+
end_offset: int | None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def sentence(self) -> int | None:
|
|
137
|
+
return self.range.sentence
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _vanished(run: ET.Element) -> bool:
|
|
141
|
+
rpr = run.find(W + "rPr")
|
|
142
|
+
vanish = rpr.find(W + "vanish") if rpr is not None else None
|
|
143
|
+
return vanish is not None and vanish.get(W + "val", "1").lower() not in _VANISH_OFF
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Walker:
|
|
147
|
+
"""Reads paragraph text in document order, carrying field state across paragraphs."""
|
|
148
|
+
|
|
149
|
+
def __init__(self) -> None:
|
|
150
|
+
# One entry per open field: True once its ``separate`` was seen (result part).
|
|
151
|
+
self.fields: list[bool] = []
|
|
152
|
+
|
|
153
|
+
def paragraph(
|
|
154
|
+
self, root: ET.Element, scope: Mapping[str, str]
|
|
155
|
+
) -> tuple[str, tuple[Piece, ...]]:
|
|
156
|
+
parts: list[str] = []
|
|
157
|
+
pieces: list[Piece] = []
|
|
158
|
+
pos = 0
|
|
159
|
+
|
|
160
|
+
def emit(text: str, path: tuple[int, ...]) -> None:
|
|
161
|
+
nonlocal pos
|
|
162
|
+
if text:
|
|
163
|
+
parts.append(text)
|
|
164
|
+
pieces.append(Piece(pos, pos + len(text), path, bool(self.fields)))
|
|
165
|
+
pos += len(text)
|
|
166
|
+
|
|
167
|
+
self.walk(root, (), scope, emit)
|
|
168
|
+
return "".join(parts), tuple(pieces)
|
|
169
|
+
|
|
170
|
+
def walk(
|
|
171
|
+
self, elem: ET.Element, path: tuple[int, ...], scope: Mapping[str, str], emit: _Emit
|
|
172
|
+
) -> None:
|
|
173
|
+
for i, child in enumerate(elem):
|
|
174
|
+
tag = child.tag
|
|
175
|
+
if not isinstance(tag, str) or tag in _SKIP:
|
|
176
|
+
continue
|
|
177
|
+
if tag == W + "r":
|
|
178
|
+
self.run(child, (*path, i), scope, emit)
|
|
179
|
+
elif tag == MC + "AlternateContent":
|
|
180
|
+
branch = _branch(child, scope)
|
|
181
|
+
if branch is not None:
|
|
182
|
+
j, chosen = branch
|
|
183
|
+
self.walk(chosen, (*path, i, j), scope, emit)
|
|
184
|
+
elif tag.startswith(W):
|
|
185
|
+
self.walk(child, (*path, i), scope, emit)
|
|
186
|
+
|
|
187
|
+
def run(
|
|
188
|
+
self, run: ET.Element, path: tuple[int, ...], scope: Mapping[str, str], emit: _Emit
|
|
189
|
+
) -> None:
|
|
190
|
+
hidden = _vanished(run)
|
|
191
|
+
for i, child in enumerate(run):
|
|
192
|
+
tag = child.tag
|
|
193
|
+
if not isinstance(tag, str):
|
|
194
|
+
continue
|
|
195
|
+
if tag == W + "fldChar":
|
|
196
|
+
self.field_char(child.get(W + "fldCharType"))
|
|
197
|
+
elif hidden or not all(self.fields):
|
|
198
|
+
continue
|
|
199
|
+
elif tag == W + "t":
|
|
200
|
+
emit("".join(child.itertext()), (*path, i))
|
|
201
|
+
elif tag in _FIXED_TEXT:
|
|
202
|
+
emit(_FIXED_TEXT[tag], (*path, i))
|
|
203
|
+
elif tag == W + "br" and child.get(W + "type") not in _BREAKS_WITHOUT_TEXT:
|
|
204
|
+
emit("\n", (*path, i))
|
|
205
|
+
elif tag == W + "sym":
|
|
206
|
+
emit(_symbol(child), (*path, i))
|
|
207
|
+
elif tag == W + "ruby":
|
|
208
|
+
for j, part in enumerate(child):
|
|
209
|
+
if part.tag == W + "rubyBase": # the annotation (w:rt) is not body text
|
|
210
|
+
self.walk(part, (*path, i, j), scope, emit)
|
|
211
|
+
|
|
212
|
+
def field_char(self, kind: str | None) -> None:
|
|
213
|
+
if kind == "begin":
|
|
214
|
+
self.fields.append(False)
|
|
215
|
+
elif kind == "separate" and self.fields:
|
|
216
|
+
self.fields[-1] = True
|
|
217
|
+
elif kind == "end" and self.fields:
|
|
218
|
+
self.fields.pop()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _symbol(sym: ET.Element) -> str:
|
|
222
|
+
"""The one character of a ``w:sym`` (its ``w:char`` hex code); empty if malformed."""
|
|
223
|
+
try:
|
|
224
|
+
code = int(sym.get(W + "char", ""), 16)
|
|
225
|
+
except ValueError:
|
|
226
|
+
return ""
|
|
227
|
+
if code == 0 or 0xD800 <= code <= 0xDFFF or code > 0x10FFFF:
|
|
228
|
+
return ""
|
|
229
|
+
return chr(code)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _branch(alt: ET.Element, scope: Mapping[str, str]) -> tuple[int, ET.Element] | None:
|
|
233
|
+
"""The single branch of an ``mc:AlternateContent`` to read: never both.
|
|
234
|
+
|
|
235
|
+
The first ``mc:Choice`` whose ``Requires`` prefixes all resolve, in the
|
|
236
|
+
paragraph's namespace scope, to a Word namespace; otherwise the Fallback.
|
|
237
|
+
"""
|
|
238
|
+
fallback: tuple[int, ET.Element] | None = None
|
|
239
|
+
for j, child in enumerate(alt):
|
|
240
|
+
if child.tag == MC + "Choice":
|
|
241
|
+
prefixes = child.get("Requires", "").split()
|
|
242
|
+
if prefixes and all(_understood(scope.get(p)) for p in prefixes):
|
|
243
|
+
return j, child
|
|
244
|
+
elif child.tag == MC + "Fallback" and fallback is None:
|
|
245
|
+
fallback = j, child
|
|
246
|
+
return fallback
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _understood(uri: str | None) -> bool:
|
|
250
|
+
return uri is not None and uri.startswith(_WORD_NAMESPACES)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class DocumentIndex:
|
|
254
|
+
"""Every main-story paragraph of one ``word/document.xml``, addressable."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, spans: DocumentSpans, paragraphs: Sequence[Paragraph]) -> None:
|
|
257
|
+
self.spans = spans
|
|
258
|
+
self.paragraphs: tuple[Paragraph, ...] = tuple(paragraphs)
|
|
259
|
+
|
|
260
|
+
def __len__(self) -> int:
|
|
261
|
+
return len(self.paragraphs)
|
|
262
|
+
|
|
263
|
+
def paragraph(self, number: int) -> Paragraph:
|
|
264
|
+
"""Paragraph ``number`` (1-based); ``STALE_ADDRESS`` when there is none."""
|
|
265
|
+
if not 1 <= number <= len(self.paragraphs):
|
|
266
|
+
raise DocControlError(
|
|
267
|
+
ErrorCode.STALE_ADDRESS,
|
|
268
|
+
f"p{number} is outside the document ({len(self.paragraphs)} paragraphs)",
|
|
269
|
+
number=number,
|
|
270
|
+
paragraphs=len(self.paragraphs),
|
|
271
|
+
)
|
|
272
|
+
return self.paragraphs[number - 1]
|
|
273
|
+
|
|
274
|
+
def resolve_address(self, address: Address | str) -> Paragraph:
|
|
275
|
+
"""The paragraph at ``address``; ``STALE_ADDRESS`` unless position and fingerprint match."""
|
|
276
|
+
if isinstance(address, str):
|
|
277
|
+
address = parse_address(address)
|
|
278
|
+
para = self.paragraph(address.number)
|
|
279
|
+
if para.fp != address.fp:
|
|
280
|
+
raise DocControlError(
|
|
281
|
+
ErrorCode.STALE_ADDRESS,
|
|
282
|
+
f"{address} no longer matches the document",
|
|
283
|
+
address=str(address),
|
|
284
|
+
current=str(para.address),
|
|
285
|
+
)
|
|
286
|
+
return para
|
|
287
|
+
|
|
288
|
+
def resolve_range(
|
|
289
|
+
self, value: AddressRange | str, language: str = DEFAULT_LANGUAGE
|
|
290
|
+
) -> ResolvedRange:
|
|
291
|
+
"""Check a range against the document and fill in its offsets.
|
|
292
|
+
|
|
293
|
+
``STALE_ADDRESS`` for a position or fingerprint that does not match;
|
|
294
|
+
``ADDRESS_INVALID`` for an offset beyond its paragraph's text or a sentence
|
|
295
|
+
number beyond the paragraph's sentences (split per ``language``).
|
|
296
|
+
"""
|
|
297
|
+
rng = parse_range(value) if isinstance(value, str) else value
|
|
298
|
+
first = self.resolve_address(rng.start)
|
|
299
|
+
last = first if rng.end == rng.start else self.resolve_address(rng.end)
|
|
300
|
+
start, end = rng.start_offset, rng.end_offset
|
|
301
|
+
if rng.sentence is not None:
|
|
302
|
+
span = sentence_span(first.text, rng.sentence, language)
|
|
303
|
+
if span is None:
|
|
304
|
+
raise DocControlError(
|
|
305
|
+
ErrorCode.ADDRESS_INVALID,
|
|
306
|
+
f"{rng} names a sentence the paragraph does not have",
|
|
307
|
+
address=str(rng),
|
|
308
|
+
)
|
|
309
|
+
start, end = span.start, span.end
|
|
310
|
+
else:
|
|
311
|
+
if start is None or end is None:
|
|
312
|
+
start, end = 0, len(last.text)
|
|
313
|
+
elif start > len(first.text) or end > len(last.text) or (first is last and start > end):
|
|
314
|
+
raise DocControlError(
|
|
315
|
+
ErrorCode.ADDRESS_INVALID,
|
|
316
|
+
f"{rng} has an offset beyond its paragraph's text",
|
|
317
|
+
address=str(rng),
|
|
318
|
+
)
|
|
319
|
+
return ResolvedRange(rng, first, last, start, end)
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def from_package(cls, pkg: Package) -> DocumentIndex:
|
|
323
|
+
return build_index(pkg.read(DOCUMENT_PART))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def build_index(data: bytes) -> DocumentIndex:
|
|
327
|
+
"""Index the bytes of a ``word/document.xml``; tokenizer errors propagate.
|
|
328
|
+
|
|
329
|
+
Field state carries from one paragraph to the next so a field that spans
|
|
330
|
+
paragraphs (a table of contents) keeps its instruction hidden. If the body
|
|
331
|
+
ends with a field still open (a ``begin`` with no ``end``), that carry would
|
|
332
|
+
blank every later paragraph, so the document is indexed again with the field
|
|
333
|
+
state reset at each paragraph.
|
|
334
|
+
"""
|
|
335
|
+
spans = scan(data)
|
|
336
|
+
paragraphs, open_fields = _paragraphs(spans, carry=True)
|
|
337
|
+
if open_fields:
|
|
338
|
+
paragraphs, _ = _paragraphs(spans, carry=False)
|
|
339
|
+
return DocumentIndex(spans, paragraphs)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _paragraphs(spans: DocumentSpans, *, carry: bool) -> tuple[list[Paragraph], bool]:
|
|
343
|
+
walker = _Walker()
|
|
344
|
+
paragraphs: list[Paragraph] = []
|
|
345
|
+
for span in spans.paragraphs:
|
|
346
|
+
if span.nested:
|
|
347
|
+
continue
|
|
348
|
+
if not carry:
|
|
349
|
+
walker.fields.clear()
|
|
350
|
+
text, pieces = walker.paragraph(spans.parse_paragraph(span.index), span.scope)
|
|
351
|
+
number = len(paragraphs) + 1
|
|
352
|
+
paragraphs.append(Paragraph(number, span.index, text, fingerprint(text), pieces))
|
|
353
|
+
return paragraphs, bool(walker.fields)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Layout facts of a paragraph (D-18): what it looks like on the page.
|
|
2
|
+
|
|
3
|
+
Plain functions of a :class:`~doc_control.model.document.Document` and a paragraph, so the
|
|
4
|
+
read operations (T31, :mod:`doc_control.ops.layout`) and structure detection share one
|
|
5
|
+
source of truth.
|
|
6
|
+
|
|
7
|
+
``empty``
|
|
8
|
+
No visible text, or only whitespace (non-breaking spaces included), and no picture
|
|
9
|
+
(``w:drawing``, ``w:pict``, ``w:object``): an image-only paragraph is not empty.
|
|
10
|
+
``blank_before``
|
|
11
|
+
How many empty paragraphs sit immediately before this one (0 when none), within
|
|
12
|
+
the same table cell or the same body level: counting stops at a table edge, at an
|
|
13
|
+
image-only paragraph and at a page-break-only paragraph (which is not blank).
|
|
14
|
+
``heading_level``
|
|
15
|
+
1-9, from a direct outline level, else the style chain (``basedOn``), else a style
|
|
16
|
+
named ``heading N``; ``None`` for body text. (An outline entry's ``level``.)
|
|
17
|
+
``style`` / ``style_name``
|
|
18
|
+
The paragraph's style id (explicit, else the default paragraph style) and its name;
|
|
19
|
+
``para.get`` and ``outline`` already report them.
|
|
20
|
+
``centred``
|
|
21
|
+
Centre alignment, direct (``w:jc``) or inherited through the style chain. Text
|
|
22
|
+
"centred" with tabs or spaces is not detected: that is a recipe (T25).
|
|
23
|
+
``page_break_before``
|
|
24
|
+
The paragraph starts a page: ``w:pageBreakBefore`` (direct wins over the style
|
|
25
|
+
chain), a page break (``w:br w:type="page"``) before any visible content, the
|
|
26
|
+
previous paragraph (same cell or body level) ending in a page break or being only
|
|
27
|
+
one, or a section break of type nextPage, oddPage, evenPage or none (not
|
|
28
|
+
continuous or nextColumn) ending the previous paragraph.
|
|
29
|
+
|
|
30
|
+
Alignment is resolved as direct, then style chain, then ``w:docDefaults``. Not covered:
|
|
31
|
+
alignment from a table style or from numbering levels; ``w:lastRenderedPageBreak``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import xml.etree.ElementTree as ET
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
|
|
39
|
+
from doc_control.model.document import Document
|
|
40
|
+
from doc_control.model.format import para_format
|
|
41
|
+
from doc_control.model.index import Paragraph
|
|
42
|
+
from doc_control.model.parts import W
|
|
43
|
+
|
|
44
|
+
_NOT_CONTENT = frozenset(W + name for name in ("pPr", "del", "moveFrom", "delText"))
|
|
45
|
+
_CONTENT = frozenset(W + name for name in ("t", "tab", "drawing", "pict", "object", "sym"))
|
|
46
|
+
_IMAGES = frozenset(W + name for name in ("drawing", "pict", "object"))
|
|
47
|
+
_PAGE_SECTIONS = frozenset({"nextPage", "oddPage", "evenPage"}) # not continuous / nextColumn
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _events(elem: ET.Element) -> Iterator[bool]:
|
|
51
|
+
"""Visible items of a paragraph in order: True for a page break, False for content."""
|
|
52
|
+
for child in elem:
|
|
53
|
+
tag = child.tag
|
|
54
|
+
if not isinstance(tag, str) or tag in _NOT_CONTENT:
|
|
55
|
+
continue
|
|
56
|
+
if tag == W + "br":
|
|
57
|
+
if child.get(W + "type") == "page":
|
|
58
|
+
yield True
|
|
59
|
+
elif tag in _CONTENT:
|
|
60
|
+
if tag != W + "t" or "".join(child.itertext()).strip():
|
|
61
|
+
yield False
|
|
62
|
+
else:
|
|
63
|
+
yield from _events(child)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _has_image(elem: ET.Element) -> bool:
|
|
67
|
+
for child in elem:
|
|
68
|
+
tag = child.tag
|
|
69
|
+
if not isinstance(tag, str) or tag in _NOT_CONTENT:
|
|
70
|
+
continue
|
|
71
|
+
if tag in _IMAGES or _has_image(child):
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _elem(doc: Document, para: Paragraph) -> ET.Element:
|
|
77
|
+
return doc.index.spans.parse_paragraph(para.span_index)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_empty(doc: Document, para: Paragraph) -> bool:
|
|
81
|
+
return not para.text.strip() and not _has_image(_elem(doc, para))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _break_only(doc: Document, para: Paragraph) -> bool:
|
|
85
|
+
items = list(_events(_elem(doc, para)))
|
|
86
|
+
return bool(items) and all(items) and not para.text.strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _previous(doc: Document, para: Paragraph) -> Paragraph | None:
|
|
90
|
+
"""The paragraph before this one in the same table cell (or the same body level)."""
|
|
91
|
+
if para.number < 2 or doc.cells[para.number - 2] != doc.cells[para.number - 1]:
|
|
92
|
+
return None
|
|
93
|
+
return doc.index.paragraphs[para.number - 2]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def blank_before(doc: Document, para: Paragraph) -> int:
|
|
97
|
+
count = 0
|
|
98
|
+
prev = _previous(doc, para)
|
|
99
|
+
while prev is not None and is_empty(doc, prev) and not _break_only(doc, prev):
|
|
100
|
+
count += 1
|
|
101
|
+
prev = _previous(doc, prev)
|
|
102
|
+
return count
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def centred(doc: Document, para: Paragraph) -> bool:
|
|
106
|
+
alignment = para_format(_elem(doc, para).find(W + "pPr")).get("alignment")
|
|
107
|
+
if alignment is None:
|
|
108
|
+
style = doc.style_id(para)
|
|
109
|
+
alignment = doc.styles.effective(style)[0].get("alignment") if style else None
|
|
110
|
+
if alignment is None:
|
|
111
|
+
alignment = doc.styles.defaults_ppr.get("alignment")
|
|
112
|
+
return alignment == "center"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _section_break_before(prev: ET.Element) -> bool:
|
|
116
|
+
sect = prev.find(f"{W}pPr/{W}sectPr")
|
|
117
|
+
if sect is None:
|
|
118
|
+
return False
|
|
119
|
+
kind = sect.find(W + "type")
|
|
120
|
+
return (kind.get(W + "val", "nextPage") if kind is not None else "nextPage") in _PAGE_SECTIONS
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def page_break_before(doc: Document, para: Paragraph) -> bool:
|
|
124
|
+
elem = _elem(doc, para)
|
|
125
|
+
direct = para_format(elem.find(W + "pPr")).get("page_break_before")
|
|
126
|
+
if direct is None:
|
|
127
|
+
style = doc.style_id(para)
|
|
128
|
+
direct = doc.styles.effective(style)[0].get("page_break_before") if style else None
|
|
129
|
+
if direct:
|
|
130
|
+
return True
|
|
131
|
+
first = next(_events(elem), False)
|
|
132
|
+
if first:
|
|
133
|
+
return True
|
|
134
|
+
prev = _previous(doc, para)
|
|
135
|
+
if prev is None:
|
|
136
|
+
return False
|
|
137
|
+
prev_elem = _elem(doc, prev)
|
|
138
|
+
if _section_break_before(prev_elem):
|
|
139
|
+
return True
|
|
140
|
+
last = None
|
|
141
|
+
for item in _events(prev_elem):
|
|
142
|
+
last = item
|
|
143
|
+
return last is True
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Normalised view of visible paragraph text, with a two-way offset map.
|
|
2
|
+
|
|
3
|
+
Offsets are code points of the visible text, as in :mod:`doc_control.model.index`; the
|
|
4
|
+
end of a range is exclusive. :func:`normalize` applies, in this order:
|
|
5
|
+
|
|
6
|
+
1. removal of soft hyphens (U+00AD) and zero-width characters (U+200B, U+200C, U+200D,
|
|
7
|
+
U+2060, U+FEFF);
|
|
8
|
+
2. NFC;
|
|
9
|
+
3. collapse of every run of whitespace (``str.isspace``: space, tab, newline, no-break
|
|
10
|
+
space U+00A0, U+2028...) into one space. Nothing is trimmed.
|
|
11
|
+
|
|
12
|
+
Zero-width characters go even where they hold a sequence together: U+200D (ZWJ) is
|
|
13
|
+
removed from an emoji sequence, which then reads as its separate emoji. NFC is applied to
|
|
14
|
+
clusters of at most 32 code points, so a longer run of marks (not found in real text) is
|
|
15
|
+
normalised in pieces.
|
|
16
|
+
|
|
17
|
+
Every normalised character covers a half-open range of original characters (if nothing
|
|
18
|
+
is left, the only boundary maps to 0). A removed
|
|
19
|
+
character belongs to the character that follows it (the trailing ones to the last
|
|
20
|
+
character), a collapsed run to its single space, and the characters NFC combines (``e`` +
|
|
21
|
+
U+0301 -> ``e`` with acute) to the combined character.
|
|
22
|
+
|
|
23
|
+
``to_original[i]`` is the original offset of normalised boundary ``i`` (``0..len(text)``)
|
|
24
|
+
and ``to_normalized[o]`` the normalised offset of original boundary ``o``
|
|
25
|
+
(``0..len(original)``). So ``text[a:b]`` comes from ``original[to_original[a]:to_original[b]]``.
|
|
26
|
+
Boundaries that exist on both sides survive a round trip unchanged. A boundary with no
|
|
27
|
+
counterpart (inside a collapsed whitespace run, between a letter and the accent NFC folds
|
|
28
|
+
into it, or inside the two characters one original character expands to) maps to the
|
|
29
|
+
nearest boundary before it, so both maps are monotonic and
|
|
30
|
+
``to_original[to_normalized[to_original[i]]] == to_original[i]`` always holds.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import bisect
|
|
36
|
+
import unicodedata
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
|
|
40
|
+
#: Longest run of code points normalised together. Real clusters are a few characters;
|
|
41
|
+
#: Unicode's stream-safe text format allows 31 combining marks after a base. A longer run
|
|
42
|
+
#: (U+0F73 repeated, say) is cut here, where NFC of the whole would cost quadratic time.
|
|
43
|
+
MAX_CLUSTER = 32
|
|
44
|
+
IGNORABLE = frozenset("\u00ad\u200b\u200c\u200d\u2060\ufeff")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class NormalizedText:
|
|
49
|
+
"""The normalised text and the offset maps between it and the original."""
|
|
50
|
+
|
|
51
|
+
text: str
|
|
52
|
+
original_length: int
|
|
53
|
+
to_original: tuple[int, ...]
|
|
54
|
+
to_normalized: tuple[int, ...]
|
|
55
|
+
|
|
56
|
+
def original_offset(self, offset: int) -> int:
|
|
57
|
+
"""Original offset of normalised boundary ``offset``."""
|
|
58
|
+
if not 0 <= offset <= len(self.text):
|
|
59
|
+
raise IndexError(f"normalised offset {offset} outside 0..{len(self.text)}")
|
|
60
|
+
return self.to_original[offset]
|
|
61
|
+
|
|
62
|
+
def normalized_offset(self, offset: int) -> int:
|
|
63
|
+
"""Normalised offset of original boundary ``offset``."""
|
|
64
|
+
if not 0 <= offset <= self.original_length:
|
|
65
|
+
raise IndexError(f"original offset {offset} outside 0..{self.original_length}")
|
|
66
|
+
return self.to_normalized[offset]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize(original: str) -> NormalizedText:
|
|
70
|
+
"""Normalise ``original`` and map offsets both ways."""
|
|
71
|
+
kept = [i for i, ch in enumerate(original) if ch not in IGNORABLE]
|
|
72
|
+
# Output characters as [start, end) original ranges. A character's range starts
|
|
73
|
+
# where the previous one ended, so removed characters attach to the next one.
|
|
74
|
+
chars: list[str] = []
|
|
75
|
+
starts: list[int] = []
|
|
76
|
+
ends: list[int] = []
|
|
77
|
+
prev_end = 0
|
|
78
|
+
chunks = _chunks(original, kept)
|
|
79
|
+
for pos, chunk in enumerate(chunks):
|
|
80
|
+
out = unicodedata.normalize("NFC", "".join(original[i] for i in chunk))
|
|
81
|
+
# Removed characters at the very end belong to the last chunk.
|
|
82
|
+
end = len(original) if pos == len(chunks) - 1 else chunk[-1] + 1
|
|
83
|
+
for ch in out:
|
|
84
|
+
if ch.isspace() and chars and chars[-1] == " ":
|
|
85
|
+
ends[-1] = end # collapse into the previous space
|
|
86
|
+
continue
|
|
87
|
+
chars.append(" " if ch.isspace() else ch)
|
|
88
|
+
starts.append(prev_end)
|
|
89
|
+
ends.append(end)
|
|
90
|
+
prev_end = end
|
|
91
|
+
|
|
92
|
+
# A chunk's characters all share its range, so an interior boundary sits at its start.
|
|
93
|
+
to_original = (*starts, len(original) if chars else 0)
|
|
94
|
+
to_normalized = tuple(bisect.bisect_right(ends, o) for o in range(len(original) + 1))
|
|
95
|
+
return NormalizedText("".join(chars), len(original), to_original, to_normalized)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _chunks(original: str, kept: list[int]) -> list[list[int]]:
|
|
99
|
+
"""Split the kept indexes where NFC cannot combine across the cut.
|
|
100
|
+
|
|
101
|
+
A character joins the current chunk when it has a combining class, or when NFC of
|
|
102
|
+
the chunk plus the character differs from NFC of the chunk followed by the
|
|
103
|
+
character (a base letter and its accent, or Hangul L+V+T, which compose step by
|
|
104
|
+
step). Whitespace stands alone: nothing composes with it, and a collapsed space
|
|
105
|
+
must stay whole. A chunk stops growing at :data:`MAX_CLUSTER` code points.
|
|
106
|
+
"""
|
|
107
|
+
chunks: list[list[int]] = []
|
|
108
|
+
text = "" # the current chunk's characters
|
|
109
|
+
for i in kept:
|
|
110
|
+
ch = original[i]
|
|
111
|
+
if (
|
|
112
|
+
chunks
|
|
113
|
+
and len(text) < MAX_CLUSTER
|
|
114
|
+
and not text[-1].isspace()
|
|
115
|
+
and (
|
|
116
|
+
unicodedata.combining(ch) != 0
|
|
117
|
+
or (
|
|
118
|
+
not (ch.isascii() and text.isascii())
|
|
119
|
+
and unicodedata.normalize("NFC", text + ch)
|
|
120
|
+
!= unicodedata.normalize("NFC", text) + unicodedata.normalize("NFC", ch)
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
):
|
|
124
|
+
chunks[-1].append(i)
|
|
125
|
+
text += ch
|
|
126
|
+
else:
|
|
127
|
+
chunks.append([i])
|
|
128
|
+
text = ch
|
|
129
|
+
return chunks
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def encode_runs(values: Sequence[int], lo: int, hi: int) -> list[list[int]]:
|
|
133
|
+
"""Compact form of ``values[lo:hi]`` as runs ``[index, value, count]``.
|
|
134
|
+
|
|
135
|
+
Entry ``index + k`` is ``value + k`` for ``k < count``. A run whose values do not
|
|
136
|
+
move is written ``[index, value, count, 0]``: entry ``index + k`` is ``value``.
|
|
137
|
+
"""
|
|
138
|
+
runs: list[list[int]] = []
|
|
139
|
+
i = lo
|
|
140
|
+
while i < hi:
|
|
141
|
+
step = values[i + 1] - values[i] if i + 1 < hi else 1
|
|
142
|
+
if step not in (0, 1):
|
|
143
|
+
step = 1
|
|
144
|
+
j = i + 1
|
|
145
|
+
while j < hi and values[j] - values[j - 1] == step:
|
|
146
|
+
j += 1
|
|
147
|
+
runs.append([i, values[i], j - i] if step else [i, values[i], j - i, 0])
|
|
148
|
+
i = j
|
|
149
|
+
return runs
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def decode_runs(runs: Sequence[Sequence[int]]) -> dict[int, int]:
|
|
153
|
+
"""Inverse of :func:`encode_runs`: index to value."""
|
|
154
|
+
out: dict[int, int] = {}
|
|
155
|
+
for run in runs:
|
|
156
|
+
index, value, count = run[:3]
|
|
157
|
+
step = 0 if len(run) > 3 else 1
|
|
158
|
+
for k in range(count):
|
|
159
|
+
out[index + k] = value + k * step
|
|
160
|
+
return out
|