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,1024 @@
1
+ """Tracked-change markup for one paragraph: ``w:ins``, ``w:del`` and paragraph marks.
2
+
3
+ :class:`ParagraphEditor` edits a paragraph element from
4
+ :meth:`doc_control.ooxml.splice.DocumentSpans.parse_paragraph` and writes every
5
+ change as a revision, so rejecting the revisions restores the original text and
6
+ accepting them gives the new text.
7
+
8
+ Markup written
9
+ Deleted text keeps its run (and the run's ``w:rPr``) and the run moves into a
10
+ ``w:del`` with ``w:t`` renamed ``w:delText``. Inserted text is one run inside
11
+ a ``w:ins``; its ``w:rPr`` is a copy of the run at the start of the range.
12
+ A deleted paragraph has ``w:pPr/w:rPr/w:del`` on its mark, an inserted one
13
+ ``w:pPr/w:rPr/w:ins``. A run is split where a range starts or ends; the new
14
+ half copies the run's attributes and ``w:rPr``. Text inside someone else's
15
+ ``w:ins`` is deleted with a ``w:del`` nested in it (what Word writes); new
16
+ text is never nested there: the foreign ``w:ins`` is split in two, the
17
+ continuation getting a fresh id, author and date copied.
18
+
19
+ What a range may cover
20
+ Only runs made of text (``w:t``, ``w:tab``, ``w:br``, ``w:cr``, hyphens,
21
+ ``w:sym``, ``w:ptab``) directly in the paragraph or in a ``w:ins``, outside
22
+ fields. Text in a hyperlink, field, smart tag, content control, ruby or
23
+ alternate content, a run holding a drawing, a footnote reference or a field
24
+ character, or such a run between the two ends of the range, raises
25
+ ``UNSUPPORTED_CONTENT`` (spec §Risks). Bookmark and comment-range markers,
26
+ proof marks and existing deletions between the ends stay where they are.
27
+
28
+ Bookmarks in a move
29
+ A moved range carries the bookmarks that start and end inside it (zero
30
+ width ones included): the markers leave the deleted originals and are
31
+ recreated, same name and a fresh id, inside the inserted copy. Accepting
32
+ leaves one live bookmark at the new place; rejecting removes the copy, and
33
+ with it the bookmark. ``_GoBack`` is dropped (Word recreates it). A bookmark
34
+ that starts or ends outside the range, and any comment anchor, is refused.
35
+ Trade-off: the references to a carried bookmark (REF/PAGEREF fields, TOC
36
+ ``\\h`` entries and link anchors) keep working after accepting but dangle
37
+ after rejecting, so :func:`referenced_bookmarks` feeds a warning.
38
+
39
+ Ids
40
+ :class:`RevisionContext` hands out ``w:id`` values above every ``w:id`` in
41
+ the document, comments, footnotes, endnotes, headers and footers, so a new
42
+ revision never shares an id with a revision, comment or bookmark.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import copy
48
+ import re
49
+ import xml.etree.ElementTree as ET
50
+ from collections.abc import Callable, Sequence
51
+ from dataclasses import dataclass, field
52
+ from datetime import UTC, datetime
53
+
54
+ from doc_control.errors import DocControlError, ErrorCode
55
+ from doc_control.model.index import Paragraph, Piece
56
+ from doc_control.ooxml.formatting import apply_ppr, apply_rpr
57
+ from doc_control.ooxml.splice import W_NS, XML_NS
58
+ from doc_control.ooxml.zipio import Package
59
+
60
+ W = f"{{{W_NS}}}"
61
+ XML_SPACE = f"{{{XML_NS}}}space"
62
+
63
+ P, PPR, RPR, R = W + "p", W + "pPr", W + "rPr", W + "r"
64
+ T, DELTEXT, INS, DEL = W + "t", W + "delText", W + "ins", W + "del"
65
+ BR = W + "br"
66
+ ID, AUTHOR, DATE = W + "id", W + "author", W + "date"
67
+
68
+ DEFAULT_AUTHOR = "Claude"
69
+ MAX_ID = 2**31 - 1
70
+ STORY_PART = re.compile(r"^word/(document|comments|footnotes|endnotes|header\d*|footer\d*)\.xml$")
71
+
72
+ _TEXT_TOKENS = frozenset(
73
+ W + name for name in ("t", "tab", "cr", "noBreakHyphen", "softHyphen", "sym", "ptab", "br")
74
+ )
75
+ _QUIET_TOKENS = frozenset({RPR, W + "lastRenderedPageBreak"})
76
+ _INERT_TOKENS = _QUIET_TOKENS | {W + "commentReference", W + "annotationRef"}
77
+ _INERT_ELEMENTS = frozenset(
78
+ W + name
79
+ for name in (
80
+ "bookmarkStart",
81
+ "bookmarkEnd",
82
+ "commentRangeStart",
83
+ "commentRangeEnd",
84
+ "permStart",
85
+ "permEnd",
86
+ "proofErr",
87
+ "moveFromRangeStart",
88
+ "moveFromRangeEnd",
89
+ "moveToRangeStart",
90
+ "moveToRangeEnd",
91
+ )
92
+ )
93
+ BOOKMARK_START, BOOKMARK_END = W + "bookmarkStart", W + "bookmarkEnd"
94
+ GOBACK = "_GoBack"
95
+ _MARK_TAGS = frozenset({INS, DEL, W + "moveFrom", W + "moveTo", W + "rPrChange"})
96
+ _PAGE_BREAKS = frozenset({"page", "column"})
97
+ _VANISH_OFF = frozenset({"0", "false", "off"})
98
+ _PAGE_BREAK_TOKEN = "page-break"
99
+
100
+
101
+ def format_date(now: datetime | None = None) -> str:
102
+ """``now`` (default: the current time) as ISO 8601 UTC with ``Z``, to the second."""
103
+ moment = datetime.now(UTC) if now is None else now
104
+ if moment.tzinfo is None:
105
+ raise ValueError("revision date must be timezone-aware")
106
+ return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
107
+
108
+
109
+ def format_author(author: str, tag: str | None) -> str:
110
+ """The ``w:author`` value: the author, with the per-call tag in parentheses if given."""
111
+ return f"{author} ({tag})" if tag else author
112
+
113
+
114
+ def used_ids(pkg: Package) -> set[int]:
115
+ """Every numeric ``w:id`` in the package's story parts (revisions, comments, bookmarks…)."""
116
+ ids: set[int] = set()
117
+ for name in pkg.names:
118
+ if not STORY_PART.match(name):
119
+ continue
120
+ try:
121
+ root = ET.fromstring(pkg.read(name))
122
+ except ET.ParseError as exc:
123
+ raise DocControlError(
124
+ ErrorCode.NOT_A_DOCX, f"{name} does not parse: {exc}", part=name
125
+ ) from exc
126
+ for elem in root.iter():
127
+ raw = elem.get(ID)
128
+ if raw is None:
129
+ continue
130
+ try:
131
+ ids.add(int(raw.strip()))
132
+ except ValueError:
133
+ continue
134
+ return ids
135
+
136
+
137
+ @dataclass
138
+ class RevisionContext:
139
+ """Author, date and the id counter shared by every revision one call writes.
140
+
141
+ ``revision_ids`` lists the ids of the revisions the call authored (what
142
+ "reject every revision the tool wrote" means); ``continuation_ids`` the ids
143
+ given to the second half of someone else's revision that had to be split.
144
+ """
145
+
146
+ used: set[int]
147
+ author: str
148
+ date: str
149
+ revision_ids: list[str] = field(default_factory=list)
150
+ continuation_ids: list[str] = field(default_factory=list)
151
+ _next: int = field(init=False, default=1)
152
+
153
+ def __post_init__(self) -> None:
154
+ self._next = max(max(self.used, default=0), 0) + 1
155
+
156
+ def _allocate(self) -> str:
157
+ value = self._next
158
+ if value > MAX_ID:
159
+ raise DocControlError(ErrorCode.INTERNAL, "no revision ids left in this document")
160
+ self._next += 1
161
+ return str(value)
162
+
163
+ def revision(self, kind: str) -> ET.Element:
164
+ """A new ``w:ins`` or ``w:del`` carrying id, author and date."""
165
+ rid = self._allocate()
166
+ self.revision_ids.append(rid)
167
+ return ET.Element(W + kind, {ID: rid, AUTHOR: self.author, DATE: self.date})
168
+
169
+ def continuation_id(self) -> str:
170
+ rid = self._allocate()
171
+ self.continuation_ids.append(rid)
172
+ return rid
173
+
174
+
175
+ def unsupported(message: str, **details: object) -> DocControlError:
176
+ return DocControlError(ErrorCode.UNSUPPORTED_CONTENT, message, **details)
177
+
178
+
179
+ # -- small element helpers ------------------------------------------------
180
+
181
+
182
+ def _elements(node: ET.Element) -> list[ET.Element]:
183
+ """Child elements, leaving out comments and processing instructions."""
184
+ return [c for c in node if isinstance(c.tag, str)]
185
+
186
+
187
+ def _set_space(t: ET.Element) -> None:
188
+ text = t.text or ""
189
+ if text != text.strip():
190
+ t.set(XML_SPACE, "preserve")
191
+
192
+
193
+ def _text_element(tag: str, text: str) -> ET.Element:
194
+ elem = ET.Element(tag)
195
+ elem.text = text
196
+ _set_space(elem)
197
+ return elem
198
+
199
+
200
+ def _tokens(run: ET.Element) -> set[str]:
201
+ out: set[str] = set()
202
+ for child in run:
203
+ if not isinstance(child.tag, str):
204
+ continue
205
+ if child.tag == BR and child.get(W + "type") in _PAGE_BREAKS:
206
+ out.add(_PAGE_BREAK_TOKEN)
207
+ else:
208
+ out.add(child.tag)
209
+ return out
210
+
211
+
212
+ def _hidden(run: ET.Element) -> bool:
213
+ rpr = run.find(RPR)
214
+ vanish = rpr.find(W + "vanish") if rpr is not None else None
215
+ return vanish is not None and vanish.get(W + "val", "1").lower() not in _VANISH_OFF
216
+
217
+
218
+ def _run_kind(run: ET.Element) -> str:
219
+ """``text``, ``inert`` (nothing to read or delete), ``hidden`` or ``other`` (structure)."""
220
+ tokens = _tokens(run)
221
+ plain = tokens <= (_TEXT_TOKENS | _QUIET_TOKENS)
222
+ if _hidden(run):
223
+ return "hidden" if tokens <= (_TEXT_TOKENS | _INERT_TOKENS) else "other"
224
+ if plain and tokens & _TEXT_TOKENS:
225
+ return "text"
226
+ if tokens <= _INERT_TOKENS:
227
+ return "inert"
228
+ return "other"
229
+
230
+
231
+ def _item_kind(node: ET.Element) -> str:
232
+ if node.tag == R:
233
+ return _run_kind(node)
234
+ if node.tag in _INERT_ELEMENTS or node.tag == DEL:
235
+ return "inert"
236
+ return "other"
237
+
238
+
239
+ def _flat(p: ET.Element) -> list[tuple[ET.Element, ET.Element]]:
240
+ """``(parent, node)`` for the paragraph's content in order, entering ``w:ins`` wrappers."""
241
+ items: list[tuple[ET.Element, ET.Element]] = []
242
+ for child in _elements(p):
243
+ if child.tag == PPR:
244
+ continue
245
+ if child.tag == INS:
246
+ items.extend((child, sub) for sub in _elements(child))
247
+ else:
248
+ items.append((p, child))
249
+ return items
250
+
251
+
252
+ _FIELD_TARGET = re.compile(r'\b(?:REF|PAGEREF|NOTEREF)\s+"?([^\s"\\]+)')
253
+
254
+
255
+ def referenced_bookmarks(pkg: Package) -> set[str]:
256
+ """Names that a REF/PAGEREF/NOTEREF field (simple or complex) or a link ``w:anchor`` targets.
257
+
258
+ A TOC ``\\h`` entry is such a link. Looked up in every story part.
259
+ """
260
+ found: set[str] = set()
261
+ for name in pkg.names:
262
+ if not STORY_PART.match(name):
263
+ continue
264
+ try:
265
+ root = ET.fromstring(pkg.read(name))
266
+ except ET.ParseError:
267
+ continue
268
+ for elem in root.iter():
269
+ if elem.tag == W + "hyperlink" and elem.get(W + "anchor"):
270
+ found.add(elem.get(W + "anchor", ""))
271
+ elif elem.tag == W + "fldSimple":
272
+ found.update(_FIELD_TARGET.findall(elem.get(W + "instr", "")))
273
+ elif elem.tag == P:
274
+ instr = "".join(t.text or "" for t in elem.iter(W + "instrText"))
275
+ found.update(_FIELD_TARGET.findall(instr))
276
+ return found
277
+
278
+
279
+ class BookmarkScope:
280
+ """The bookmarks of a moved range of paragraphs, checked and given their new ids.
281
+
282
+ ``paragraphs`` are the range's (unedited) elements, ``addresses`` their
283
+ addresses for messages, ``name_of`` finds the name of a bookmark by id when
284
+ only its end is in the range. A bookmark must start and end inside the range
285
+ (a zero width one does) or the move is refused; ``_GoBack`` is dropped.
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ paragraphs: Sequence[ET.Element],
291
+ addresses: Sequence[str],
292
+ ctx: RevisionContext,
293
+ name_of: Callable[[str], str | None],
294
+ ) -> None:
295
+ self.ctx = ctx
296
+ self.dropped: set[str] = set()
297
+ self.carried: dict[str, str] = {} # old id -> new id, allocated on first use
298
+ self.names: list[str] = [] # names of the carried bookmarks
299
+ open_: dict[str, str] = {}
300
+ for p, address in zip(paragraphs, addresses, strict=True):
301
+ reachable = {id(node) for _, node in _flat(p)}
302
+ for elem in p.iter():
303
+ if elem.tag not in (BOOKMARK_START, BOOKMARK_END):
304
+ continue
305
+ bid = elem.get(ID, "")
306
+ if id(elem) not in reachable:
307
+ name = elem.get(W + "name") or name_of(bid) or f"id {bid}"
308
+ raise unsupported(
309
+ f"bookmark {name!r} sits inside a deletion or other structure, "
310
+ "which a move cannot carry",
311
+ paragraph=address,
312
+ bookmark=name,
313
+ )
314
+ if elem.tag == BOOKMARK_START:
315
+ open_[bid] = elem.get(W + "name", "")
316
+ continue
317
+ if bid not in open_:
318
+ name = name_of(bid) or f"id {bid}"
319
+ raise unsupported(
320
+ f"bookmark {name!r} starts outside the moved paragraphs, "
321
+ "which a move cannot carry",
322
+ paragraph=address,
323
+ bookmark=name,
324
+ )
325
+ name = open_.pop(bid)
326
+ if name == GOBACK:
327
+ self.dropped.add(bid)
328
+ else:
329
+ self.carried[bid] = ""
330
+ self.names.append(name)
331
+ if open_:
332
+ name = next(iter(open_.values()))
333
+ raise unsupported(
334
+ f"bookmark {name!r} ends outside the moved paragraphs, which a move cannot carry",
335
+ paragraph=addresses[-1],
336
+ bookmark=name,
337
+ )
338
+
339
+ def owns(self, marker: ET.Element) -> bool:
340
+ bid = marker.get(ID, "")
341
+ return bid in self.dropped or bid in self.carried
342
+
343
+ def new_id(self, old: str) -> str:
344
+ if not self.carried[old]:
345
+ self.carried[old] = self.ctx.continuation_id()
346
+ return self.carried[old]
347
+
348
+
349
+ def _clone_rpr(rpr: ET.Element, ctx: RevisionContext, *, keep_changes: bool) -> ET.Element:
350
+ """A copy of a run's ``w:rPr``; a tracked format change either gets a new id or is dropped."""
351
+ clone = copy.deepcopy(rpr)
352
+ for parent in list(clone.iter()):
353
+ for child in list(parent):
354
+ if child.tag == W + "rPrChange" and not keep_changes:
355
+ parent.remove(child)
356
+ for elem in clone.iter():
357
+ if elem.get(ID) is not None:
358
+ elem.set(ID, ctx.continuation_id())
359
+ return clone
360
+
361
+
362
+ def clone_run(run: ET.Element, ctx: RevisionContext) -> ET.Element:
363
+ """A copy of ``run`` for new content: formatting kept, tracked format changes dropped."""
364
+ new = ET.Element(run.tag, dict(run.attrib))
365
+ for child in run:
366
+ if child.tag == RPR:
367
+ new.append(_clone_rpr(child, ctx, keep_changes=False))
368
+ else:
369
+ new.append(copy.deepcopy(child))
370
+ return new
371
+
372
+
373
+ def text_children(text: str) -> list[ET.Element]:
374
+ """``w:t``/``w:tab``/``w:br`` children giving exactly ``text`` as visible text."""
375
+ out: list[ET.Element] = []
376
+ for part in re.split(r"([\t\n])", text):
377
+ if part == "\t":
378
+ out.append(ET.Element(W + "tab"))
379
+ elif part == "\n":
380
+ out.append(ET.Element(BR))
381
+ elif part:
382
+ out.append(_text_element(T, part))
383
+ return out
384
+
385
+
386
+ # -- paragraph marks --------------------------------------------------------
387
+
388
+
389
+ def mark_state(p: ET.Element) -> set[tuple[str, str]]:
390
+ """``(kind, id)`` of the revisions on the paragraph mark (``ins``, ``del``…)."""
391
+ rpr = _mark_rpr(p)
392
+ if rpr is None:
393
+ return set()
394
+ return {(c.tag.removeprefix(W), c.get(ID, "")) for c in _elements(rpr) if c.tag in _MARK_TAGS}
395
+
396
+
397
+ def _mark_rpr(p: ET.Element) -> ET.Element | None:
398
+ ppr = p.find(PPR)
399
+ return ppr.find(RPR) if ppr is not None else None
400
+
401
+
402
+ def has_section_break(p: ET.Element) -> bool:
403
+ ppr = p.find(PPR)
404
+ return ppr is not None and ppr.find(W + "sectPr") is not None
405
+
406
+
407
+ def set_mark(p: ET.Element, kind: str, ctx: RevisionContext) -> None:
408
+ """Track the paragraph mark as inserted (``ins``) or deleted (``del``)."""
409
+ if has_section_break(p):
410
+ raise unsupported("the paragraph ends a section: its mark cannot be tracked")
411
+ ppr = p.find(PPR)
412
+ if ppr is None:
413
+ ppr = ET.Element(PPR)
414
+ p.insert(0, ppr)
415
+ rpr = ppr.find(RPR)
416
+ if rpr is None:
417
+ rpr = ET.Element(RPR)
418
+ later = [i for i, c in enumerate(ppr) if c.tag in (W + "sectPr", W + "pPrChange")]
419
+ ppr.insert(later[0] if later else len(ppr), rpr)
420
+ if any(c.tag == W + kind for c in rpr):
421
+ if kind == "ins":
422
+ raise unsupported("the paragraph mark is already someone else's insertion")
423
+ return # already deleted by someone else
424
+ where = 0
425
+ if kind == "del" and len(rpr) and rpr[0].tag == INS:
426
+ where = 1 # CT_ParaRPr orders ins before del
427
+ rpr.insert(where, ctx.revision(kind))
428
+
429
+
430
+ def track_paragraph_format(
431
+ p: ET.Element, ctx: RevisionContext, props: dict[str, object], where: dict[str, object]
432
+ ) -> bool:
433
+ """Change a paragraph's properties as a ``w:pPrChange`` holding the previous ones.
434
+
435
+ The change is the last child of ``w:pPr`` and its ``w:pPr`` the previous properties
436
+ (without the mark's ``w:rPr``, ``w:sectPr`` and any earlier change). Returns False if
437
+ nothing would change. A paragraph with an unresolved tracked property change is refused.
438
+ """
439
+ ppr = p.find(PPR)
440
+ if ppr is not None and ppr.find(W + "pPrChange") is not None:
441
+ raise unsupported("the paragraph has an unresolved tracked property change", **where)
442
+ work = copy.deepcopy(ppr) if ppr is not None else ET.Element(PPR)
443
+ previous = ET.Element(PPR)
444
+ previous.extend(
445
+ copy.deepcopy(c) for c in work if c.tag not in (RPR, W + "sectPr", W + "pPrChange")
446
+ )
447
+ if not apply_ppr(work, props):
448
+ return False
449
+ change = ctx.revision("pPrChange")
450
+ change.append(previous)
451
+ work.append(change)
452
+ if ppr is not None:
453
+ p.remove(ppr)
454
+ p.insert(0, work)
455
+ return True
456
+
457
+
458
+ def new_paragraph(
459
+ reference: ET.Element | None, ctx: RevisionContext, *, inserted_mark: bool
460
+ ) -> tuple[ET.Element, ET.Element | None]:
461
+ """An empty paragraph with the reference paragraph's properties.
462
+
463
+ Returns the ``w:p`` and the run properties its text should use (the
464
+ reference paragraph's mark properties). Section breaks, tracked property
465
+ changes and the reference's revision marks are not copied.
466
+ """
467
+ p = ET.Element(P)
468
+ ppr = ET.Element(PPR)
469
+ src = reference.find(PPR) if reference is not None else None
470
+ if src is not None:
471
+ ppr = copy.deepcopy(src)
472
+ for child in list(ppr):
473
+ if child.tag in (W + "sectPr", W + "pPrChange"):
474
+ ppr.remove(child)
475
+ rpr = ppr.find(RPR)
476
+ if rpr is None:
477
+ rpr = ET.Element(RPR)
478
+ ppr.append(rpr)
479
+ for child in list(rpr):
480
+ if child.tag in _MARK_TAGS:
481
+ rpr.remove(child)
482
+ run_props: ET.Element | None = None
483
+ if len(rpr):
484
+ run_props = _clone_rpr(rpr, ctx, keep_changes=False)
485
+ if inserted_mark:
486
+ rpr.insert(0, ctx.revision("ins"))
487
+ elif not len(rpr):
488
+ ppr.remove(rpr)
489
+ if len(ppr):
490
+ p.append(ppr)
491
+ return p, run_props
492
+
493
+
494
+ def inserted_content(
495
+ ctx: RevisionContext,
496
+ *,
497
+ text: str | None = None,
498
+ run_props: ET.Element | None = None,
499
+ runs: list[ET.Element] | None = None,
500
+ ) -> ET.Element | None:
501
+ """A ``w:ins`` holding a new run for ``text`` or the given ``runs``; None if both are empty."""
502
+ if runs is None:
503
+ if not text:
504
+ return None
505
+ run = ET.Element(R)
506
+ if run_props is not None:
507
+ run.append(copy.deepcopy(run_props))
508
+ run.extend(text_children(text))
509
+ runs = [run]
510
+ if not runs:
511
+ return None
512
+ ins = ctx.revision("ins")
513
+ ins.extend(runs)
514
+ return ins
515
+
516
+
517
+ # -- the paragraph editor -----------------------------------------------------
518
+
519
+
520
+ class ParagraphEditor:
521
+ """Edits one paragraph element in place as tracked changes."""
522
+
523
+ def __init__(self, p: ET.Element, para: Paragraph, ctx: RevisionContext) -> None:
524
+ self.p = p
525
+ self.para = para
526
+ self.ctx = ctx
527
+
528
+ # -- locating ----------------------------------------------------------
529
+
530
+ def _where(self, what: str) -> dict[str, object]:
531
+ return {"paragraph": str(self.para.address), "where": what}
532
+
533
+ def _site(self, piece: Piece) -> tuple[ET.Element, ET.Element, int]:
534
+ """``(parent, run, child index)`` of a piece in an editable place, else a refusal."""
535
+ if piece.in_field:
536
+ raise unsupported("the range covers text inside a field", **self._where("field"))
537
+ nodes = [self.p]
538
+ try:
539
+ for i in piece.path:
540
+ nodes.append(nodes[-1][i])
541
+ except IndexError as exc:
542
+ raise DocControlError(
543
+ ErrorCode.INTERNAL, f"{self.para.address}: index does not match the paragraph"
544
+ ) from exc
545
+ inner, run, leaf = nodes[1:-2], nodes[-2], nodes[-1]
546
+ if run.tag != R:
547
+ raise unsupported("the range covers text outside a run", **self._where("structure"))
548
+ if len(inner) > 1 or (inner and inner[0].tag != INS):
549
+ kind = inner[0].tag.removeprefix(W) if inner and isinstance(inner[0].tag, str) else "?"
550
+ raise unsupported(
551
+ f"the range covers text inside a {kind} element, which v1 does not edit",
552
+ **self._where(kind),
553
+ )
554
+ kind = _run_kind(run)
555
+ if kind != "text":
556
+ raise unsupported(
557
+ "the range covers a run with a drawing, reference or other structure",
558
+ **self._where("run"),
559
+ )
560
+ parent = inner[0] if inner else self.p
561
+ # An edit to the right of this one may already have cut the text short.
562
+ seen = "".join(leaf.itertext()) if leaf.tag == T else None
563
+ if seen is not None and (
564
+ len(leaf) or not self.para.text[piece.start : piece.end].startswith(seen)
565
+ ):
566
+ raise DocControlError(
567
+ ErrorCode.INTERNAL, f"{self.para.address}: index does not match the text"
568
+ )
569
+ return parent, run, piece.path[-1]
570
+
571
+ def _require_plain(self) -> None:
572
+ """Refuse a paragraph holding anything but plain runs, markers and revisions."""
573
+ for _, node in _flat(self.p):
574
+ if _item_kind(node) == "other":
575
+ raise unsupported(
576
+ "the paragraph holds a field, link, drawing, reference or other structure",
577
+ **self._where(node.tag.removeprefix(W) if isinstance(node.tag, str) else "?"),
578
+ )
579
+
580
+ # -- cutting runs ----------------------------------------------------------
581
+
582
+ def _cut(
583
+ self, parent: ET.Element, run: ET.Element, k: int, offset: int
584
+ ) -> tuple[ET.Element | None, ET.Element | None]:
585
+ """Cut ``run`` before character ``offset`` of its child ``k``.
586
+
587
+ Returns ``(left, right)``: the run ending at the cut and the run
588
+ starting at it. One is ``None`` when the cut is at the run's edge, and
589
+ then the other is ``run`` itself. The left half keeps the original
590
+ element, so paths that point at or before the cut stay valid.
591
+ """
592
+ child = run[k]
593
+ b = k
594
+ if child.tag == T:
595
+ text = child.text or ""
596
+ if offset >= len(text):
597
+ b = k + 1
598
+ elif offset > 0:
599
+ tail = ET.Element(T, dict(child.attrib))
600
+ tail.text = text[offset:]
601
+ child.text = text[:offset]
602
+ _set_space(child)
603
+ _set_space(tail)
604
+ run.insert(k + 1, tail)
605
+ b = k + 1
606
+ elif offset > 0:
607
+ b = k + 1
608
+ kids = list(run)
609
+ before = [c for c in kids[:b] if isinstance(c.tag, str) and c.tag != RPR]
610
+ after = [c for c in kids[b:] if isinstance(c.tag, str)]
611
+ if not before:
612
+ return None, run
613
+ if not after:
614
+ return run, None
615
+ right = ET.Element(run.tag, dict(run.attrib))
616
+ rpr = run.find(RPR)
617
+ if rpr is not None:
618
+ right.append(_clone_rpr(rpr, self.ctx, keep_changes=True))
619
+ for c in kids[b:]:
620
+ run.remove(c)
621
+ right.append(c)
622
+ parent.insert(list(parent).index(run) + 1, right)
623
+ return run, right
624
+
625
+ def _wrapper_copy(self, wrapper: ET.Element) -> ET.Element:
626
+ """An empty ``w:ins`` like ``wrapper`` (same author and date) with a fresh id."""
627
+ new = ET.Element(wrapper.tag, dict(wrapper.attrib))
628
+ new.set(ID, self.ctx.continuation_id())
629
+ return new
630
+
631
+ def _insert_after(
632
+ self, parent: ET.Element, node: ET.Element, new: ET.Element | Sequence[ET.Element]
633
+ ) -> None:
634
+ """Put ``new`` right after ``node``, outside any ``w:ins`` ``parent`` is."""
635
+ news = [new] if isinstance(new, ET.Element) else list(new)
636
+ if parent is self.p:
637
+ at = list(parent).index(node) + 1
638
+ for j, item in enumerate(news):
639
+ parent.insert(at + j, item)
640
+ return
641
+ kids = list(parent)
642
+ following = kids[kids.index(node) + 1 :]
643
+ at = list(self.p).index(parent) + 1
644
+ if following:
645
+ rest = self._wrapper_copy(parent)
646
+ for c in following:
647
+ parent.remove(c)
648
+ rest.append(c)
649
+ self.p.insert(at, rest)
650
+ for j, item in enumerate(news):
651
+ self.p.insert(at + j, item)
652
+
653
+ def _insert_before(
654
+ self, parent: ET.Element, node: ET.Element, new: ET.Element | Sequence[ET.Element]
655
+ ) -> None:
656
+ """Put ``new`` right before ``node``, outside any ``w:ins`` ``parent`` is."""
657
+ news = [new] if isinstance(new, ET.Element) else list(new)
658
+ if parent is self.p:
659
+ at = list(parent).index(node)
660
+ for j, item in enumerate(news):
661
+ parent.insert(at + j, item)
662
+ return
663
+ kids = list(parent)
664
+ at = list(self.p).index(parent)
665
+ if any(isinstance(c.tag, str) for c in kids[: kids.index(node)]):
666
+ rest = self._wrapper_copy(parent)
667
+ for c in kids[kids.index(node) :]:
668
+ parent.remove(c)
669
+ rest.append(c)
670
+ self.p.insert(at + 1, rest)
671
+ at += 1
672
+ for j, item in enumerate(news):
673
+ self.p.insert(at + j, item)
674
+
675
+ # -- the edits ---------------------------------------------------------------
676
+
677
+ def replace(self, start: int, end: int, text: str | None) -> None:
678
+ """Track the deletion of visible characters ``[start, end)`` and insert ``text`` there.
679
+
680
+ ``text`` of ``None`` or ``""`` only deletes. With ``start == end`` the
681
+ text is inserted at that offset. The caller has checked the offsets.
682
+ """
683
+ if start == end:
684
+ if text:
685
+ self._insert_at(start, text)
686
+ return
687
+ pieces = self.para.pieces_in(start, end)
688
+ sites = [self._site(piece) for piece in pieces]
689
+ first_piece, last_piece = pieces[0], pieces[-1]
690
+ first_parent, first_run, first_k = sites[0]
691
+ last_parent, last_run, last_k = sites[-1]
692
+ rpr = first_run.find(RPR)
693
+ run_props = _clone_rpr(rpr, self.ctx, keep_changes=False) if rpr is not None else None
694
+
695
+ left, _ = self._cut(last_parent, last_run, last_k, end - last_piece.start)
696
+ assert left is not None
697
+ last_run = left
698
+ _, right = self._cut(first_parent, first_run, first_k, start - first_piece.start)
699
+ if right is not None:
700
+ first_run = right
701
+ if last_run is sites[0][1]: # the range ended in the run that was just split
702
+ last_run = right
703
+
704
+ items = _flat(self.p)
705
+ order = {id(node): i for i, (_, node) in enumerate(items)}
706
+ span = items[order[id(first_run)] : order[id(last_run)] + 1]
707
+ groups: list[list[tuple[ET.Element, ET.Element]]] = []
708
+ broken = True
709
+ for parent, node in span:
710
+ kind = _item_kind(node)
711
+ if kind == "other":
712
+ raise unsupported(
713
+ "the range covers a field, link, drawing, reference or other structure",
714
+ **self._where(node.tag.removeprefix(W) if isinstance(node.tag, str) else "?"),
715
+ )
716
+ if kind == "text":
717
+ if broken or groups[-1][-1][0] is not parent:
718
+ groups.append([])
719
+ groups[-1].append((parent, node))
720
+ broken = False
721
+ else:
722
+ broken = True
723
+ wrapped = self._wrap(groups)
724
+ last_del, last_parent_of_del = wrapped if wrapped else (None, self.p)
725
+ if text:
726
+ assert last_del is not None
727
+ ins = inserted_content(self.ctx, text=text, run_props=run_props)
728
+ assert ins is not None
729
+ self._insert_after(last_parent_of_del, last_del, ins)
730
+
731
+ def _wrap(
732
+ self, groups: list[list[tuple[ET.Element, ET.Element]]]
733
+ ) -> tuple[ET.Element, ET.Element] | None:
734
+ """Move each group of runs into a new ``w:del``; returns the last ``(del, parent)``."""
735
+ last: tuple[ET.Element, ET.Element] | None = None
736
+ for group in groups:
737
+ parent = group[0][0]
738
+ runs = [node for _, node in group]
739
+ at = list(parent).index(runs[0])
740
+ deletion = self.ctx.revision("del")
741
+ for run in runs:
742
+ parent.remove(run)
743
+ for child in run:
744
+ if child.tag == T:
745
+ child.tag = DELTEXT
746
+ deletion.append(run)
747
+ parent.insert(at, deletion)
748
+ last = (deletion, parent)
749
+ return last
750
+
751
+ def _insert_at(self, offset: int, text: str) -> None:
752
+ """Insert ``text`` at visible ``offset`` (no characters deleted)."""
753
+ after = self.para.pieces_in(offset, offset + 1)
754
+ before = self.para.pieces_in(offset - 1, offset) if offset > 0 else []
755
+ run_props: ET.Element | None = None
756
+ if after and not after[0].in_field and self._editable(after[0]):
757
+ parent, run, k = self._site(after[0])
758
+ run_props = self._props_of(run)
759
+ _, right = self._cut(parent, run, k, offset - after[0].start)
760
+ target = right if right is not None else run
761
+ ins = inserted_content(self.ctx, text=text, run_props=run_props)
762
+ assert ins is not None
763
+ self._insert_before(parent, target, ins)
764
+ elif before and not before[0].in_field and self._editable(before[0]):
765
+ parent, run, k = self._site(before[0])
766
+ run_props = self._props_of(run)
767
+ left, _ = self._cut(parent, run, k, offset - before[0].start)
768
+ target = left if left is not None else run
769
+ ins = inserted_content(self.ctx, text=text, run_props=run_props)
770
+ assert ins is not None
771
+ self._insert_after(parent, target, ins)
772
+ elif not self.para.pieces:
773
+ self._require_plain()
774
+ ins = inserted_content(self.ctx, text=text, run_props=self._mark_props())
775
+ assert ins is not None
776
+ self.p.append(ins)
777
+ else:
778
+ raise unsupported(
779
+ "the insertion point is next to a field, link or other structure",
780
+ **self._where("insertion point"),
781
+ )
782
+
783
+ def place(self, offset: int, nodes: Sequence[ET.Element]) -> None:
784
+ """Put zero-width ``nodes`` (comment markers) at visible ``offset``; no text changes.
785
+
786
+ Offset ``0`` and the end of the text go to the paragraph's edges, so a
787
+ whole-paragraph anchor works next to a field or link. Elsewhere the run at
788
+ the offset is cut and the nodes land between the halves, outside any
789
+ ``w:ins``. Several placements in one paragraph must be made from the
790
+ highest offset down: a placement shifts what follows it. Raises
791
+ ``UNSUPPORTED_CONTENT`` inside a field, link or other structure.
792
+ """
793
+ if offset <= 0:
794
+ at = 1 if len(self.p) and self.p[0].tag == PPR else 0
795
+ for j, item in enumerate(nodes):
796
+ self.p.insert(at + j, item)
797
+ return
798
+ if offset >= len(self.para.text):
799
+ self.p.extend(nodes)
800
+ return
801
+ after = self.para.pieces_in(offset, offset + 1)
802
+ before = self.para.pieces_in(offset - 1, offset)
803
+ if after and self._editable(after[0]):
804
+ parent, run, k = self._site(after[0])
805
+ _, right = self._cut(parent, run, k, offset - after[0].start)
806
+ self._insert_before(parent, right if right is not None else run, nodes)
807
+ elif before and self._editable(before[0]):
808
+ parent, run, k = self._site(before[0])
809
+ left, _ = self._cut(parent, run, k, offset - before[0].start)
810
+ self._insert_after(parent, left if left is not None else run, nodes)
811
+ else:
812
+ raise unsupported(
813
+ "the anchor point is inside or next to a field, link or other structure",
814
+ **self._where("anchor point"),
815
+ )
816
+
817
+ def _editable(self, piece: Piece) -> bool:
818
+ try:
819
+ self._site(piece)
820
+ except DocControlError as exc:
821
+ if exc.code is ErrorCode.UNSUPPORTED_CONTENT:
822
+ return False
823
+ raise
824
+ return True
825
+
826
+ def _props_of(self, run: ET.Element) -> ET.Element | None:
827
+ rpr = run.find(RPR)
828
+ return _clone_rpr(rpr, self.ctx, keep_changes=False) if rpr is not None else None
829
+
830
+ def _mark_props(self) -> ET.Element | None:
831
+ rpr = _mark_rpr(self.p)
832
+ if rpr is None:
833
+ return None
834
+ props = _clone_rpr(rpr, self.ctx, keep_changes=False)
835
+ for child in list(props):
836
+ if child.tag in _MARK_TAGS:
837
+ props.remove(child)
838
+ return props if len(props) else None
839
+
840
+ def delete_content(self) -> None:
841
+ """Track the deletion of every run in the paragraph, hidden ones included."""
842
+ self._require_plain()
843
+ groups: list[list[tuple[ET.Element, ET.Element]]] = []
844
+ broken = True
845
+ for parent, node in _flat(self.p):
846
+ kind = _item_kind(node)
847
+ if kind == "hidden" and W + "commentReference" in _tokens(node):
848
+ raise unsupported(
849
+ "a hidden run holds a comment reference, which cannot be deleted here",
850
+ **self._where("comment reference"),
851
+ )
852
+ if kind in ("text", "hidden"):
853
+ if broken or groups[-1][-1][0] is not parent:
854
+ groups.append([])
855
+ groups[-1].append((parent, node))
856
+ broken = False
857
+ else:
858
+ broken = True
859
+ self._wrap(groups)
860
+
861
+ def detach_bookmarks(self, scope: BookmarkScope) -> None:
862
+ """Remove the markers a move carries away (or drops) from this deleted original."""
863
+ for parent, node in _flat(self.p):
864
+ if node.tag in (BOOKMARK_START, BOOKMARK_END) and scope.owns(node):
865
+ parent.remove(node)
866
+
867
+ def copy_runs(self, scope: BookmarkScope | None = None) -> list[ET.Element]:
868
+ """Copies of the paragraph's runs (for a move), made before it is edited.
869
+
870
+ Bookmark markers come along (same name, fresh id) when ``scope`` says
871
+ the move carries them. A paragraph with a comment reference or a comment
872
+ range marker is refused: copying them would duplicate their ids, and
873
+ leaving them behind would orphan the new text.
874
+ """
875
+ self._require_plain()
876
+ out: list[ET.Element] = []
877
+ for _, node in _flat(self.p):
878
+ if node.tag in (BOOKMARK_START, BOOKMARK_END):
879
+ if scope is None or not scope.owns(node):
880
+ raise unsupported(
881
+ "the paragraph holds a bookmark, which this move cannot carry",
882
+ **self._where(node.get(W + "name") or "bookmark"),
883
+ )
884
+ bid = node.get(ID, "")
885
+ if bid not in scope.dropped:
886
+ clone = copy.deepcopy(node)
887
+ clone.set(ID, scope.new_id(bid))
888
+ out.append(clone)
889
+ continue
890
+ if node.tag in _INERT_ELEMENTS and node.tag != W + "proofErr":
891
+ raise unsupported(
892
+ "the paragraph holds a comment anchor, which a move cannot carry",
893
+ **self._where(node.tag.removeprefix(W)),
894
+ )
895
+ if node.tag != R:
896
+ continue
897
+ kind = _run_kind(node)
898
+ if kind == "inert" and W + "commentReference" in _tokens(node):
899
+ raise unsupported(
900
+ "the paragraph holds a comment reference, which a move cannot carry",
901
+ **self._where("comment reference"),
902
+ )
903
+ if kind in ("text", "hidden"):
904
+ if W + "commentReference" in _tokens(node):
905
+ raise unsupported(
906
+ "the paragraph holds a comment reference, which a move cannot carry",
907
+ **self._where("comment reference"),
908
+ )
909
+ out.append(clone_run(node, self.ctx))
910
+ return out
911
+
912
+ def mark(self, kind: str) -> None:
913
+ set_mark(self.p, kind, self.ctx)
914
+
915
+ def format_paragraph(self, props: dict[str, object]) -> bool:
916
+ """Change paragraph properties as a tracked ``w:pPrChange``; False if nothing changes."""
917
+ return track_paragraph_format(self.p, self.ctx, props, self._where("paragraph properties"))
918
+
919
+ def format_range(self, start: int, end: int, props: dict[str, object]) -> bool:
920
+ """Change run properties of visible characters ``[start, end)`` as ``w:rPrChange``.
921
+
922
+ Runs are split at the ends of the range. Returns False if every run already has
923
+ the properties. The caller has checked the offsets.
924
+ """
925
+ pieces = self.para.pieces_in(start, end)
926
+ if not pieces:
927
+ return False
928
+ sites = [self._site(piece) for piece in pieces]
929
+ first_piece, last_piece = pieces[0], pieces[-1]
930
+ first_parent, first_run, first_k = sites[0]
931
+ last_parent, last_run, last_k = sites[-1]
932
+ left, _ = self._cut(last_parent, last_run, last_k, end - last_piece.start)
933
+ assert left is not None
934
+ last_run = left
935
+ _, right = self._cut(first_parent, first_run, first_k, start - first_piece.start)
936
+ if right is not None:
937
+ first_run = right
938
+ if last_run is sites[0][1]:
939
+ last_run = right
940
+ items = _flat(self.p)
941
+ order = {id(node): i for i, (_, node) in enumerate(items)}
942
+ changed = False
943
+ for _, node in items[order[id(first_run)] : order[id(last_run)] + 1]:
944
+ kind = _item_kind(node)
945
+ if kind == "other":
946
+ raise unsupported(
947
+ "the range covers a field, link, drawing, reference or other structure",
948
+ **self._where(node.tag.removeprefix(W) if isinstance(node.tag, str) else "?"),
949
+ )
950
+ if kind == "text":
951
+ changed |= self._format_run(node, props)
952
+ return changed
953
+
954
+ def _format_run(self, run: ET.Element, props: dict[str, object]) -> bool:
955
+ rpr = run.find(RPR)
956
+ if rpr is not None and rpr.find(W + "rPrChange") is not None:
957
+ raise unsupported(
958
+ "the range holds a run with an unresolved tracked format change",
959
+ **self._where("run"),
960
+ )
961
+ work = copy.deepcopy(rpr) if rpr is not None else ET.Element(RPR)
962
+ if not apply_rpr(work, props):
963
+ return False
964
+ change = self.ctx.revision("rPrChange")
965
+ change.append(copy.deepcopy(rpr) if rpr is not None else ET.Element(RPR))
966
+ work.append(change)
967
+ if rpr is not None:
968
+ run.remove(rpr)
969
+ run.insert(0, work)
970
+ return True
971
+
972
+
973
+ # -- simulating accept and reject (used to verify a write) ----------------------
974
+
975
+
976
+ def _restore_properties(props: ET.Element, change: ET.Element) -> None:
977
+ """Reject a ``w:pPrChange`` / ``w:rPrChange``: the previous properties come back."""
978
+ keep = (RPR, W + "sectPr", W + "pPrChange") if props.tag == PPR else (W + "rPrChange",)
979
+ previous = change.find(PPR if props.tag == PPR else RPR)
980
+ kept = [c for c in props if c.tag in keep and c is not change]
981
+ for child in list(props):
982
+ props.remove(child)
983
+ if previous is not None:
984
+ props.extend(list(previous))
985
+ props.extend(kept)
986
+
987
+
988
+ def reject_revisions(p: ET.Element, ids: set[str]) -> None:
989
+ """Reject, in place, the revisions of ``p`` whose id is in ``ids`` (marks excluded).
990
+
991
+ Inserted content is removed; a deletion is unwrapped and its ``w:delText``
992
+ becomes ``w:t`` again; a ``w:pPrChange`` / ``w:rPrChange`` puts back the properties it holds.
993
+ """
994
+
995
+ for parent in list(p.iter()):
996
+ for child in list(parent):
997
+ if child.tag in (W + "pPrChange", W + "rPrChange") and child.get(ID) in ids:
998
+ _restore_properties(parent, child)
999
+
1000
+ def walk(node: ET.Element) -> None:
1001
+ for child in list(node):
1002
+ if not isinstance(child.tag, str):
1003
+ continue
1004
+ mine = child.get(ID) in ids and node.tag != RPR
1005
+ if child.tag == INS and mine:
1006
+ node.remove(child)
1007
+ elif child.tag == DEL and mine:
1008
+ at = list(node).index(child)
1009
+ node.remove(child)
1010
+ for offset, sub in enumerate(list(child)):
1011
+ for elem in sub.iter():
1012
+ if elem.tag == DELTEXT:
1013
+ elem.tag = T
1014
+ node.insert(at + offset, sub)
1015
+ walk_range(node, at, len(child))
1016
+ else:
1017
+ walk(child)
1018
+
1019
+ def walk_range(node: ET.Element, at: int, count: int) -> None:
1020
+ for sub in list(node)[at : at + count]:
1021
+ if isinstance(sub.tag, str):
1022
+ walk(sub)
1023
+
1024
+ walk(p)