@introspection-ai/recipe-documents-docx 0.0.0-stage → 0.1.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.
@@ -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