athena-python-docx 0.1.3__tar.gz → 0.1.5__tar.gz
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.
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/PKG-INFO +1 -1
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/__init__.py +1 -1
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/document.py +114 -94
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/table.py +77 -63
- athena_python_docx-0.1.5/docx/text/paragraph.py +152 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/text/run.py +41 -56
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/pyproject.toml +1 -1
- athena_python_docx-0.1.3/docx/text/paragraph.py +0 -141
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/.gitignore +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/CLAUDE.md +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/README.md +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/_batching.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/api.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/client.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/enum/__init__.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/enum/table.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/enum/text.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/errors.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/shared.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/text/__init__.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/docx/typing.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/scripts/publish.sh +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/tests/__init__.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/tests/conftest.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/tests/test_commands.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/tests/test_python_docx_api_parity.py +0 -0
- {athena_python_docx-0.1.3 → athena_python_docx-0.1.5}/tests/test_smoke_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: athena-python-docx
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Drop-in replacement for python-docx that connects to Athena's Superdoc/Keryx collaborative document stack
|
|
5
5
|
Project-URL: Homepage, https://athenaintelligence.ai
|
|
6
6
|
Author-email: Athena Intelligence <engineering@athenaintelligence.ai>
|
|
@@ -51,19 +51,37 @@ class Document:
|
|
|
51
51
|
def paragraphs(self) -> list["Paragraph"]:
|
|
52
52
|
"""Return all paragraphs in document order.
|
|
53
53
|
|
|
54
|
-
python-docx returns a live list; we return a snapshot.
|
|
55
|
-
|
|
54
|
+
python-docx returns a live list; we return a snapshot. For a
|
|
55
|
+
live-ish list, call this again.
|
|
56
56
|
"""
|
|
57
57
|
from docx.text.paragraph import Paragraph
|
|
58
58
|
|
|
59
59
|
self._ensure_open()
|
|
60
|
-
blocks: list[
|
|
61
|
-
|
|
60
|
+
# doc.blocks.list takes `nodeTypes: list[str]` (not `type: str`).
|
|
61
|
+
# Include "heading" too so iterating over paragraphs covers both —
|
|
62
|
+
# python-docx does not distinguish headings from paragraphs at the
|
|
63
|
+
# Document.paragraphs level either (they're all Paragraph instances).
|
|
64
|
+
raw: object = run_sync(
|
|
65
|
+
self._session.doc.blocks.list(
|
|
66
|
+
{"nodeTypes": ["paragraph", "heading"], "includeText": True},
|
|
67
|
+
),
|
|
62
68
|
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
# Real shape: {"blocks": [{"kind":"block","nodeType":..,"nodeId":..,"text":..}], ...}
|
|
70
|
+
if isinstance(raw, dict):
|
|
71
|
+
blocks_obj: object = raw.get("blocks", [])
|
|
72
|
+
blocks: list = blocks_obj if isinstance(blocks_obj, list) else []
|
|
73
|
+
elif isinstance(raw, list):
|
|
74
|
+
blocks = raw
|
|
75
|
+
else:
|
|
76
|
+
blocks = []
|
|
77
|
+
out: list["Paragraph"] = []
|
|
78
|
+
for b in blocks:
|
|
79
|
+
if not isinstance(b, dict):
|
|
80
|
+
continue
|
|
81
|
+
node_id: str = str(b.get("nodeId", ""))
|
|
82
|
+
if node_id:
|
|
83
|
+
out.append(Paragraph(session=self._session, node_id=node_id))
|
|
84
|
+
return out
|
|
67
85
|
|
|
68
86
|
@property
|
|
69
87
|
def tables(self) -> list["Table"]:
|
|
@@ -101,48 +119,29 @@ class Document:
|
|
|
101
119
|
|
|
102
120
|
self._ensure_open()
|
|
103
121
|
|
|
104
|
-
# Superdoc's
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
else:
|
|
129
|
-
md: str
|
|
130
|
-
if style and style.lower().startswith("heading"):
|
|
131
|
-
try:
|
|
132
|
-
level: int = int(style.rsplit(" ", 1)[-1])
|
|
133
|
-
except ValueError:
|
|
134
|
-
level = 1
|
|
135
|
-
md = f"{'#' * level} {text}\n\n"
|
|
136
|
-
else:
|
|
137
|
-
md = f"{text}\n\n"
|
|
138
|
-
|
|
139
|
-
result = run_sync(
|
|
140
|
-
self._session.doc.insert(
|
|
141
|
-
{"value": md, "type": "markdown"},
|
|
142
|
-
),
|
|
143
|
-
)
|
|
144
|
-
node_id = _extract_inserted_node_id(result, expected_type="paragraph")
|
|
145
|
-
|
|
122
|
+
# Use Superdoc's canonical primitive `doc.create.paragraph`. It
|
|
123
|
+
# accepts an `at` anchor and a `text` body; an empty `text` creates
|
|
124
|
+
# a truly-empty paragraph (no markdown-parser edge case).
|
|
125
|
+
#
|
|
126
|
+
# For heading styles we still dispatch to add_heading, which uses
|
|
127
|
+
# `doc.create.heading` (also canonical, takes `level`).
|
|
128
|
+
if style and style.lower().startswith("heading"):
|
|
129
|
+
try:
|
|
130
|
+
level: int = int(style.rsplit(" ", 1)[-1])
|
|
131
|
+
except ValueError:
|
|
132
|
+
level = 1
|
|
133
|
+
return self.add_heading(text=text, level=level)
|
|
134
|
+
if style and style.lower() == "title":
|
|
135
|
+
return self.add_heading(text=text, level=0)
|
|
136
|
+
|
|
137
|
+
params: dict = {
|
|
138
|
+
"text": text,
|
|
139
|
+
"at": {"kind": "documentEnd"},
|
|
140
|
+
}
|
|
141
|
+
result: dict = run_sync(
|
|
142
|
+
self._session.doc.create.paragraph(params),
|
|
143
|
+
)
|
|
144
|
+
node_id: str = _extract_inserted_node_id(result, expected_type="paragraph")
|
|
146
145
|
if not node_id:
|
|
147
146
|
raise RuntimeError(
|
|
148
147
|
f"Superdoc did not return a nodeId for add_paragraph: {result!r}",
|
|
@@ -154,13 +153,33 @@ class Document:
|
|
|
154
153
|
text: str = "",
|
|
155
154
|
level: int = 1,
|
|
156
155
|
) -> "Paragraph":
|
|
157
|
-
"""Append a heading
|
|
156
|
+
"""Append a heading via Superdoc's canonical `doc.create.heading`."""
|
|
157
|
+
from docx.text.paragraph import Paragraph
|
|
158
|
+
|
|
159
|
+
self._ensure_open()
|
|
158
160
|
if not 0 <= level <= 9:
|
|
159
161
|
raise ValidationError(
|
|
160
162
|
f"level must be in 0..9; got {level}",
|
|
161
163
|
)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
|
|
165
|
+
# Map level 0 → Title, 1..9 → Heading N. create.heading understands
|
|
166
|
+
# level directly (level=0 means Title in python-docx semantics; Superdoc
|
|
167
|
+
# accepts levels 1..9 for HeadingN and may interpret 0 as Title depending
|
|
168
|
+
# on version — we pass level as-is).
|
|
169
|
+
params: dict = {
|
|
170
|
+
"text": text,
|
|
171
|
+
"level": level if level >= 1 else 1,
|
|
172
|
+
"at": {"kind": "documentEnd"},
|
|
173
|
+
}
|
|
174
|
+
result: dict = run_sync(
|
|
175
|
+
self._session.doc.create.heading(params),
|
|
176
|
+
)
|
|
177
|
+
node_id: str = _extract_inserted_node_id(result, expected_type="paragraph")
|
|
178
|
+
if not node_id:
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f"Superdoc did not return a nodeId for add_heading: {result!r}",
|
|
181
|
+
)
|
|
182
|
+
return Paragraph(session=self._session, node_id=node_id)
|
|
164
183
|
|
|
165
184
|
def add_table(
|
|
166
185
|
self,
|
|
@@ -168,7 +187,7 @@ class Document:
|
|
|
168
187
|
cols: int,
|
|
169
188
|
style: str | None = None,
|
|
170
189
|
) -> "Table":
|
|
171
|
-
"""Append a table with the given dimensions."""
|
|
190
|
+
"""Append a table with the given dimensions via `doc.create.table`."""
|
|
172
191
|
from docx.table import Table
|
|
173
192
|
|
|
174
193
|
self._ensure_open()
|
|
@@ -177,18 +196,18 @@ class Document:
|
|
|
177
196
|
f"rows and cols must be >= 1; got rows={rows} cols={cols}",
|
|
178
197
|
)
|
|
179
198
|
|
|
199
|
+
# The real param is `columns` (not `cols`). `at` is required to
|
|
200
|
+
# position the table; we always append to document end.
|
|
180
201
|
result: dict = run_sync(
|
|
181
202
|
self._session.doc.create.table(
|
|
182
|
-
{
|
|
203
|
+
{
|
|
204
|
+
"rows": rows,
|
|
205
|
+
"columns": cols,
|
|
206
|
+
"at": {"kind": "documentEnd"},
|
|
207
|
+
},
|
|
183
208
|
),
|
|
184
209
|
)
|
|
185
|
-
|
|
186
|
-
result.get("result", {}) if isinstance(result, dict) else {}
|
|
187
|
-
)
|
|
188
|
-
table_info: dict = (
|
|
189
|
-
result_data.get("table", {}) if isinstance(result_data, dict) else {}
|
|
190
|
-
)
|
|
191
|
-
node_id: str = str(table_info.get("nodeId", ""))
|
|
210
|
+
node_id: str = _extract_inserted_node_id(result, expected_type="table")
|
|
192
211
|
if not node_id:
|
|
193
212
|
raise RuntimeError(
|
|
194
213
|
f"Superdoc did not return a nodeId for add_table: {result!r}",
|
|
@@ -201,9 +220,7 @@ class Document:
|
|
|
201
220
|
),
|
|
202
221
|
)
|
|
203
222
|
# Re-fetch nodeId — set_style may rotate it. Prefer matching the
|
|
204
|
-
# original id
|
|
205
|
-
# don't cause us to pick up the wrong table. Only fall back to
|
|
206
|
-
# last-in-list (with a warning) when the original id is gone.
|
|
223
|
+
# original id; fall back to last-in-list with a warning.
|
|
207
224
|
refetch: dict = run_sync(
|
|
208
225
|
self._session.doc.find({"type": "table"}),
|
|
209
226
|
)
|
|
@@ -233,7 +250,7 @@ class Document:
|
|
|
233
250
|
width: "Emu | int | None" = None,
|
|
234
251
|
height: "Emu | int | None" = None,
|
|
235
252
|
) -> None:
|
|
236
|
-
"""Append an inline image
|
|
253
|
+
"""Append an inline image via `doc.create.image`."""
|
|
237
254
|
self._ensure_open()
|
|
238
255
|
|
|
239
256
|
image_bytes: bytes
|
|
@@ -259,9 +276,6 @@ class Document:
|
|
|
259
276
|
b64: str = base64.b64encode(image_bytes).decode("ascii")
|
|
260
277
|
data_uri: str = f"data:{content_type};base64,{b64}"
|
|
261
278
|
|
|
262
|
-
# Default 6-inch wide, proportional 4.5-inch tall at 96 DPI
|
|
263
|
-
# (python-docx uses image's intrinsic size; we don't have that here,
|
|
264
|
-
# so we default to a reasonable 6x4.5 inch rectangle)
|
|
265
279
|
w_px: float = _emu_to_px(width) if width is not None else 576.0
|
|
266
280
|
h_px: float = _emu_to_px(height) if height is not None else 432.0
|
|
267
281
|
|
|
@@ -276,13 +290,16 @@ class Document:
|
|
|
276
290
|
)
|
|
277
291
|
|
|
278
292
|
def add_page_break(self) -> None:
|
|
279
|
-
"""Append a page break at the end of the document.
|
|
293
|
+
"""Append a page break at the end of the document.
|
|
294
|
+
|
|
295
|
+
Uses `doc.create.section_break` with OOXML `breakType="nextPage"`,
|
|
296
|
+
which is the canonical hard page break (what python-docx's
|
|
297
|
+
``add_page_break()`` produces).
|
|
298
|
+
"""
|
|
280
299
|
self._ensure_open()
|
|
281
|
-
# Use form-feed character; Superdoc's markdown parser converts
|
|
282
|
-
# this to a <w:br w:type="page"/> in export.
|
|
283
300
|
run_sync(
|
|
284
|
-
self._session.doc.
|
|
285
|
-
{"
|
|
301
|
+
self._session.doc.create.section_break(
|
|
302
|
+
{"at": {"kind": "documentEnd"}, "breakType": "nextPage"},
|
|
286
303
|
),
|
|
287
304
|
)
|
|
288
305
|
|
|
@@ -333,29 +350,32 @@ class Document:
|
|
|
333
350
|
# ---- Module-level helpers ----
|
|
334
351
|
|
|
335
352
|
def _extract_inserted_node_id(result: dict, *, expected_type: str) -> str:
|
|
336
|
-
"""Parse Superdoc
|
|
337
|
-
|
|
338
|
-
Superdoc rotates response shapes across versions; we probe in order
|
|
339
|
-
of "most current" first. Real observed shape (superdoc-sdk 1.6.0.dev29):
|
|
340
|
-
|
|
341
|
-
{
|
|
342
|
-
"receipt": {
|
|
343
|
-
"resolution": {
|
|
344
|
-
"target": {"kind": "text", "blockId": "<uuid>", "range": {...}}
|
|
345
|
-
}
|
|
346
|
-
},
|
|
347
|
-
...
|
|
348
|
-
}
|
|
353
|
+
"""Parse Superdoc create/insert response to find the new node's blockId.
|
|
349
354
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
355
|
+
Real observed shapes (superdoc-sdk 1.6.0.dev29):
|
|
356
|
+
|
|
357
|
+
doc.create.paragraph/heading/table returns
|
|
358
|
+
{"success": True, "<paragraph|heading|table>": {"nodeId": "..."}}
|
|
359
|
+
|
|
360
|
+
doc.insert (markdown/text) returns
|
|
361
|
+
{"receipt": {"resolution": {"target": {"blockId": "..."}}}, ...}
|
|
362
|
+
|
|
363
|
+
We probe create-shape first (nested by expected_type), then insert-shape.
|
|
364
|
+
Returns "" if nothing matches.
|
|
354
365
|
"""
|
|
355
366
|
if not isinstance(result, dict):
|
|
356
367
|
return ""
|
|
357
368
|
|
|
358
|
-
# Shape 1
|
|
369
|
+
# Shape 1 — doc.create.paragraph/heading/table/image: top-level key
|
|
370
|
+
# named by the result type holds `{kind, nodeType, nodeId}`.
|
|
371
|
+
for key in (expected_type, "paragraph", "heading", "table", "image"):
|
|
372
|
+
obj = result.get(key)
|
|
373
|
+
if isinstance(obj, dict):
|
|
374
|
+
node_id = obj.get("nodeId")
|
|
375
|
+
if isinstance(node_id, str) and node_id:
|
|
376
|
+
return node_id
|
|
377
|
+
|
|
378
|
+
# Shape 2 — doc.insert: receipt.resolution.target.blockId.
|
|
359
379
|
receipt: object = result.get("receipt")
|
|
360
380
|
if isinstance(receipt, dict):
|
|
361
381
|
resolution: object = receipt.get("resolution")
|
|
@@ -366,7 +386,7 @@ def _extract_inserted_node_id(result: dict, *, expected_type: str) -> str:
|
|
|
366
386
|
if block_id:
|
|
367
387
|
return block_id
|
|
368
388
|
|
|
369
|
-
# Shape
|
|
389
|
+
# Shape 3 — top-level blockId or target.blockId.
|
|
370
390
|
top_block: object = result.get("blockId")
|
|
371
391
|
if isinstance(top_block, str) and top_block:
|
|
372
392
|
return top_block
|
|
@@ -376,7 +396,7 @@ def _extract_inserted_node_id(result: dict, *, expected_type: str) -> str:
|
|
|
376
396
|
if tb:
|
|
377
397
|
return tb
|
|
378
398
|
|
|
379
|
-
# Shape
|
|
399
|
+
# Shape 4 (legacy): nested result.result.insertedNodes[] / nodes[].
|
|
380
400
|
data_obj: object = result.get("result", {})
|
|
381
401
|
data: dict = data_obj if isinstance(data_obj, dict) else {}
|
|
382
402
|
nodes_obj: object = data.get("insertedNodes", data.get("nodes", []))
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"""Table, _Row, _Column, _Cell — python-docx parity.
|
|
2
2
|
|
|
3
|
-
Phase 1
|
|
4
|
-
Table.rows
|
|
5
|
-
Table.
|
|
6
|
-
Table.
|
|
7
|
-
|
|
8
|
-
_Cell.
|
|
3
|
+
Phase 1 supported:
|
|
4
|
+
Table.rows / columns — via doc.tables.get
|
|
5
|
+
Table.cell(r, c) — builds a _Cell proxy (lazy)
|
|
6
|
+
Table.add_row / add_column — via doc.tables.insert_row / insert_column
|
|
7
|
+
Table.style setter — via doc.tables.set_style
|
|
8
|
+
_Cell.text getter — via doc.tables.get_cells (filtered)
|
|
9
|
+
_Cell.merge(other) — via doc.tables.merge_cells
|
|
10
|
+
|
|
11
|
+
Phase 1 NOT supported (NotImplementedError):
|
|
12
|
+
Table.style getter — via doc.tables.getStyles/getProperties
|
|
13
|
+
_Cell.text setter — needs find+replace-in-cell design
|
|
9
14
|
"""
|
|
10
15
|
|
|
11
16
|
from __future__ import annotations
|
|
@@ -20,6 +25,12 @@ if TYPE_CHECKING:
|
|
|
20
25
|
from docx.client import Session
|
|
21
26
|
|
|
22
27
|
|
|
28
|
+
_STUB_SUFFIX = (
|
|
29
|
+
" — not yet mapped to a Superdoc SDK primitive; see "
|
|
30
|
+
"docx-studio/python-sdk/CLAUDE.md for Phase 2 scope."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
23
34
|
def _log_warn(msg: str) -> None:
|
|
24
35
|
print(f"[docx-sdk] WARN: {msg}", file=sys.stderr)
|
|
25
36
|
|
|
@@ -30,26 +41,28 @@ class Table:
|
|
|
30
41
|
self._node_id: str = node_id
|
|
31
42
|
|
|
32
43
|
def _fresh_node_id(self) -> str:
|
|
33
|
-
"""Re-find this table by its
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
find_result: dict = run_sync(
|
|
44
|
+
"""Re-find this table by its original nodeId first; warn+last-in-list
|
|
45
|
+
as a last resort (set_style etc. can rotate ids)."""
|
|
46
|
+
find_result: object = run_sync(
|
|
37
47
|
self._session.doc.find({"type": "table"}),
|
|
38
48
|
)
|
|
39
|
-
|
|
49
|
+
items_obj: object = (
|
|
50
|
+
find_result.get("items", []) if isinstance(find_result, dict) else []
|
|
51
|
+
)
|
|
52
|
+
items: list = items_obj if isinstance(items_obj, list) else []
|
|
40
53
|
for item in items:
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
if not isinstance(item, dict):
|
|
55
|
+
continue
|
|
56
|
+
addr = item.get("address", {})
|
|
57
|
+
if isinstance(addr, dict) and addr.get("nodeId") == self._node_id:
|
|
43
58
|
return self._node_id
|
|
44
|
-
# Not found by exact id — trust the LAST table as ours, with a warning
|
|
45
59
|
if items:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
60
|
+
last = items[-1] if isinstance(items[-1], dict) else {}
|
|
61
|
+
last_addr = last.get("address", {}) if isinstance(last, dict) else {}
|
|
49
62
|
new_id: str = str(last_addr.get("nodeId", ""))
|
|
50
63
|
if new_id:
|
|
51
64
|
_log_warn(
|
|
52
|
-
f"
|
|
65
|
+
f"table nodeId rotated: {self._node_id} -> {new_id}",
|
|
53
66
|
)
|
|
54
67
|
self._node_id = new_id
|
|
55
68
|
return self._node_id
|
|
@@ -57,21 +70,29 @@ class Table:
|
|
|
57
70
|
@property
|
|
58
71
|
def rows(self) -> list["_Row"]:
|
|
59
72
|
nid: str = self._fresh_node_id()
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
info: object = run_sync(self._session.doc.tables.get({"nodeId": nid}))
|
|
74
|
+
info_dict: dict = info if isinstance(info, dict) else {}
|
|
75
|
+
# doc.tables.get may return row count at various paths
|
|
76
|
+
row_count: int = int(
|
|
77
|
+
info_dict.get("rows")
|
|
78
|
+
or info_dict.get("rowCount")
|
|
79
|
+
or (info_dict.get("table", {}) or {}).get("rows")
|
|
80
|
+
or 0,
|
|
62
81
|
)
|
|
63
|
-
info: dict = info_obj if isinstance(info_obj, dict) else {}
|
|
64
|
-
row_count: int = int(info.get("rows", 0))
|
|
65
82
|
return [_Row(table=self, index=i) for i in range(row_count)]
|
|
66
83
|
|
|
67
84
|
@property
|
|
68
85
|
def columns(self) -> list["_Column"]:
|
|
69
86
|
nid: str = self._fresh_node_id()
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
info: object = run_sync(self._session.doc.tables.get({"nodeId": nid}))
|
|
88
|
+
info_dict: dict = info if isinstance(info, dict) else {}
|
|
89
|
+
col_count: int = int(
|
|
90
|
+
info_dict.get("columns")
|
|
91
|
+
or info_dict.get("cols")
|
|
92
|
+
or info_dict.get("columnCount")
|
|
93
|
+
or (info_dict.get("table", {}) or {}).get("columns")
|
|
94
|
+
or 0,
|
|
72
95
|
)
|
|
73
|
-
info: dict = info_obj if isinstance(info_obj, dict) else {}
|
|
74
|
-
col_count: int = int(info.get("cols", 0))
|
|
75
96
|
return [_Column(table=self, index=j) for j in range(col_count)]
|
|
76
97
|
|
|
77
98
|
def cell(self, row_idx: int, col_idx: int) -> "_Cell":
|
|
@@ -81,15 +102,15 @@ class Table:
|
|
|
81
102
|
nid: str = self._fresh_node_id()
|
|
82
103
|
row_count: int = len(self.rows)
|
|
83
104
|
if row_count < 1:
|
|
84
|
-
raise ValidationError(
|
|
85
|
-
|
|
86
|
-
)
|
|
105
|
+
raise ValidationError(f"Cannot add row to empty table {nid}")
|
|
106
|
+
# Real params: nodeId, rowIndex, position, count
|
|
87
107
|
run_sync(
|
|
88
108
|
self._session.doc.tables.insert_row(
|
|
89
109
|
{
|
|
90
|
-
"
|
|
110
|
+
"nodeId": nid,
|
|
91
111
|
"rowIndex": row_count - 1,
|
|
92
112
|
"position": "below",
|
|
113
|
+
"count": 1,
|
|
93
114
|
},
|
|
94
115
|
),
|
|
95
116
|
)
|
|
@@ -99,15 +120,14 @@ class Table:
|
|
|
99
120
|
nid: str = self._fresh_node_id()
|
|
100
121
|
col_count: int = len(self.columns)
|
|
101
122
|
if col_count < 1:
|
|
102
|
-
raise ValidationError(
|
|
103
|
-
f"Cannot add column to empty table {nid}",
|
|
104
|
-
)
|
|
123
|
+
raise ValidationError(f"Cannot add column to empty table {nid}")
|
|
105
124
|
run_sync(
|
|
106
125
|
self._session.doc.tables.insert_column(
|
|
107
126
|
{
|
|
108
|
-
"
|
|
127
|
+
"nodeId": nid,
|
|
109
128
|
"columnIndex": col_count - 1,
|
|
110
129
|
"position": "right",
|
|
130
|
+
"count": 1,
|
|
111
131
|
},
|
|
112
132
|
),
|
|
113
133
|
)
|
|
@@ -115,13 +135,7 @@ class Table:
|
|
|
115
135
|
|
|
116
136
|
@property
|
|
117
137
|
def style(self) -> str | None:
|
|
118
|
-
|
|
119
|
-
info_obj: object = run_sync(
|
|
120
|
-
self._session.doc.tables.get({"nodeId": nid}),
|
|
121
|
-
)
|
|
122
|
-
info: dict = info_obj if isinstance(info_obj, dict) else {}
|
|
123
|
-
style_id: str = str(info.get("styleId", ""))
|
|
124
|
-
return style_id or None
|
|
138
|
+
raise NotImplementedError("Table.style getter" + _STUB_SUFFIX)
|
|
125
139
|
|
|
126
140
|
@style.setter
|
|
127
141
|
def style(self, value: str | None) -> None:
|
|
@@ -170,28 +184,30 @@ class _Cell:
|
|
|
170
184
|
@property
|
|
171
185
|
def text(self) -> str:
|
|
172
186
|
nid: str = self._table._fresh_node_id()
|
|
173
|
-
|
|
174
|
-
self._table._session.doc.tables.
|
|
175
|
-
{
|
|
187
|
+
result: object = run_sync(
|
|
188
|
+
self._table._session.doc.tables.get_cells(
|
|
189
|
+
{
|
|
190
|
+
"nodeId": nid,
|
|
191
|
+
"rowIndex": self._row,
|
|
192
|
+
"columnIndex": self._col,
|
|
193
|
+
},
|
|
176
194
|
),
|
|
177
195
|
)
|
|
178
|
-
if not isinstance(
|
|
196
|
+
if not isinstance(result, dict):
|
|
197
|
+
return ""
|
|
198
|
+
cells_obj: object = result.get("cells", result.get("items", []))
|
|
199
|
+
cells: list = cells_obj if isinstance(cells_obj, list) else []
|
|
200
|
+
if not cells or not isinstance(cells[0], dict):
|
|
179
201
|
return ""
|
|
180
|
-
|
|
202
|
+
first = cells[0]
|
|
203
|
+
text: object = first.get("text")
|
|
204
|
+
if isinstance(text, str):
|
|
205
|
+
return text
|
|
206
|
+
return ""
|
|
181
207
|
|
|
182
208
|
@text.setter
|
|
183
|
-
def text(self, value: str) -> None:
|
|
184
|
-
|
|
185
|
-
run_sync(
|
|
186
|
-
self._table._session.doc.tables.update_cell_text(
|
|
187
|
-
{
|
|
188
|
-
"tableNodeId": nid,
|
|
189
|
-
"row": self._row,
|
|
190
|
-
"col": self._col,
|
|
191
|
-
"text": value,
|
|
192
|
-
},
|
|
193
|
-
),
|
|
194
|
-
)
|
|
209
|
+
def text(self, value: str) -> None: # noqa: ARG002
|
|
210
|
+
raise NotImplementedError("_Cell.text setter" + _STUB_SUFFIX)
|
|
195
211
|
|
|
196
212
|
def merge(self, other: "_Cell") -> "_Cell":
|
|
197
213
|
if other._table is not self._table:
|
|
@@ -202,11 +218,9 @@ class _Cell:
|
|
|
202
218
|
run_sync(
|
|
203
219
|
self._table._session.doc.tables.merge_cells(
|
|
204
220
|
{
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
208
|
-
"endRow": other._row,
|
|
209
|
-
"endCol": other._col,
|
|
221
|
+
"nodeId": nid,
|
|
222
|
+
"start": {"rowIndex": self._row, "columnIndex": self._col},
|
|
223
|
+
"end": {"rowIndex": other._row, "columnIndex": other._col},
|
|
210
224
|
},
|
|
211
225
|
),
|
|
212
226
|
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Paragraph — mirrors python-docx's Paragraph class.
|
|
2
|
+
|
|
3
|
+
Phase 1 supported:
|
|
4
|
+
.text (getter, via doc.get_node_by_id)
|
|
5
|
+
|
|
6
|
+
Phase 1 NOT supported (raises NotImplementedError with a clear message):
|
|
7
|
+
.text setter — Superdoc has no direct block-text replace
|
|
8
|
+
.runs — returns []; iterating post-hoc runs requires
|
|
9
|
+
a node-content walker we haven't built yet
|
|
10
|
+
.style getter — works via get_node_by_id
|
|
11
|
+
.style setter — uses doc.styles.paragraph.set_style
|
|
12
|
+
.alignment get/set — no direct Superdoc equivalent wired yet
|
|
13
|
+
.add_run(text) — works via doc.insert with target at end of block
|
|
14
|
+
|
|
15
|
+
All stubs point at the follow-up design note in CLAUDE.md so agents
|
|
16
|
+
see actionable errors instead of opaque AttributeErrors from the
|
|
17
|
+
underlying SDK.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from docx._batching import run_sync
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from docx.client import Session
|
|
28
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
29
|
+
from docx.text.run import Run
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_STUB_SUFFIX = (
|
|
33
|
+
" — not yet mapped to a Superdoc SDK primitive; see "
|
|
34
|
+
"docx-studio/python-sdk/CLAUDE.md for Phase 2 scope."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _node_text(session: "Session", node_id: str) -> str:
|
|
39
|
+
"""Fetch a block's full text via doc.get_node_by_id."""
|
|
40
|
+
info: object = run_sync(
|
|
41
|
+
session.doc.get_node_by_id({"id": node_id}),
|
|
42
|
+
)
|
|
43
|
+
if not isinstance(info, dict):
|
|
44
|
+
return ""
|
|
45
|
+
node: object = info.get("node")
|
|
46
|
+
if isinstance(node, dict):
|
|
47
|
+
text: object = node.get("text")
|
|
48
|
+
if isinstance(text, str):
|
|
49
|
+
return text
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Paragraph:
|
|
54
|
+
"""A paragraph block in a Word document."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, *, session: "Session", node_id: str) -> None:
|
|
57
|
+
self._session: "Session" = session
|
|
58
|
+
self._node_id: str = node_id
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def text(self) -> str:
|
|
62
|
+
"""Return the plain-text content of this paragraph."""
|
|
63
|
+
return _node_text(self._session, self._node_id)
|
|
64
|
+
|
|
65
|
+
@text.setter
|
|
66
|
+
def text(self, value: str) -> None: # noqa: ARG002
|
|
67
|
+
raise NotImplementedError(
|
|
68
|
+
"Paragraph.text setter" + _STUB_SUFFIX,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def runs(self) -> list["Run"]:
|
|
73
|
+
"""Return the runs in this paragraph.
|
|
74
|
+
|
|
75
|
+
Returns `[]` until the Phase 2 node-content walker lands.
|
|
76
|
+
"""
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def style(self) -> str | None:
|
|
81
|
+
info: object = run_sync(
|
|
82
|
+
self._session.doc.get_node_by_id({"id": self._node_id}),
|
|
83
|
+
)
|
|
84
|
+
if not isinstance(info, dict):
|
|
85
|
+
return None
|
|
86
|
+
node: object = info.get("node")
|
|
87
|
+
if not isinstance(node, dict):
|
|
88
|
+
return None
|
|
89
|
+
style_id: object = node.get("styleId") or node.get("style")
|
|
90
|
+
return str(style_id) if style_id else None
|
|
91
|
+
|
|
92
|
+
@style.setter
|
|
93
|
+
def style(self, value: str | None) -> None:
|
|
94
|
+
target: dict = {
|
|
95
|
+
"kind": "block",
|
|
96
|
+
"nodeType": "paragraph",
|
|
97
|
+
"nodeId": self._node_id,
|
|
98
|
+
}
|
|
99
|
+
run_sync(
|
|
100
|
+
self._session.doc.styles.paragraph.set_style(
|
|
101
|
+
{"target": target, "styleId": value or "Normal"},
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def alignment(self) -> "WD_ALIGN_PARAGRAPH | None":
|
|
107
|
+
raise NotImplementedError(
|
|
108
|
+
"Paragraph.alignment getter" + _STUB_SUFFIX,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@alignment.setter
|
|
112
|
+
def alignment(self, value: "WD_ALIGN_PARAGRAPH | None") -> None: # noqa: ARG002
|
|
113
|
+
raise NotImplementedError(
|
|
114
|
+
"Paragraph.alignment setter" + _STUB_SUFFIX,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def add_run(
|
|
118
|
+
self,
|
|
119
|
+
text: str = "",
|
|
120
|
+
style: str | None = None, # noqa: ARG002
|
|
121
|
+
) -> "Run":
|
|
122
|
+
"""Append a run to this paragraph.
|
|
123
|
+
|
|
124
|
+
Inserts `text` at the END of the block via doc.insert with a
|
|
125
|
+
block-targeted range. Returns a Run proxy that references the
|
|
126
|
+
newly-inserted character span.
|
|
127
|
+
"""
|
|
128
|
+
from docx.text.run import Run
|
|
129
|
+
|
|
130
|
+
# Fetch the block's current text to know where "end of block" is.
|
|
131
|
+
current: str = _node_text(self._session, self._node_id)
|
|
132
|
+
start: int = len(current)
|
|
133
|
+
|
|
134
|
+
# Insert at end of the block. The insert params support targeting
|
|
135
|
+
# a block + offset: {blockId, start, end} at top level where start==end
|
|
136
|
+
# means an insertion point (not a range).
|
|
137
|
+
run_sync(
|
|
138
|
+
self._session.doc.insert(
|
|
139
|
+
{
|
|
140
|
+
"value": text,
|
|
141
|
+
"type": "text",
|
|
142
|
+
"blockId": self._node_id,
|
|
143
|
+
"start": start,
|
|
144
|
+
"end": start,
|
|
145
|
+
},
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
return Run(
|
|
149
|
+
session=self._session,
|
|
150
|
+
block_id=self._node_id,
|
|
151
|
+
range_=(start, start + len(text)),
|
|
152
|
+
)
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
"""Run and Font —
|
|
1
|
+
"""Run and Font — mirrors python-docx's run.Run and text.font.Font.
|
|
2
|
+
|
|
3
|
+
Phase 1 supported:
|
|
4
|
+
Run.text getter — via doc.get_node_by_id, sliced by range
|
|
5
|
+
Run.text setter — via doc.replace (exists)
|
|
6
|
+
Run.bold / italic / underline setters — via doc.format.apply (exists)
|
|
7
|
+
Font.name / size / color.rgb setters — via doc.format.apply
|
|
8
|
+
|
|
9
|
+
Phase 1 NOT supported (NotImplementedError with a clear message):
|
|
10
|
+
Run.bold / italic / underline getters — needs inline-format reader
|
|
11
|
+
Font.name / size / color.rgb getters — needs inline-format reader
|
|
12
|
+
"""
|
|
2
13
|
|
|
3
14
|
from __future__ import annotations
|
|
4
15
|
|
|
@@ -11,6 +22,26 @@ if TYPE_CHECKING:
|
|
|
11
22
|
from docx.client import Session
|
|
12
23
|
|
|
13
24
|
|
|
25
|
+
_STUB_SUFFIX = (
|
|
26
|
+
" — not yet mapped to a Superdoc SDK primitive; see "
|
|
27
|
+
"docx-studio/python-sdk/CLAUDE.md for Phase 2 scope."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _block_text(session: "Session", block_id: str) -> str:
|
|
32
|
+
info: object = run_sync(
|
|
33
|
+
session.doc.get_node_by_id({"id": block_id}),
|
|
34
|
+
)
|
|
35
|
+
if not isinstance(info, dict):
|
|
36
|
+
return ""
|
|
37
|
+
node: object = info.get("node")
|
|
38
|
+
if isinstance(node, dict):
|
|
39
|
+
text: object = node.get("text")
|
|
40
|
+
if isinstance(text, str):
|
|
41
|
+
return text
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
14
45
|
class Run:
|
|
15
46
|
"""A text run — a contiguous span with uniform formatting."""
|
|
16
47
|
|
|
@@ -35,12 +66,7 @@ class Run:
|
|
|
35
66
|
|
|
36
67
|
@property
|
|
37
68
|
def text(self) -> str:
|
|
38
|
-
|
|
39
|
-
self._session.doc.blocks.get({"nodeId": self._block_id}),
|
|
40
|
-
)
|
|
41
|
-
full: str = (
|
|
42
|
-
str(block_obj.get("text", "")) if isinstance(block_obj, dict) else ""
|
|
43
|
-
)
|
|
69
|
+
full: str = _block_text(self._session, self._block_id)
|
|
44
70
|
return full[self._range[0] : self._range[1]]
|
|
45
71
|
|
|
46
72
|
@text.setter
|
|
@@ -62,7 +88,7 @@ class Run:
|
|
|
62
88
|
|
|
63
89
|
@property
|
|
64
90
|
def bold(self) -> bool | None:
|
|
65
|
-
|
|
91
|
+
raise NotImplementedError("Run.bold getter" + _STUB_SUFFIX)
|
|
66
92
|
|
|
67
93
|
@bold.setter
|
|
68
94
|
def bold(self, value: bool | None) -> None:
|
|
@@ -72,7 +98,7 @@ class Run:
|
|
|
72
98
|
|
|
73
99
|
@property
|
|
74
100
|
def italic(self) -> bool | None:
|
|
75
|
-
|
|
101
|
+
raise NotImplementedError("Run.italic getter" + _STUB_SUFFIX)
|
|
76
102
|
|
|
77
103
|
@italic.setter
|
|
78
104
|
def italic(self, value: bool | None) -> None:
|
|
@@ -82,7 +108,7 @@ class Run:
|
|
|
82
108
|
|
|
83
109
|
@property
|
|
84
110
|
def underline(self) -> bool | None:
|
|
85
|
-
|
|
111
|
+
raise NotImplementedError("Run.underline getter" + _STUB_SUFFIX)
|
|
86
112
|
|
|
87
113
|
@underline.setter
|
|
88
114
|
def underline(self, value: bool | None) -> None:
|
|
@@ -94,42 +120,16 @@ class Run:
|
|
|
94
120
|
def font(self) -> "Font":
|
|
95
121
|
return Font(self)
|
|
96
122
|
|
|
97
|
-
def _get_inline_bool(self, name: str) -> bool | None:
|
|
98
|
-
block_obj: object = run_sync(
|
|
99
|
-
self._session.doc.blocks.get({"nodeId": self._block_id}),
|
|
100
|
-
)
|
|
101
|
-
if not isinstance(block_obj, dict):
|
|
102
|
-
return None
|
|
103
|
-
inline_obj: object = block_obj.get("inline", {})
|
|
104
|
-
if not isinstance(inline_obj, dict):
|
|
105
|
-
return None
|
|
106
|
-
val: object = inline_obj.get(name)
|
|
107
|
-
return val if isinstance(val, bool) else None
|
|
108
|
-
|
|
109
123
|
|
|
110
124
|
class Font:
|
|
111
|
-
"""Font properties of a Run.
|
|
112
|
-
|
|
113
|
-
Accessed via `run.font`; do not instantiate directly.
|
|
114
|
-
"""
|
|
125
|
+
"""Font properties of a Run. Accessed via `run.font`; do not instantiate."""
|
|
115
126
|
|
|
116
127
|
def __init__(self, run: Run) -> None:
|
|
117
128
|
self._run: Run = run
|
|
118
129
|
|
|
119
|
-
def _get_inline(self) -> dict:
|
|
120
|
-
block_obj: object = run_sync(
|
|
121
|
-
self._run._session.doc.blocks.get({"nodeId": self._run._block_id}),
|
|
122
|
-
)
|
|
123
|
-
if not isinstance(block_obj, dict):
|
|
124
|
-
return {}
|
|
125
|
-
inline_obj: object = block_obj.get("inline", {})
|
|
126
|
-
return inline_obj if isinstance(inline_obj, dict) else {}
|
|
127
|
-
|
|
128
130
|
@property
|
|
129
131
|
def name(self) -> str | None:
|
|
130
|
-
|
|
131
|
-
value: str = str(inline.get("fontFamily", ""))
|
|
132
|
-
return value or None
|
|
132
|
+
raise NotImplementedError("Font.name getter" + _STUB_SUFFIX)
|
|
133
133
|
|
|
134
134
|
@name.setter
|
|
135
135
|
def name(self, value: str | None) -> None:
|
|
@@ -139,11 +139,7 @@ class Font:
|
|
|
139
139
|
|
|
140
140
|
@property
|
|
141
141
|
def size(self) -> "Pt | None":
|
|
142
|
-
|
|
143
|
-
sz: object = inline.get("fontSize")
|
|
144
|
-
if isinstance(sz, (int, float)):
|
|
145
|
-
return Pt(float(sz))
|
|
146
|
-
return None
|
|
142
|
+
raise NotImplementedError("Font.size getter" + _STUB_SUFFIX)
|
|
147
143
|
|
|
148
144
|
@size.setter
|
|
149
145
|
def size(self, value: "Pt | int | None") -> None:
|
|
@@ -160,25 +156,14 @@ class Font:
|
|
|
160
156
|
|
|
161
157
|
|
|
162
158
|
class _ColorProxy:
|
|
163
|
-
"""Matches python-docx's `run.font.color`
|
|
164
|
-
ColorFormat object with `.rgb` get/set.
|
|
165
|
-
"""
|
|
159
|
+
"""Matches python-docx's `run.font.color` — has .rgb get/set."""
|
|
166
160
|
|
|
167
161
|
def __init__(self, run: Run) -> None:
|
|
168
162
|
self._run: Run = run
|
|
169
163
|
|
|
170
164
|
@property
|
|
171
165
|
def rgb(self) -> "RGBColor | None":
|
|
172
|
-
|
|
173
|
-
self._run._session.doc.blocks.get({"nodeId": self._run._block_id}),
|
|
174
|
-
)
|
|
175
|
-
if not isinstance(block_obj, dict):
|
|
176
|
-
return None
|
|
177
|
-
inline_obj: object = block_obj.get("inline", {})
|
|
178
|
-
if not isinstance(inline_obj, dict):
|
|
179
|
-
return None
|
|
180
|
-
hex_: str = str(inline_obj.get("color", ""))
|
|
181
|
-
return RGBColor.from_string(hex_) if hex_ else None
|
|
166
|
+
raise NotImplementedError("Font.color.rgb getter" + _STUB_SUFFIX)
|
|
182
167
|
|
|
183
168
|
@rgb.setter
|
|
184
169
|
def rgb(self, value: "RGBColor | None") -> None:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "athena-python-docx"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Drop-in replacement for python-docx that connects to Athena's Superdoc/Keryx collaborative document stack"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
"""Paragraph — mirrors python-docx's Paragraph class.
|
|
2
|
-
|
|
3
|
-
Phase 1 surface:
|
|
4
|
-
.text (get/set)
|
|
5
|
-
.runs -> list[Run] (returns empty list in Phase 1 — Phase 2 will implement)
|
|
6
|
-
.style (get/set)
|
|
7
|
-
.alignment (get/set)
|
|
8
|
-
.add_run(text, bold=None, italic=None, ...) -> Run
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
from typing import TYPE_CHECKING
|
|
14
|
-
|
|
15
|
-
from docx._batching import run_sync
|
|
16
|
-
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
17
|
-
|
|
18
|
-
if TYPE_CHECKING:
|
|
19
|
-
from docx.client import Session
|
|
20
|
-
from docx.text.run import Run
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class Paragraph:
|
|
24
|
-
"""A paragraph block in a Word document."""
|
|
25
|
-
|
|
26
|
-
def __init__(self, *, session: "Session", node_id: str) -> None:
|
|
27
|
-
self._session: "Session" = session
|
|
28
|
-
self._node_id: str = node_id
|
|
29
|
-
|
|
30
|
-
@property
|
|
31
|
-
def text(self) -> str:
|
|
32
|
-
"""Return the plain-text content of this paragraph."""
|
|
33
|
-
block_obj: object = run_sync(
|
|
34
|
-
self._session.doc.blocks.get({"nodeId": self._node_id}),
|
|
35
|
-
)
|
|
36
|
-
if not isinstance(block_obj, dict):
|
|
37
|
-
return ""
|
|
38
|
-
return str(block_obj.get("text", ""))
|
|
39
|
-
|
|
40
|
-
@text.setter
|
|
41
|
-
def text(self, value: str) -> None:
|
|
42
|
-
"""Replace the entire text content of this paragraph."""
|
|
43
|
-
run_sync(
|
|
44
|
-
self._session.doc.blocks.update_text(
|
|
45
|
-
{"nodeId": self._node_id, "text": value},
|
|
46
|
-
),
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
@property
|
|
50
|
-
def runs(self) -> list["Run"]:
|
|
51
|
-
"""Return the runs in this paragraph.
|
|
52
|
-
|
|
53
|
-
Phase 1: returns an empty list. Superdoc's block model doesn't
|
|
54
|
-
expose a runs accessor on individual paragraphs in the current
|
|
55
|
-
SDK version; iterating them would require a custom JSON walker.
|
|
56
|
-
Phase 2 will implement this.
|
|
57
|
-
"""
|
|
58
|
-
return []
|
|
59
|
-
|
|
60
|
-
@property
|
|
61
|
-
def style(self) -> str | None:
|
|
62
|
-
block_obj: object = run_sync(
|
|
63
|
-
self._session.doc.blocks.get({"nodeId": self._node_id}),
|
|
64
|
-
)
|
|
65
|
-
if not isinstance(block_obj, dict):
|
|
66
|
-
return None
|
|
67
|
-
style_id: str = str(block_obj.get("styleId", ""))
|
|
68
|
-
return style_id or None
|
|
69
|
-
|
|
70
|
-
@style.setter
|
|
71
|
-
def style(self, value: str | None) -> None:
|
|
72
|
-
run_sync(
|
|
73
|
-
self._session.doc.blocks.set_style(
|
|
74
|
-
{"nodeId": self._node_id, "styleId": value or "Normal"},
|
|
75
|
-
),
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
@property
|
|
79
|
-
def alignment(self) -> "WD_ALIGN_PARAGRAPH | None":
|
|
80
|
-
block_obj: object = run_sync(
|
|
81
|
-
self._session.doc.blocks.get({"nodeId": self._node_id}),
|
|
82
|
-
)
|
|
83
|
-
if not isinstance(block_obj, dict):
|
|
84
|
-
return None
|
|
85
|
-
raw: str = str(block_obj.get("alignment", ""))
|
|
86
|
-
return WD_ALIGN_PARAGRAPH.from_superdoc(raw) if raw else None
|
|
87
|
-
|
|
88
|
-
@alignment.setter
|
|
89
|
-
def alignment(self, value: "WD_ALIGN_PARAGRAPH | None") -> None:
|
|
90
|
-
sd_value: str = (
|
|
91
|
-
value.to_superdoc() if value is not None else "left"
|
|
92
|
-
)
|
|
93
|
-
run_sync(
|
|
94
|
-
self._session.doc.format.set_alignment(
|
|
95
|
-
{
|
|
96
|
-
"target": {"kind": "block", "nodeId": self._node_id},
|
|
97
|
-
"value": sd_value,
|
|
98
|
-
},
|
|
99
|
-
),
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
def add_run(
|
|
103
|
-
self,
|
|
104
|
-
text: str = "",
|
|
105
|
-
style: str | None = None, # noqa: ARG002
|
|
106
|
-
) -> "Run":
|
|
107
|
-
"""Append a run to this paragraph and return it.
|
|
108
|
-
|
|
109
|
-
Strategy: append text at the end of the paragraph, then return
|
|
110
|
-
a Run proxy targeting that character range.
|
|
111
|
-
|
|
112
|
-
Args:
|
|
113
|
-
text: The text content of the new run.
|
|
114
|
-
style: Ignored in Phase 1 (python-docx parity — accepts the
|
|
115
|
-
kwarg but has no-op behavior until Superdoc exposes
|
|
116
|
-
run-style references).
|
|
117
|
-
"""
|
|
118
|
-
from docx.text.run import Run
|
|
119
|
-
|
|
120
|
-
# Get current length to compute range start
|
|
121
|
-
block_obj: object = run_sync(
|
|
122
|
-
self._session.doc.blocks.get({"nodeId": self._node_id}),
|
|
123
|
-
)
|
|
124
|
-
current_text: str = (
|
|
125
|
-
str(block_obj.get("text", "")) if isinstance(block_obj, dict) else ""
|
|
126
|
-
)
|
|
127
|
-
range_start: int = len(current_text)
|
|
128
|
-
|
|
129
|
-
# Append text to the block
|
|
130
|
-
run_sync(
|
|
131
|
-
self._session.doc.blocks.append_text(
|
|
132
|
-
{"nodeId": self._node_id, "text": text},
|
|
133
|
-
),
|
|
134
|
-
)
|
|
135
|
-
range_end: int = range_start + len(text)
|
|
136
|
-
|
|
137
|
-
return Run(
|
|
138
|
-
session=self._session,
|
|
139
|
-
block_id=self._node_id,
|
|
140
|
-
range_=(range_start, range_end),
|
|
141
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|