@introspection-ai/recipe-documents-docx 0.0.0-stage → 0.2.0
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.
- package/README.md +56 -2
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/dist/python.d.ts +26 -0
- package/dist/python.d.ts.map +1 -0
- package/dist/python.js +52 -0
- package/dist/python.js.map +1 -0
- package/package.json +48 -3
- package/profiles/legal-redlining-en-us/SKILL.md +55 -0
- package/python/docx_helper.py +55 -0
- package/python/pyproject.toml +10 -0
- package/python/recipe_documents_docx/LICENSE-redline-bench +28 -0
- package/python/recipe_documents_docx/__init__.py +1 -0
- package/python/recipe_documents_docx/anchors.py +186 -0
- package/python/recipe_documents_docx/model.py +279 -0
- package/python/recipe_documents_docx/ops.py +939 -0
- package/python/uv.lock +119 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Resolve a quoted anchor to one place in one paragraph, or say precisely why not.
|
|
2
|
+
|
|
3
|
+
The folding table, the paragraph-scoped search and the structured failure follow RedlineBench's
|
|
4
|
+
redline_engine/anchoring.py and references/anchors.md (https://github.com/crosbylegal/redline-bench,
|
|
5
|
+
commit b29d1113af5d98e2d577f4f17673ebb72834f107, MIT License, Copyright (c) 2026 Crosby Legal; see
|
|
6
|
+
LICENSE-redline-bench). Changes: offsets are mapped back to the raw text so the edit lands on the
|
|
7
|
+
original runs; soft hyphens and zero-width characters are dropped; a leading list number is
|
|
8
|
+
ignored when the quote only matches without it; a failure lists the closest paragraphs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from difflib import SequenceMatcher
|
|
16
|
+
|
|
17
|
+
from .model import Unit
|
|
18
|
+
|
|
19
|
+
FOLD = {
|
|
20
|
+
"‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'",
|
|
21
|
+
"“": '"', "”": '"', "„": '"', "‟": '"', "″": '"',
|
|
22
|
+
"‐": "-", "‑": "-", "‒": "-", "–": "-", "—": "-", "―": "-", "−": "-",
|
|
23
|
+
" ": " ", " ": " ", " ": " ", " ": " ", " ": " ", "\t": " ",
|
|
24
|
+
}
|
|
25
|
+
DROP = {"", "", "", "", "", ""}
|
|
26
|
+
NUMBER_PREFIX = re.compile(
|
|
27
|
+
r"^\s*(?:(?:section|article|clause|schedule|exhibit|annex)\s+)?"
|
|
28
|
+
r"(?:\(?[0-9]{1,3}[a-z]?\)?(?:\.[0-9]{1,3})*\.?|\([a-z]{1,4}\)|[a-z]{1,4}[.)]|[ivxlc]{1,6}[.)])\s+",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fold(text: str, casefold: bool = False) -> tuple[str, list[int]]:
|
|
34
|
+
"""Text normalized for matching, and for each folded character the raw index it came from."""
|
|
35
|
+
out: list[str] = []
|
|
36
|
+
index: list[int] = []
|
|
37
|
+
space = False
|
|
38
|
+
for i, ch in enumerate(text):
|
|
39
|
+
if ch in DROP:
|
|
40
|
+
continue
|
|
41
|
+
ch = FOLD.get(ch, ch)
|
|
42
|
+
if ch.isspace():
|
|
43
|
+
if space:
|
|
44
|
+
continue
|
|
45
|
+
ch, space = " ", True
|
|
46
|
+
else:
|
|
47
|
+
space = False
|
|
48
|
+
if casefold:
|
|
49
|
+
lower = ch.lower()
|
|
50
|
+
ch = lower if len(lower) == 1 else ch
|
|
51
|
+
out.append(ch)
|
|
52
|
+
index.append(i)
|
|
53
|
+
return "".join(out), index
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Located:
|
|
58
|
+
unit: Unit
|
|
59
|
+
start: int # raw offsets into unit.text
|
|
60
|
+
end: int
|
|
61
|
+
notes: list[str] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def text(self) -> str:
|
|
65
|
+
return self.unit.text[self.start:self.end]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AnchorFailure(Exception):
|
|
69
|
+
def __init__(self, reason: str, message: str, candidates: list[dict] | None = None):
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.reason = reason
|
|
72
|
+
self.candidates = candidates or []
|
|
73
|
+
|
|
74
|
+
def as_dict(self) -> dict:
|
|
75
|
+
found = {"reason": self.reason, "message": str(self)}
|
|
76
|
+
if self.candidates:
|
|
77
|
+
found["candidates"] = self.candidates
|
|
78
|
+
return found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def occurrences(unit: Unit, needle: str, casefold: bool) -> list[tuple[int, int]]:
|
|
82
|
+
hay, index = fold(unit.text, casefold)
|
|
83
|
+
found = []
|
|
84
|
+
start = hay.find(needle)
|
|
85
|
+
while start != -1 and needle:
|
|
86
|
+
end = start + len(needle)
|
|
87
|
+
found.append((index[start], index[end - 1] + 1))
|
|
88
|
+
start = hay.find(needle, end)
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def snippet(text: str, limit: int = 160) -> str:
|
|
93
|
+
text = " ".join(text.split())
|
|
94
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def closest(units: list[Unit], quote: str, limit: int = 3) -> list[dict]:
|
|
98
|
+
"""The paragraphs whose text is most like the quote, with the most similar stretch of each."""
|
|
99
|
+
needle, _ = fold(quote.strip(), casefold=True)
|
|
100
|
+
if not needle:
|
|
101
|
+
return []
|
|
102
|
+
scored = []
|
|
103
|
+
for unit in units:
|
|
104
|
+
hay, index = fold(unit.text, casefold=True)
|
|
105
|
+
if len(hay) < 3:
|
|
106
|
+
continue
|
|
107
|
+
matcher = SequenceMatcher(None, needle, hay, autojunk=False)
|
|
108
|
+
block = matcher.find_longest_match(0, len(needle), 0, len(hay))
|
|
109
|
+
if block.size < min(6, len(needle)):
|
|
110
|
+
continue
|
|
111
|
+
start = max(0, block.b - block.a)
|
|
112
|
+
end = min(len(hay), start + len(needle))
|
|
113
|
+
ratio = SequenceMatcher(None, needle, hay[start:end], autojunk=False).ratio()
|
|
114
|
+
if ratio >= 0.45:
|
|
115
|
+
raw = unit.text[index[start]: index[end - 1] + 1]
|
|
116
|
+
scored.append((ratio, unit.id, raw))
|
|
117
|
+
scored.sort(key=lambda item: -item[0])
|
|
118
|
+
return [{"paragraph": pid, "text": snippet(raw), "similarity": round(r, 2)} for r, pid, raw in scored[:limit]]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def locate(units: list[Unit], quote: str, paragraph: str | None = None, near: str | None = None,
|
|
122
|
+
occurrence: int | None = None) -> Located:
|
|
123
|
+
"""One place for the quote, or an AnchorFailure naming the reason and the closest candidates."""
|
|
124
|
+
if not quote or not quote.strip():
|
|
125
|
+
raise AnchorFailure("empty_quote", "quote is empty")
|
|
126
|
+
pool = units
|
|
127
|
+
if paragraph:
|
|
128
|
+
pool = [u for u in units if u.id == paragraph or u.id.startswith(f"{paragraph}.")]
|
|
129
|
+
if not pool:
|
|
130
|
+
raise AnchorFailure("paragraph_not_found", f"there is no paragraph {paragraph}: use an id from read")
|
|
131
|
+
|
|
132
|
+
attempts: list[tuple[str, bool, str | None]] = []
|
|
133
|
+
folded, _ = fold(quote.strip())
|
|
134
|
+
attempts.append((folded, False, None))
|
|
135
|
+
attempts.append((fold(quote.strip(), True)[0], True, "matched ignoring case"))
|
|
136
|
+
stripped = NUMBER_PREFIX.sub("", quote.strip(), count=1)
|
|
137
|
+
if stripped and stripped != quote.strip() and len(stripped) >= 4:
|
|
138
|
+
note = f"ignored the list number {quote.strip()[: len(quote.strip()) - len(stripped)].strip()!r}"
|
|
139
|
+
attempts.append((fold(stripped)[0], False, note))
|
|
140
|
+
attempts.append((fold(stripped, True)[0], True, note + ", matched ignoring case"))
|
|
141
|
+
|
|
142
|
+
for needle, casefold, note in attempts:
|
|
143
|
+
matches = [(u, s, e) for u in pool for s, e in occurrences(u, needle, casefold)]
|
|
144
|
+
if not matches:
|
|
145
|
+
continue
|
|
146
|
+
notes = [note] if note else []
|
|
147
|
+
if near and len(matches) > 1:
|
|
148
|
+
near_folded = fold(near.strip(), True)[0]
|
|
149
|
+
ranked = []
|
|
150
|
+
for u, s, e in matches:
|
|
151
|
+
hay, index = fold(u.text, True)
|
|
152
|
+
positions = [index[m.start()] for m in re.finditer(re.escape(near_folded), hay)] if near_folded else []
|
|
153
|
+
if positions:
|
|
154
|
+
ranked.append((min(abs(p - s) for p in positions), u, s, e))
|
|
155
|
+
if ranked:
|
|
156
|
+
ranked.sort(key=lambda item: item[0])
|
|
157
|
+
best = [item for item in ranked if item[0] == ranked[0][0]]
|
|
158
|
+
if len(best) == 1:
|
|
159
|
+
_, u, s, e = best[0]
|
|
160
|
+
return Located(u, s, e, notes)
|
|
161
|
+
matches = [(u, s, e) for _, u, s, e in best]
|
|
162
|
+
if occurrence:
|
|
163
|
+
if occurrence > len(matches):
|
|
164
|
+
raise AnchorFailure(
|
|
165
|
+
"occurrence_out_of_range",
|
|
166
|
+
f"the quote matches {len(matches)} time(s); occurrence {occurrence} doesn't exist",
|
|
167
|
+
[{"paragraph": u.id, "text": snippet(u.text[max(0, s - 40): e + 40])} for u, s, e in matches[:5]],
|
|
168
|
+
)
|
|
169
|
+
u, s, e = matches[occurrence - 1]
|
|
170
|
+
return Located(u, s, e, notes)
|
|
171
|
+
if len(matches) > 1:
|
|
172
|
+
raise AnchorFailure(
|
|
173
|
+
"ambiguous",
|
|
174
|
+
f"the quote matches {len(matches)} places: add paragraph, near or occurrence",
|
|
175
|
+
[{"paragraph": u.id, "text": snippet(u.text[max(0, s - 40): e + 40])} for u, s, e in matches[:5]],
|
|
176
|
+
)
|
|
177
|
+
u, s, e = matches[0]
|
|
178
|
+
return Located(u, s, e, notes)
|
|
179
|
+
|
|
180
|
+
where = f"paragraph {paragraph}" if paragraph else "the document"
|
|
181
|
+
raise AnchorFailure(
|
|
182
|
+
"not_found",
|
|
183
|
+
f"the quote isn't in {where} (after forgiving whitespace, quotes, dashes and case): copy it from the "
|
|
184
|
+
"current text in read; it may already include an earlier edit",
|
|
185
|
+
closest(units, quote),
|
|
186
|
+
)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""The Word document as the `documents` tool shows it: paragraphs with stable ids and their current text.
|
|
2
|
+
|
|
3
|
+
Paragraph ids are body-block positions: `p-012` is the 13th block of the body, and a paragraph in a
|
|
4
|
+
table cell is `p-012.r2c3` (`.2` after it for the cell's second paragraph, a nested table's included). A block-level content
|
|
5
|
+
control is one block: its paragraphs are `p-012`, `p-012.2`, `p-012.3` and so on. Edits never add or remove
|
|
6
|
+
blocks, so the ids hold for the whole redline.
|
|
7
|
+
|
|
8
|
+
The inline markup walk and the margin, change and thread listings are from recipe-redlining's
|
|
9
|
+
lib/view_docx.py and skills/reading-a-redline/read_markup.py (Apache-2.0, Introspection).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from itertools import groupby
|
|
17
|
+
|
|
18
|
+
from docx.commentops import comment_thread
|
|
19
|
+
from docx.numbering import list_numbering
|
|
20
|
+
|
|
21
|
+
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
22
|
+
ADDED = {f"{W}ins": "+", f"{W}moveTo": "+"}
|
|
23
|
+
REMOVED = {f"{W}del": "-", f"{W}moveFrom": "-"}
|
|
24
|
+
CONTAINERS = (f"{W}hyperlink", f"{W}smartTag", f"{W}sdt", f"{W}sdtContent", f"{W}fldSimple", f"{W}customXml")
|
|
25
|
+
BODY = "word/document.xml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Unit:
|
|
30
|
+
"""One paragraph: its id, the live element, Word's list label and the text a quote matches."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
element: object
|
|
34
|
+
text: str
|
|
35
|
+
style: str = ""
|
|
36
|
+
label: str = ""
|
|
37
|
+
marked: bool = False
|
|
38
|
+
extras: dict = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def signed(author, date) -> str:
|
|
42
|
+
return f"{author or 'unknown author'}, {str(date or 'no date')[:10]}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_text(run) -> str:
|
|
46
|
+
parts = []
|
|
47
|
+
for node in run:
|
|
48
|
+
if node.tag in (f"{W}t", f"{W}delText"):
|
|
49
|
+
parts.append(node.text or "")
|
|
50
|
+
elif node.tag == f"{W}tab":
|
|
51
|
+
parts.append("\t")
|
|
52
|
+
elif node.tag in (f"{W}br", f"{W}cr"):
|
|
53
|
+
parts.append("\n")
|
|
54
|
+
elif node.tag == f"{W}noBreakHyphen":
|
|
55
|
+
parts.append("-")
|
|
56
|
+
return "".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def current_text(element) -> str:
|
|
60
|
+
"""The paragraph as it reads with every tracked change accepted: what a quote is matched against."""
|
|
61
|
+
parts = []
|
|
62
|
+
for node in element:
|
|
63
|
+
if node.tag == f"{W}r":
|
|
64
|
+
parts.append(run_text(node))
|
|
65
|
+
elif node.tag in ADDED or node.tag in CONTAINERS:
|
|
66
|
+
parts.append(current_text(node))
|
|
67
|
+
return "".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
INLINE_COMMENT = 60
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def walk(element, comments: dict, seen: set) -> str:
|
|
74
|
+
"""Read only: the order of runs, changes and comment ranges in a paragraph."""
|
|
75
|
+
parts = []
|
|
76
|
+
for node in element:
|
|
77
|
+
if node.tag == f"{W}r":
|
|
78
|
+
parts.append(run_text(node))
|
|
79
|
+
elif node.tag in ADDED or node.tag in REMOVED:
|
|
80
|
+
mark = ADDED.get(node.tag) or REMOVED[node.tag]
|
|
81
|
+
who = signed(node.get(f"{W}author"), node.get(f"{W}date"))
|
|
82
|
+
parts.append(f"[{mark}{walk(node, comments, seen)}{mark}]({who})")
|
|
83
|
+
elif node.tag == f"{W}commentRangeStart":
|
|
84
|
+
parts.append("«")
|
|
85
|
+
elif node.tag == f"{W}commentRangeEnd":
|
|
86
|
+
number = int(node.get(f"{W}id"))
|
|
87
|
+
seen.add(number)
|
|
88
|
+
comment = comments.get(number)
|
|
89
|
+
who = signed(comment.author, comment.timestamp) if comment else "unknown author"
|
|
90
|
+
body = " ".join((comment.text if comment else "").split())
|
|
91
|
+
# The full text is in the thread listing, which pages; inline keeps a paragraph's line bounded.
|
|
92
|
+
if len(body) > INLINE_COMMENT:
|
|
93
|
+
body = body[:INLINE_COMMENT].rstrip() + "… (see its thread)"
|
|
94
|
+
parts.append(f"»[comment {number}, {who}: {body}]")
|
|
95
|
+
elif node.tag in CONTAINERS:
|
|
96
|
+
parts.append(walk(node, comments, seen))
|
|
97
|
+
return "".join(parts)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def has_markup(element) -> bool:
|
|
101
|
+
for tag in (*ADDED, *REMOVED, f"{W}commentRangeStart", f"{W}commentReference"):
|
|
102
|
+
if next(element.iter(tag), None) is not None:
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
ROMAN = [(1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i")]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def formatted(number: int, fmt: str | None) -> str:
|
|
111
|
+
if fmt in ("lowerLetter", "upperLetter"):
|
|
112
|
+
letters = ""
|
|
113
|
+
n = number
|
|
114
|
+
while n > 0:
|
|
115
|
+
n, rest = divmod(n - 1, 26)
|
|
116
|
+
letters = chr(97 + rest) + letters
|
|
117
|
+
return letters.upper() if fmt == "upperLetter" else letters
|
|
118
|
+
if fmt in ("lowerRoman", "upperRoman"):
|
|
119
|
+
out, n = "", number
|
|
120
|
+
for value, digits in ROMAN:
|
|
121
|
+
while n >= value:
|
|
122
|
+
out, n = out + digits, n - value
|
|
123
|
+
return out.upper() if fmt == "upperRoman" else out
|
|
124
|
+
if fmt in ("bullet", "none"):
|
|
125
|
+
return ""
|
|
126
|
+
return str(number)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def list_labels(document) -> dict[tuple, str]:
|
|
130
|
+
"""Approximate list numbers ("1.2.") by block position. Restarts and overrides are not modelled."""
|
|
131
|
+
try:
|
|
132
|
+
report = list_numbering(document)
|
|
133
|
+
except Exception: # an unreadable numbering part only costs the labels
|
|
134
|
+
return {}
|
|
135
|
+
definitions = {d.num_id: d for d in report.definitions}
|
|
136
|
+
counters: dict[int, list[int]] = {}
|
|
137
|
+
labels: dict[tuple, str] = {}
|
|
138
|
+
for paragraph in report.numbered_paragraphs:
|
|
139
|
+
if paragraph.story != BODY:
|
|
140
|
+
continue
|
|
141
|
+
definition = definitions.get(paragraph.num_id)
|
|
142
|
+
if definition is None:
|
|
143
|
+
continue
|
|
144
|
+
levels = {level.level: level for level in definition.levels}
|
|
145
|
+
level = levels.get(paragraph.level)
|
|
146
|
+
if level is None or level.num_fmt in ("bullet", "none"):
|
|
147
|
+
continue
|
|
148
|
+
counts = counters.setdefault(definition.abstract_num_id, [0] * 9)
|
|
149
|
+
counts[paragraph.level] += 1
|
|
150
|
+
for deeper in range(paragraph.level + 1, 9):
|
|
151
|
+
counts[deeper] = 0
|
|
152
|
+
text = level.lvl_text or ""
|
|
153
|
+
for n in range(1, 10):
|
|
154
|
+
source = levels.get(n - 1)
|
|
155
|
+
value = counts[n - 1] or 1
|
|
156
|
+
text = text.replace(f"%{n}", formatted(value, source.num_fmt if source else "decimal"))
|
|
157
|
+
key = (paragraph.index, paragraph.table_cell) if paragraph.table_cell else (paragraph.index,)
|
|
158
|
+
labels[key] = text.strip()
|
|
159
|
+
return labels
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def units(document, labels: bool = False) -> list[Unit]:
|
|
163
|
+
"""Every body paragraph, table cells included, in reading order. List labels only for read: they cost a numbering walk."""
|
|
164
|
+
labels = list_labels(document) if labels else {}
|
|
165
|
+
out: list[Unit] = []
|
|
166
|
+
body = document.element.body
|
|
167
|
+
index = -1
|
|
168
|
+
for block in body.iterchildren():
|
|
169
|
+
if block.tag == f"{W}p":
|
|
170
|
+
index += 1
|
|
171
|
+
style = style_name(document, block)
|
|
172
|
+
out.append(Unit(f"p-{index:03d}", block, current_text(block), style, labels.get((index,), ""), has_markup(block)))
|
|
173
|
+
elif block.tag == f"{W}tbl":
|
|
174
|
+
index += 1
|
|
175
|
+
# Only this table's own rows and cells: a table nested in a cell is part of that cell, and its
|
|
176
|
+
# paragraphs follow the cell's own as `.2`, `.3` ..., each visited once.
|
|
177
|
+
for r, row in enumerate(direct(block, f"{W}tr"), 1):
|
|
178
|
+
for c, cell in enumerate(direct(row, f"{W}tc"), 1):
|
|
179
|
+
for k, p in enumerate(cell.iter(f"{W}p"), 1):
|
|
180
|
+
suffix = f".{k}" if k > 1 else ""
|
|
181
|
+
out.append(Unit(f"p-{index:03d}.r{r}c{c}{suffix}", p, current_text(p), "", "", has_markup(p)))
|
|
182
|
+
elif block.tag == f"{W}sdt":
|
|
183
|
+
index += 1
|
|
184
|
+
# A block-level content control can hold several paragraphs: each gets the control's id,
|
|
185
|
+
# with `.2`, `.3` ... after it for the second and later, as in a table cell.
|
|
186
|
+
for k, p in enumerate(block.iter(f"{W}p"), 1):
|
|
187
|
+
suffix = f".{k}" if k > 1 else ""
|
|
188
|
+
out.append(Unit(f"p-{index:03d}{suffix}", p, current_text(p), style_name(document, p), "", has_markup(p)))
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def direct(parent, tag: str):
|
|
193
|
+
"""`parent`'s own `tag` children, through any content control or custom XML wrapping them, not deeper."""
|
|
194
|
+
for child in parent.iterchildren():
|
|
195
|
+
if child.tag == tag:
|
|
196
|
+
yield child
|
|
197
|
+
elif child.tag in (f"{W}sdt", f"{W}sdtContent", f"{W}customXml"):
|
|
198
|
+
yield from direct(child, tag)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def style_name(document, element) -> str:
|
|
202
|
+
p_pr = element.find(f"{W}pPr")
|
|
203
|
+
style = p_pr.find(f"{W}pStyle") if p_pr is not None else None
|
|
204
|
+
if style is None:
|
|
205
|
+
return ""
|
|
206
|
+
style_id = style.get(f"{W}val")
|
|
207
|
+
try:
|
|
208
|
+
return document.styles.get_by_id(style_id, 1).name or style_id # WD_STYLE_TYPE.PARAGRAPH
|
|
209
|
+
except Exception:
|
|
210
|
+
return style_id or ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def margin_lines(document, kind: str) -> list[str]:
|
|
214
|
+
even_pages = document.settings.odd_and_even_pages_header_footer
|
|
215
|
+
lines = []
|
|
216
|
+
shown = set()
|
|
217
|
+
for number, section in enumerate(document.sections, 1):
|
|
218
|
+
where = f", section {number}" if number > 1 else ""
|
|
219
|
+
variants = [("", getattr(section, kind))]
|
|
220
|
+
if section.different_first_page_header_footer:
|
|
221
|
+
variants.append(("first-page ", getattr(section, f"first_page_{kind}")))
|
|
222
|
+
if even_pages:
|
|
223
|
+
variants.append(("even-page ", getattr(section, f"even_page_{kind}")))
|
|
224
|
+
for variant, margin in variants:
|
|
225
|
+
if margin.part.partname not in shown:
|
|
226
|
+
shown.add(margin.part.partname)
|
|
227
|
+
lines += [f"[{variant}{kind}{where}] {p.text}" for p in margin.paragraphs if p.text.strip()]
|
|
228
|
+
return lines
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def day(value) -> str:
|
|
232
|
+
return str(value or "no date")[:10]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def change_lines(document) -> list[str]:
|
|
236
|
+
"""Word splits one edit into many revisions, so neighbours of one kind and author read as one."""
|
|
237
|
+
|
|
238
|
+
def key(item):
|
|
239
|
+
_, r = item
|
|
240
|
+
return r.revision_type, r.author, r.story, r.anchor.index, r.is_paragraph_mark
|
|
241
|
+
|
|
242
|
+
lines = []
|
|
243
|
+
for (kind, author, story, _, mark), group in groupby(enumerate(document.revisions), key):
|
|
244
|
+
group = list(group)
|
|
245
|
+
first, last = group[0][0], group[-1][0]
|
|
246
|
+
number = str(first) if first == last else f"{first}-{last}"
|
|
247
|
+
text = "(paragraph mark)" if mark else "".join(r.text for _, r in group)
|
|
248
|
+
where = "" if story == BODY else f" in {story}"
|
|
249
|
+
lines.append(f"{number} {kind}{where}, {author}, {day(group[0][1].date)}: {text}")
|
|
250
|
+
return lines
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def thread_lines(document) -> list[str]:
|
|
254
|
+
comments = comment_thread(document)
|
|
255
|
+
parents = {c["comment_id"]: c["parent_id"] for c in comments}
|
|
256
|
+
|
|
257
|
+
def root(number):
|
|
258
|
+
while parents.get(number) is not None:
|
|
259
|
+
number = parents[number]
|
|
260
|
+
return number
|
|
261
|
+
|
|
262
|
+
lines = []
|
|
263
|
+
for comment in comments:
|
|
264
|
+
if comment["parent_id"] is None:
|
|
265
|
+
state = "resolved" if comment["resolved"] else "open"
|
|
266
|
+
anchor = " ".join((comment.get("anchored_text") or "").split())[:120]
|
|
267
|
+
lines.append(f"thread {comment['comment_id']} ({state}) on: {anchor}")
|
|
268
|
+
for entry in comments:
|
|
269
|
+
if root(entry["comment_id"]) == comment["comment_id"]:
|
|
270
|
+
found = document.comments.get(entry["comment_id"])
|
|
271
|
+
when = day(found.timestamp if found is not None else None)
|
|
272
|
+
lines.append(f" {entry['comment_id']}, {entry['author']}, {when}: {entry['text'].strip()}")
|
|
273
|
+
return lines
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def census(document) -> Counter:
|
|
277
|
+
counts = Counter(f"{r.author}: {r.revision_type}" for r in document.revisions)
|
|
278
|
+
counts.update(f"{c['author']}: comment" for c in comment_thread(document))
|
|
279
|
+
return counts
|