@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.
- package/README.md +14 -2
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -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 +47 -3
- 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,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
|