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