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,479 @@
1
+ """History operations: ``open``, ``undo``, ``restore`` and ``history.*``.
2
+
3
+ Everything here reads or writes the document's store (see :mod:`doc_control.safety.history`
4
+ for the log format). ``undo`` takes back a logged ``batch`` or ``restore`` exactly (the
5
+ paragraphs it changed return byte for byte, so text and paragraph properties are as they
6
+ were) and is itself logged; it is untracked, since it removes the tool's own tracked
7
+ changes. ``restore`` brings a logged paragraph back as a *tracked insertion* after the
8
+ paragraph that holds its place now, so the writer sees it and can reject it. ``history.purge``
9
+ removes history only after a dry run, and leaves a tombstone. Reading history never creates a
10
+ store, and works for a document that no longer exists (or, with ``store``, straight from a
11
+ store folder listed by ``history.orphans``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from doc_control.envelope import Reply
22
+ from doc_control.errors import DocControlError, ErrorCode
23
+ from doc_control.model.document import Document
24
+ from doc_control.model.index import DocumentIndex
25
+ from doc_control.ooxml.revisions import DEFAULT_AUTHOR
26
+ from doc_control.ooxml.splice import scan
27
+ from doc_control.ooxml.zipio import Package
28
+ from doc_control.ops.tracked import InsertParagraph, apply_edits
29
+ from doc_control.registry import operation
30
+ from doc_control.safety import history
31
+ from doc_control.safety.history import (
32
+ UNDOABLE_KINDS,
33
+ Log,
34
+ log_path_for,
35
+ read_log,
36
+ to_bytes,
37
+ undo_entry,
38
+ )
39
+ from doc_control.safety.store import backup, store_dir
40
+ from doc_control.safety.verify import DOCUMENT_PART
41
+
42
+ _DOC: dict[str, Any] = {"type": "string", "minLength": 1, "description": "Path to the .docx."}
43
+ _STORE: dict[str, Any] = {
44
+ "type": "string",
45
+ "minLength": 1,
46
+ "description": "A store folder (see history.orphans) to read instead of the document's own.",
47
+ }
48
+ _OP_ID: dict[str, Any] = {"type": "string", "minLength": 3, "description": "Op id, e.g. h-000123."}
49
+ _EXPECT: dict[str, Any] = {
50
+ "type": "object",
51
+ "properties": {"sha256": {"type": "string", "minLength": 1}},
52
+ "additionalProperties": False,
53
+ }
54
+ DEFAULT_LIMIT = 20
55
+ MAX_LIMIT = 200
56
+ PREVIEW = 120
57
+
58
+
59
+ def _log(request: dict[str, Any]) -> Log:
60
+ """The log named by ``store`` or ``doc``; reading creates nothing."""
61
+ if "store" in request:
62
+ return read_log(Path(request["store"]) / "log.jsonl")
63
+ if "doc" in request:
64
+ return read_log(log_path_for(Path(request["doc"])))
65
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "give `doc` or `store`")
66
+
67
+
68
+ def _doc_reply(path: str, result: dict[str, Any]) -> Reply:
69
+ return Reply(result, doc=Document(path).envelope_doc())
70
+
71
+
72
+ def _short(text: str) -> str:
73
+ return text if len(text) <= PREVIEW else text[: PREVIEW - 1] + "…"
74
+
75
+
76
+ # -- open -----------------------------------------------------------------------------------
77
+
78
+
79
+ @operation(
80
+ "open",
81
+ "write",
82
+ "Back up the document into its store (retention applies) and note it in the history; "
83
+ "call it when a session starts. The document is not changed.",
84
+ {
85
+ "type": "object",
86
+ "properties": {"doc": _DOC},
87
+ "required": ["doc"],
88
+ "additionalProperties": False,
89
+ },
90
+ )
91
+ def open_(request: dict[str, Any]) -> Reply:
92
+ path = Path(request["doc"])
93
+ if not path.is_file():
94
+ raise DocControlError(ErrorCode.DOC_NOT_FOUND, f"no such file: {path}", path=str(path))
95
+ sha = Package(path).sha256 # NOT_A_DOCX for anything else, before a store is made
96
+ copy = backup(path)
97
+ seen = [e for e in read_log(log_path_for(path)).entries if e.get("kind") != "purge"]
98
+ op_id: str | None = None
99
+ if not seen or seen[-1].get("sha256_after") != sha: # new to the history: note it
100
+ op_id = history.record(
101
+ path, "open", before_sha256=sha, after_sha256=sha, backup=copy, changes=[]
102
+ )
103
+ return _doc_reply(
104
+ str(path), {"backup": str(copy), "op_id": op_id, "store": str(store_dir(path))}
105
+ )
106
+
107
+
108
+ # -- undo -----------------------------------------------------------------------------------
109
+
110
+
111
+ def _undoable(entry: dict[str, Any]) -> bool:
112
+ return entry.get("kind") in UNDOABLE_KINDS and bool(entry.get("changes"))
113
+
114
+
115
+ @operation(
116
+ "undo",
117
+ "write",
118
+ "Take back a logged batch or restore (the latest one that is still in effect by default), "
119
+ "returning its paragraphs to their prior state. Refused if they were edited since.",
120
+ {
121
+ "type": "object",
122
+ "properties": {
123
+ "doc": _DOC,
124
+ "op_id": {**_OP_ID, "description": "The op to undo (default: the latest in effect)."},
125
+ "expect": _EXPECT,
126
+ "dry_run": {"type": "boolean", "description": "Check and preview; write nothing."},
127
+ },
128
+ "required": ["doc"],
129
+ "additionalProperties": False,
130
+ },
131
+ )
132
+ def undo(request: dict[str, Any]) -> Reply:
133
+ path = Path(request["doc"])
134
+ log = read_log(log_path_for(path))
135
+ undone = log.undone()
136
+ op_id = request.get("op_id")
137
+ if op_id is None:
138
+ pending = [e for e in log.entries if _undoable(e) and e["op_id"] not in undone]
139
+ if not pending:
140
+ raise DocControlError(ErrorCode.HISTORY_NOT_FOUND, "nothing to undo")
141
+ entry = pending[-1]
142
+ else:
143
+ entry = log.get(op_id)
144
+ if not _undoable(entry):
145
+ raise DocControlError(
146
+ ErrorCode.HISTORY_NOT_FOUND,
147
+ f"{op_id} is a {entry.get('kind')} entry; only batch and restore can be undone",
148
+ op_id=op_id,
149
+ )
150
+ if op_id in undone:
151
+ raise DocControlError(
152
+ ErrorCode.HISTORY_NOT_FOUND, f"{op_id} was already undone", op_id=op_id
153
+ )
154
+ dry_run = request.get("dry_run", False)
155
+ outcome = undo_entry(
156
+ path,
157
+ entry,
158
+ expect_sha256=request.get("expect", {}).get("sha256"),
159
+ dry_run=dry_run,
160
+ env=None,
161
+ )
162
+ warnings: list[str] = []
163
+ result: dict[str, Any] = {
164
+ "undid": entry["op_id"],
165
+ "dry_run": dry_run,
166
+ "paragraphs": len(outcome.reverted),
167
+ }
168
+ if not dry_run:
169
+ assert outcome.after_sha256 is not None
170
+ try:
171
+ result["op_id"] = history.record(
172
+ path,
173
+ "undo",
174
+ before_sha256=outcome.before_sha256,
175
+ after_sha256=outcome.after_sha256,
176
+ backup=outcome.backup,
177
+ changes=outcome.reverted,
178
+ request={"op": "undo", "op_id": entry["op_id"]},
179
+ extra={"undoes": entry["op_id"]},
180
+ )
181
+ except (DocControlError, OSError) as exc:
182
+ result["op_id"] = None
183
+ warnings.append(
184
+ f"HISTORY_NOT_RECORDED: the undo succeeded but could not be logged: {exc}"
185
+ )
186
+ result["sha256"] = outcome.after_sha256
187
+ result["backup"] = str(outcome.backup)
188
+ return Reply(result, doc=Document(str(path)).envelope_doc(), warnings=warnings)
189
+
190
+
191
+ # -- restore --------------------------------------------------------------------------------
192
+
193
+
194
+ def _restorable(change: dict[str, Any]) -> bool:
195
+ return change.get("kind") == "replace" and bool(change.get("text_before"))
196
+
197
+
198
+ @operation(
199
+ "restore",
200
+ "write",
201
+ "Bring a logged paragraph back as a tracked insertion after the paragraph that holds its "
202
+ "place now. Without `change`, every paragraph the op deleted.",
203
+ {
204
+ "type": "object",
205
+ "properties": {
206
+ "doc": _DOC,
207
+ "store": _STORE,
208
+ "op_id": {**_OP_ID, "description": "The logged op the paragraph was changed by."},
209
+ "change": {
210
+ "type": "integer",
211
+ "minimum": 0,
212
+ "description": "Index of one change of the op (history.list shows them).",
213
+ },
214
+ "after": {
215
+ "type": "string",
216
+ "minLength": 1,
217
+ "description": "Insert after this paragraph address instead of the old place "
218
+ "(needed when that place was edited, or the history comes from `store`).",
219
+ },
220
+ "expect": _EXPECT,
221
+ "dry_run": {"type": "boolean", "description": "Preview only; write nothing."},
222
+ "author": {"type": "string", "minLength": 1, "description": "Revision author."},
223
+ "tag": {"type": "string", "minLength": 1, "description": "Appended to the author."},
224
+ },
225
+ "required": ["doc", "op_id"],
226
+ "additionalProperties": False,
227
+ },
228
+ )
229
+ def restore(request: dict[str, Any]) -> Reply:
230
+ path = Path(request["doc"])
231
+ entry = _log(request).get(request["op_id"])
232
+ changes = entry.get("changes", [])
233
+ if "change" in request:
234
+ number = request["change"]
235
+ if not 0 <= number < len(changes) or not _restorable(changes[number]):
236
+ raise DocControlError(
237
+ ErrorCode.HISTORY_NOT_FOUND,
238
+ f"{entry['op_id']} has no restorable paragraph at change {number}",
239
+ op_id=entry["op_id"],
240
+ )
241
+ chosen = [number]
242
+ else:
243
+ chosen = [n for n, c in enumerate(changes) if _restorable(c) and c.get("deleted")]
244
+ if not chosen:
245
+ raise DocControlError(
246
+ ErrorCode.HISTORY_NOT_FOUND,
247
+ f"{entry['op_id']} deleted no paragraph; pass `change` to restore an edited one",
248
+ op_id=entry["op_id"],
249
+ )
250
+ if "after" in request and len(chosen) != 1:
251
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "`after` needs exactly one `change`")
252
+ pkg = Package(path) if path.is_file() else None
253
+ if pkg is None:
254
+ raise DocControlError(ErrorCode.DOC_NOT_FOUND, f"no such file: {path}", path=str(path))
255
+ anchors = _anchors(pkg, changes, chosen, request)
256
+ edits = [InsertParagraph(changes[n]["text_before"], after=anchors[n]) for n in chosen]
257
+ dry_run = request.get("dry_run", False)
258
+ done = apply_edits(
259
+ path,
260
+ edits,
261
+ expect_sha256=request.get("expect", {}).get("sha256"),
262
+ author=request.get("author", DEFAULT_AUTHOR),
263
+ tag=request.get("tag"),
264
+ dry_run=dry_run,
265
+ kind="restore",
266
+ request={"op": "restore", "op_id": entry["op_id"], "changes": chosen},
267
+ log_extra={"restores": entry["op_id"]},
268
+ )
269
+ result = done.to_dict()
270
+ result["restored"] = [
271
+ {"change": n, "after": anchors[n], "text": _short(changes[n]["text_before"])}
272
+ for n in chosen
273
+ ]
274
+ result["from"] = entry["op_id"]
275
+ return _doc_reply(str(path), result)
276
+
277
+
278
+ def _anchors(
279
+ pkg: Package, changes: list[dict[str, Any]], chosen: list[int], request: dict[str, Any]
280
+ ) -> dict[int, str]:
281
+ """For each chosen change, the address of the paragraph to insert after."""
282
+ if "after" in request:
283
+ return {chosen[0]: request["after"]}
284
+ data = pkg.read(DOCUMENT_PART)
285
+ spans = scan(data).paragraphs
286
+ index = DocumentIndex.from_package(pkg)
287
+ by_span = {p.span_index: p for p in index.paragraphs}
288
+ out: dict[int, str] = {}
289
+ for n in chosen:
290
+ target = to_bytes(changes[n]["after"])
291
+ found = [i for i, s in enumerate(spans) if data[s.start : s.end] == target]
292
+ para = by_span.get(found[0]) if len(found) == 1 else None
293
+ if para is None:
294
+ raise DocControlError(
295
+ ErrorCode.BATCH_CONFLICT,
296
+ f"the place of change {n} was edited since; pass `after` with an address",
297
+ change=n,
298
+ )
299
+ out[n] = str(para.address)
300
+ return out
301
+
302
+
303
+ # -- history.* ------------------------------------------------------------------------------
304
+
305
+ _READ_PROPS: dict[str, Any] = {"doc": _DOC, "store": _STORE}
306
+
307
+
308
+ def _summary(entry: dict[str, Any], undone: set[str]) -> dict[str, Any]:
309
+ out: dict[str, Any] = {
310
+ "op_id": entry["op_id"],
311
+ "kind": entry.get("kind"),
312
+ "time": entry.get("time"),
313
+ }
314
+ if entry.get("kind") == "purge":
315
+ return {**out, "removed": entry.get("removed"), "scope": entry.get("scope")}
316
+ out.update(author=entry.get("author"), tag=entry.get("tag"))
317
+ changes = entry.get("changes", [])
318
+ out["paragraphs"] = len(changes)
319
+ request = entry.get("request")
320
+ if isinstance(request, list):
321
+ out["ops"] = [r.get("op") for r in request if isinstance(r, dict)]
322
+ if entry.get("kind") in UNDOABLE_KINDS:
323
+ out["undone"] = entry["op_id"] in undone
324
+ out["deleted"] = [
325
+ {"change": n, "text": _short(c["text_before"])}
326
+ for n, c in enumerate(changes)
327
+ if _restorable(c) and c.get("deleted")
328
+ ][:5]
329
+ for key in ("undoes", "restores", "sha256_after", "backup"):
330
+ if entry.get(key) is not None:
331
+ out[key] = entry[key]
332
+ return out
333
+
334
+
335
+ @operation(
336
+ "history.list",
337
+ "read",
338
+ "The undo log, newest first: what each write changed, whether it was undone, and the "
339
+ "deleted paragraphs `restore` can bring back.",
340
+ {
341
+ "type": "object",
342
+ "properties": {
343
+ **_READ_PROPS,
344
+ "limit": {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT},
345
+ "cursor": {**_OP_ID, "description": "The `next` of the previous response."},
346
+ "kind": {"enum": ["batch", "restore", "undo", "open", "purge"]},
347
+ },
348
+ "additionalProperties": False,
349
+ },
350
+ )
351
+ def history_list(request: dict[str, Any]) -> Reply:
352
+ log = _log(request)
353
+ undone = log.undone()
354
+ kind = request.get("kind")
355
+ rows = [e for e in reversed(log.entries) if kind is None or e.get("kind") == kind]
356
+ cursor = request.get("cursor")
357
+ if cursor is not None and history.op_number(cursor) is None:
358
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, f"bad cursor: {cursor!r}", path="$.cursor")
359
+ if cursor is not None:
360
+ rows = [
361
+ e
362
+ for e in rows
363
+ if (history.op_number(e["op_id"]) or 0) < (history.op_number(cursor) or 0)
364
+ ]
365
+ limit = request.get("limit", DEFAULT_LIMIT)
366
+ page = rows[:limit]
367
+ result: dict[str, Any] = {
368
+ "entries": [_summary(e, undone) for e in page],
369
+ "total": len(rows),
370
+ }
371
+ if log.skipped:
372
+ result["unreadable_lines"] = log.skipped
373
+ more = len(rows) > limit
374
+ return Reply(result, next_cursor=page[-1]["op_id"] if more and page else None)
375
+
376
+
377
+ @operation(
378
+ "history.search",
379
+ "read",
380
+ "Find a quote in the undo log: in the requests, and in the text of every paragraph "
381
+ "before and after each write.",
382
+ {
383
+ "type": "object",
384
+ "properties": {
385
+ **_READ_PROPS,
386
+ "query": {"type": "string", "minLength": 1, "description": "Text to find."},
387
+ "ignore_case": {"type": "boolean"},
388
+ "ignore_accents": {"type": "boolean"},
389
+ "limit": {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT},
390
+ },
391
+ "required": ["query"],
392
+ "additionalProperties": False,
393
+ },
394
+ )
395
+ def history_search(request: dict[str, Any]) -> Reply:
396
+ log = _log(request)
397
+ limit = request.get("limit", DEFAULT_LIMIT)
398
+ hits: list[dict[str, Any]] = []
399
+ total = 0
400
+ for entry in reversed(log.entries):
401
+ if entry.get("kind") == "purge":
402
+ continue
403
+ found = history.search_entry(
404
+ entry,
405
+ request["query"],
406
+ ignore_case=request.get("ignore_case", False),
407
+ ignore_accents=request.get("ignore_accents", False),
408
+ )
409
+ total += len(found)
410
+ hits.extend(found[: max(0, limit - len(hits))])
411
+ return Reply({"matches": hits, "total": total})
412
+
413
+
414
+ @operation(
415
+ "history.purge",
416
+ "write",
417
+ "Remove undo history (all, or before a date) for privacy, leaving a tombstone. Run with "
418
+ "dry_run first, then repeat with its `confirm`.",
419
+ {
420
+ "type": "object",
421
+ "properties": {
422
+ **_READ_PROPS,
423
+ "all": {"type": "boolean", "description": "Remove the whole log."},
424
+ "before": {
425
+ "type": "string",
426
+ "minLength": 1,
427
+ "description": "Remove entries older than this ISO date or time (UTC).",
428
+ },
429
+ "dry_run": {"type": "boolean", "description": "List what would go; returns confirm."},
430
+ "confirm": {"type": "string", "minLength": 1, "description": "From the dry run."},
431
+ },
432
+ "additionalProperties": False,
433
+ },
434
+ )
435
+ def history_purge(request: dict[str, Any]) -> Reply:
436
+ if "store" in request:
437
+ log_path = Path(request["store"]) / "log.jsonl"
438
+ elif "doc" in request:
439
+ log_path = log_path_for(Path(request["doc"]))
440
+ else:
441
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "give `doc` or `store`")
442
+ before = _parse_when(request["before"]) if "before" in request else None
443
+ result = history.purge(
444
+ log_path,
445
+ before=before,
446
+ everything=request.get("all", False),
447
+ dry_run=request.get("dry_run", False),
448
+ confirm=request.get("confirm"),
449
+ )
450
+ return Reply(result)
451
+
452
+
453
+ def _parse_when(text: str) -> datetime:
454
+ try:
455
+ when = datetime.fromisoformat(text.replace("Z", "+00:00"))
456
+ except ValueError as exc:
457
+ raise DocControlError(
458
+ ErrorCode.SCHEMA_INVALID, f"`before` is not an ISO date: {text!r}", path="$.before"
459
+ ) from exc
460
+ return when if when.tzinfo else when.replace(tzinfo=UTC)
461
+
462
+
463
+ @operation(
464
+ "history.orphans",
465
+ "read",
466
+ "List stores whose document is gone (app-data stores, and `dir`/.doc-control). Their "
467
+ "history is never deleted automatically: restore from it with `store`, or purge it.",
468
+ {
469
+ "type": "object",
470
+ "properties": {
471
+ "dir": {"type": "string", "minLength": 1, "description": "Also scan this folder."}
472
+ },
473
+ "additionalProperties": False,
474
+ },
475
+ )
476
+ def history_orphans(request: dict[str, Any]) -> Reply:
477
+ folder = Path(request["dir"]) if "dir" in request else None
478
+ found = history.find_orphans(folder, os.environ)
479
+ return Reply({**found, "total": len(found["orphans"])})
@@ -0,0 +1,40 @@
1
+ """Layout facts for `para.get` and `outline` (D-18), registered through the
2
+ :data:`doc_control.ops.read.PARA_FACTS` and :data:`doc_control.ops.read.OUTLINE_FACTS` hooks.
3
+
4
+ The definitions live in :mod:`doc_control.model.layout`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from doc_control.model.layout import blank_before, centred, is_empty, page_break_before
12
+ from doc_control.ops.read import OUTLINE_FACTS, PARA_FACTS, ParagraphContext
13
+
14
+
15
+ def para_facts(ctx: ParagraphContext) -> dict[str, Any]:
16
+ doc, para = ctx.doc, ctx.paragraph
17
+ return {
18
+ "empty": is_empty(doc, para),
19
+ "blank_before": blank_before(doc, para),
20
+ "heading_level": doc.heading_level(para),
21
+ "centred": centred(doc, para),
22
+ "page_break_before": page_break_before(doc, para),
23
+ }
24
+
25
+
26
+ def outline_facts(ctx: ParagraphContext) -> dict[str, Any]:
27
+ """Outline entries already carry ``level`` and ``style``; add the rest."""
28
+ doc, para = ctx.doc, ctx.paragraph
29
+ style = doc.style_id(para)
30
+ style_def = doc.styles.get(style) if style is not None else None
31
+ return {
32
+ "style_name": style_def.name if style_def is not None else None,
33
+ "blank_before": blank_before(doc, para),
34
+ "centred": centred(doc, para),
35
+ "page_break_before": page_break_before(doc, para),
36
+ }
37
+
38
+
39
+ PARA_FACTS.append(para_facts)
40
+ OUTLINE_FACTS.append(outline_facts)