iwork-studio 2.3.0__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.
- iwork_studio/__init__.py +3 -0
- iwork_studio/app_ops.py +1043 -0
- iwork_studio/apps.py +80 -0
- iwork_studio/backups.py +129 -0
- iwork_studio/design.py +337 -0
- iwork_studio/exporter.py +280 -0
- iwork_studio/format_check.py +91 -0
- iwork_studio/helpers.py +179 -0
- iwork_studio/installer.py +150 -0
- iwork_studio/keynote_applescript.py +226 -0
- iwork_studio/keynote_deck.py +266 -0
- iwork_studio/keynote_io.py +582 -0
- iwork_studio/keynote_slides.py +430 -0
- iwork_studio/keynote_theme.py +386 -0
- iwork_studio/mcp_server.py +906 -0
- iwork_studio/numbers_format.py +594 -0
- iwork_studio/numbers_io.py +576 -0
- iwork_studio/numbers_structure.py +368 -0
- iwork_studio/pages_io.py +593 -0
- iwork_studio/pdf.py +152 -0
- iwork_studio/preview.py +140 -0
- iwork_studio/render_verify.py +199 -0
- iwork_studio-2.3.0.dist-info/METADATA +348 -0
- iwork_studio-2.3.0.dist-info/RECORD +29 -0
- iwork_studio-2.3.0.dist-info/WHEEL +5 -0
- iwork_studio-2.3.0.dist-info/entry_points.txt +2 -0
- iwork_studio-2.3.0.dist-info/licenses/LICENSE +21 -0
- iwork_studio-2.3.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +43 -0
- iwork_studio-2.3.0.dist-info/top_level.txt +1 -0
iwork_studio/app_ops.py
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
"""iWork Studio — app-driven ops (macOS + the app + a GUI session).
|
|
2
|
+
|
|
3
|
+
numbers_set_formula put a formula in a cell (Numbers computes it)
|
|
4
|
+
numbers_sort sort a table's body rows by a column
|
|
5
|
+
pages_placeholders list / fill template placeholders ("Name", "Date" …)
|
|
6
|
+
keynote_set_transition slide transition effect / duration / auto-advance
|
|
7
|
+
keynote_add_image place an image on a slide
|
|
8
|
+
keynote_slideshow start / stop / next / previous (no file change)
|
|
9
|
+
create_document new document from one of Apple's built-in templates
|
|
10
|
+
|
|
11
|
+
Same protocol as every other write: gates → snapshot → backup → op + in-place
|
|
12
|
+
save → fresh re-read → expectation check → restore on any mismatch.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from iwork_studio.apps import app_name
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"set_formula", "sort_table", "list_placeholders", "fill_placeholders", "set_transition",
|
|
30
|
+
"read_tables", "set_table_cells", "recalculate", "add_image", "add_chart", "slideshow", "create_document", "TRANSITIONS", "CHART_TYPES", "AppOpError",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AppOpError(ValueError):
|
|
35
|
+
"""Bad request for an app-driven op (unknown effect, bad cell, unknown template…)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
TRANSITIONS = (
|
|
39
|
+
"no transition effect", "magic move", "dissolve", "fade through color", "move in", "push", "reveal",
|
|
40
|
+
"wipe", "iris", "cube", "doorway", "flip", "flop", "page flip", "reflection", "revolving door", "scale",
|
|
41
|
+
"swap", "swoosh", "twirl", "twist", "blinds", "color planes", "confetti", "drop", "droplet", "fall",
|
|
42
|
+
"grid", "mosaic", "perspective", "pivot", "shimmer", "sparkle", "switch", "clothesline", "fade and move",
|
|
43
|
+
"object cube", "object flip", "object pop", "object push", "object revolve", "object zoom", "swing",
|
|
44
|
+
)
|
|
45
|
+
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".tif", ".tiff", ".heic", ".pdf", ".bmp", ".webp"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _jxa(kind: str, body: str, params: dict, timeout: int = 300) -> dict:
|
|
49
|
+
from iwork_studio.keynote_slides import _assert_aqua
|
|
50
|
+
|
|
51
|
+
_assert_aqua()
|
|
52
|
+
script = ("function run(argv) {\n const params = JSON.parse(argv[0]);\n"
|
|
53
|
+
f" const app = Application({json.dumps(app_name(kind))});\n{body}\n}}")
|
|
54
|
+
r = subprocess.run(["osascript", "-l", "JavaScript", "-e", script, json.dumps(params)],
|
|
55
|
+
capture_output=True, text=True, timeout=timeout)
|
|
56
|
+
if r.returncode != 0:
|
|
57
|
+
raise RuntimeError(f"JXA failed (rc={r.returncode}): {r.stderr.strip()[:500]}")
|
|
58
|
+
return json.loads(r.stdout.strip() or "{}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_OPEN_CHECK = """
|
|
62
|
+
const already = app.documents().some(d => {
|
|
63
|
+
try { const f = d.file(); return f && f.toString() === params.path; } catch (e) { return false; }
|
|
64
|
+
});
|
|
65
|
+
if (already) return JSON.stringify({open_in_app: true});
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _edit_in_app(kind: str, path: Path, body: str, params: dict) -> dict:
|
|
70
|
+
"""open → body (has `doc`) → in-place save → close. Refuses a document already open."""
|
|
71
|
+
out = _jxa(kind, _OPEN_CHECK + " const doc = app.open(Path(params.path));\n let result = {};\n try {\n"
|
|
72
|
+
+ body + "\n app.save(doc);\n } finally {\n app.close(doc, {saving: 'no'});\n }\n"
|
|
73
|
+
" return JSON.stringify(result);", {"path": str(path), **params})
|
|
74
|
+
if out.get("open_in_app"):
|
|
75
|
+
from iwork_studio.keynote_slides import DocumentOpenError
|
|
76
|
+
|
|
77
|
+
raise DocumentOpenError(f"{path.name} is open in {kind}. Save and close it first.")
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _restore(target: Path, backup: Path) -> None:
|
|
82
|
+
fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix=f".{target.name}.rb", suffix=target.suffix)
|
|
83
|
+
os.close(fd)
|
|
84
|
+
shutil.copy2(backup, tmp)
|
|
85
|
+
os.replace(tmp, target)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Numbers (app computes formulas and sorts) ─────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _numbers_target(path, sheet, table, *, whole_document: bool = False):
|
|
92
|
+
from numbers_parser import Document
|
|
93
|
+
|
|
94
|
+
from iwork_studio import numbers_io as nio
|
|
95
|
+
|
|
96
|
+
target = Path(path).resolve()
|
|
97
|
+
if not target.exists():
|
|
98
|
+
raise FileNotFoundError(target)
|
|
99
|
+
if target.suffix.lower() != ".numbers":
|
|
100
|
+
raise AppOpError(f"{target.name} is not a .numbers file")
|
|
101
|
+
doc = Document(str(target))
|
|
102
|
+
if whole_document:
|
|
103
|
+
return target, doc, None, None
|
|
104
|
+
sh, tb = nio._resolve_cell(doc, sheet, table)
|
|
105
|
+
return target, doc, sh, tb
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _values(doc) -> dict:
|
|
109
|
+
out = {}
|
|
110
|
+
for i in range(len(doc.sheets)):
|
|
111
|
+
sh = doc.sheets[i]
|
|
112
|
+
for j in range(len(sh.tables)):
|
|
113
|
+
tb = sh.tables[j]
|
|
114
|
+
for r in range(tb.num_rows):
|
|
115
|
+
for c in range(tb.num_cols):
|
|
116
|
+
cell = tb.cell(r, c)
|
|
117
|
+
v = cell.value
|
|
118
|
+
out[(sh.name, tb.name, r, c)] = (
|
|
119
|
+
v.isoformat() if hasattr(v, "isoformat") else v, cell.formula if cell.is_formula else None)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _fnorm(f: str | None) -> str:
|
|
124
|
+
return re.sub(r"[\s$]", "", (f or "").lstrip("=")).upper()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _numbers_write(path, sheet, table, op: str, body: str, params: dict, expect, summary: dict,
|
|
128
|
+
backup_dir=None, max_backups: int = 10, whole_document: bool = False) -> dict:
|
|
129
|
+
from numbers_parser import Document
|
|
130
|
+
|
|
131
|
+
from iwork_studio import numbers_io as nio
|
|
132
|
+
|
|
133
|
+
target, doc, sh, tb = _numbers_target(path, sheet, table, whole_document=whole_document)
|
|
134
|
+
where = {} if whole_document else {"sheet": sh.name, "table": tb.name}
|
|
135
|
+
before = _values(doc)
|
|
136
|
+
has_charts = nio.contains_charts(target) # Numbers does the edit itself, so its charts stay linked
|
|
137
|
+
bdir = Path(backup_dir) if backup_dir else target.parent / f"{target.name}.backups"
|
|
138
|
+
backup = nio._versioned_backup(target, bdir)
|
|
139
|
+
nio._prune_backups(bdir, max_backups)
|
|
140
|
+
try:
|
|
141
|
+
out = _edit_in_app("Numbers", target, _COUNT_CHARTS + " const chartsBefore = countCharts();\n" + body
|
|
142
|
+
+ " result.charts = [chartsBefore, countCharts()];\n",
|
|
143
|
+
{**where, **params})
|
|
144
|
+
after_doc = Document(str(target))
|
|
145
|
+
expect(before, _values(after_doc), after_doc)
|
|
146
|
+
if isinstance(out, dict) and out.get("recalculated") is not None:
|
|
147
|
+
summary = {**summary, "recalculated": out["recalculated"]}
|
|
148
|
+
cb, ca = ((out or {}).get("charts") or [None, None])
|
|
149
|
+
if has_charts and (cb is None or ca is None):
|
|
150
|
+
raise nio.WriteVerificationError("this file has charts and Numbers doesn't report them, so the "
|
|
151
|
+
"result can't be checked — rolled back")
|
|
152
|
+
if cb != ca:
|
|
153
|
+
raise nio.WriteVerificationError(f"charts went from {cb} to {ca} — rolled back")
|
|
154
|
+
except Exception:
|
|
155
|
+
_restore(target, backup)
|
|
156
|
+
raise
|
|
157
|
+
return {"ok": True, "file": str(target), "op": op, **where, "backup": str(backup), **summary}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_COUNT_CHARTS = """ const countCharts = () => {
|
|
161
|
+
try { return doc.sheets().reduce((n, s) => n + s.charts().length, 0); } catch (e) { return null; }
|
|
162
|
+
};
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
# Numbers shows a formula's stored result and doesn't recalculate on open, so after
|
|
166
|
+
# no-app edits totals can be stale. Re-entering each formula as-is makes Numbers
|
|
167
|
+
# recompute it from the current inputs.
|
|
168
|
+
_RECALC_ALL = """ let recalculated = 0;
|
|
169
|
+
doc.sheets().forEach(s => s.tables().forEach(t => {
|
|
170
|
+
let fs = [];
|
|
171
|
+
try { fs = t.cells.formula(); } catch (e) {}
|
|
172
|
+
if (!fs.some(f => f)) return;
|
|
173
|
+
const cells = t.cells();
|
|
174
|
+
for (let i = 0; i < fs.length; i++) {
|
|
175
|
+
if (fs[i]) { cells[i].value = fs[i]; recalculated++; }
|
|
176
|
+
}
|
|
177
|
+
}));
|
|
178
|
+
result.recalculated = recalculated;
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
_NUM_TABLE = """ const sh = doc.sheets.byName(params.sheet);
|
|
182
|
+
const tb = sh.tables.byName(params.table);
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def set_formula(path, ref: str, formula: str, *, sheet: str | None = None, table: str | None = None, **kw) -> dict:
|
|
187
|
+
"""Put a formula (e.g. "=SUM(B2:B9)") in one cell; Numbers computes it.
|
|
188
|
+
Every other cell's input is checked unchanged (computed results may update)."""
|
|
189
|
+
from iwork_studio import numbers_io as nio
|
|
190
|
+
|
|
191
|
+
if not isinstance(formula, str) or not formula.strip().startswith("="):
|
|
192
|
+
raise AppOpError('formula must start with "=" (e.g. "=SUM(B2:B9)")')
|
|
193
|
+
target, doc, sh, tb = _numbers_target(path, sheet, table)
|
|
194
|
+
r, c = nio._parse_ref(ref)
|
|
195
|
+
if not (0 <= r < tb.num_rows and 0 <= c < tb.num_cols):
|
|
196
|
+
raise nio.CellRefError(f"{ref} is outside {tb.name!r} ({tb.num_rows} rows × {tb.num_cols} columns)")
|
|
197
|
+
key = (sh.name, tb.name, r, c)
|
|
198
|
+
from iwork_studio.numbers_format import _col_letters
|
|
199
|
+
|
|
200
|
+
a1 = f"{_col_letters(c)}{r + 1}"
|
|
201
|
+
|
|
202
|
+
def expect(before, after, after_doc):
|
|
203
|
+
if set(after) != set(before):
|
|
204
|
+
raise nio.WriteVerificationError("table shape changed — rolled back")
|
|
205
|
+
got_formula = after[key][1]
|
|
206
|
+
if got_formula is None:
|
|
207
|
+
raise nio.WriteVerificationError(f"{ref} did not become a formula (reads {after[key][0]!r}) — rolled back")
|
|
208
|
+
if _fnorm(got_formula) != _fnorm(formula):
|
|
209
|
+
raise nio.WriteVerificationError(f"{ref} formula reads {got_formula!r}, wanted {formula!r} — rolled back")
|
|
210
|
+
for k, (v, f) in before.items():
|
|
211
|
+
if k == key:
|
|
212
|
+
continue
|
|
213
|
+
if f is None and after[k][1] is None and after[k][0] != v:
|
|
214
|
+
raise nio.WriteVerificationError(f"{k[1]}!R{k[2] + 1}C{k[3] + 1} changed ({v!r} → {after[k][0]!r})")
|
|
215
|
+
if f is not None and _fnorm(after[k][1]) != _fnorm(f):
|
|
216
|
+
raise nio.WriteVerificationError(f"formula at {k[1]}!R{k[2] + 1}C{k[3] + 1} changed")
|
|
217
|
+
if (f is None) != (after[k][1] is None):
|
|
218
|
+
raise nio.WriteVerificationError(f"{k[1]}!R{k[2] + 1}C{k[3] + 1} changed kind")
|
|
219
|
+
|
|
220
|
+
body = _RECALC_ALL + _NUM_TABLE + " tb.cells.byName(params.ref).value = params.formula;\n"
|
|
221
|
+
out = _numbers_write(path, sheet, table, "set_formula", body,
|
|
222
|
+
{"ref": a1, "formula": formula.strip()}, expect, {"ref": a1, "formula": formula.strip()}, **kw)
|
|
223
|
+
from numbers_parser import Document
|
|
224
|
+
|
|
225
|
+
t2 = nio._resolve_cell(Document(out["file"]), out["sheet"], out["table"])[1]
|
|
226
|
+
out["result"] = t2.cell(r, c).value
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def recalculate(path, **kw) -> dict:
|
|
231
|
+
"""Make Numbers recompute every formula from the current values (after no-app edits,
|
|
232
|
+
Numbers keeps showing the old results). Formulas and all other values are checked unchanged."""
|
|
233
|
+
from iwork_studio import numbers_io as nio
|
|
234
|
+
|
|
235
|
+
def expect(before, after, after_doc):
|
|
236
|
+
if set(after) != set(before):
|
|
237
|
+
raise nio.WriteVerificationError("table shape changed — rolled back")
|
|
238
|
+
for k, (v, f) in before.items():
|
|
239
|
+
if f is None:
|
|
240
|
+
if after[k][1] is not None or not _same(after[k][0], v):
|
|
241
|
+
raise nio.WriteVerificationError(f"{k[1]}!R{k[2] + 1}C{k[3] + 1} changed — rolled back")
|
|
242
|
+
elif _fnorm(after[k][1]) != _fnorm(f):
|
|
243
|
+
raise nio.WriteVerificationError(f"formula at {k[1]}!R{k[2] + 1}C{k[3] + 1} changed — rolled back")
|
|
244
|
+
|
|
245
|
+
out = _numbers_write(path, None, None, "recalculate", _RECALC_ALL, {}, expect, {}, whole_document=True, **kw)
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def sort_table(path, column: str, *, descending: bool = False, sheet: str | None = None,
|
|
250
|
+
table: str | None = None, **kw) -> dict:
|
|
251
|
+
"""Sort the table's body rows (header rows stay put) by a column letter."""
|
|
252
|
+
from iwork_studio import numbers_io as nio
|
|
253
|
+
|
|
254
|
+
target, doc, sh, tb = _numbers_target(path, sheet, table)
|
|
255
|
+
_, c = nio._parse_ref(f"{column}1")
|
|
256
|
+
if not 0 <= c < tb.num_cols:
|
|
257
|
+
raise nio.CellRefError(f"column {column!r} is outside the table ({tb.num_cols} columns)")
|
|
258
|
+
h = tb.num_header_rows
|
|
259
|
+
if tb.num_rows - h < 2:
|
|
260
|
+
raise AppOpError("nothing to sort (fewer than two body rows)")
|
|
261
|
+
if list(tb.merge_ranges):
|
|
262
|
+
raise AppOpError("this table has merged cells; Numbers can't sort it")
|
|
263
|
+
|
|
264
|
+
def rows(vals, name):
|
|
265
|
+
return [tuple(vals[(sh.name, name, r, cc)][0] for cc in range(tb.num_cols)) for r in range(tb.num_rows)]
|
|
266
|
+
|
|
267
|
+
def expect(before, after, after_doc):
|
|
268
|
+
if set(after) != set(before):
|
|
269
|
+
raise nio.WriteVerificationError("table shape changed — rolled back")
|
|
270
|
+
for k, v in before.items():
|
|
271
|
+
if (k[0], k[1]) != (sh.name, tb.name) and after[k] != v:
|
|
272
|
+
raise nio.WriteVerificationError(f"table {k[1]!r} changed collaterally — rolled back")
|
|
273
|
+
rb, ra = rows(before, tb.name), rows(after, tb.name)
|
|
274
|
+
if ra[:h] != rb[:h]:
|
|
275
|
+
raise nio.WriteVerificationError("header rows changed — rolled back")
|
|
276
|
+
if Counter(ra[h:]) != Counter(rb[h:]):
|
|
277
|
+
raise nio.WriteVerificationError("rows were altered, not just reordered — rolled back")
|
|
278
|
+
nums = [row[c] for row in ra[h:] if isinstance(row[c], (int, float)) and not isinstance(row[c], bool)]
|
|
279
|
+
if nums != sorted(nums, reverse=descending):
|
|
280
|
+
raise nio.WriteVerificationError(f"column {column} is not in {'descending' if descending else 'ascending'} order")
|
|
281
|
+
|
|
282
|
+
body = _NUM_TABLE + (" tb.sort({by: tb.columns[params.col], direction: params.dir});\n")
|
|
283
|
+
return _numbers_write(path, sheet, table, "sort", body,
|
|
284
|
+
{"col": c, "dir": "descending" if descending else "ascending"}, expect,
|
|
285
|
+
{"column": column.upper(), "order": "descending" if descending else "ascending",
|
|
286
|
+
"rows_sorted": tb.num_rows - h}, **kw)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── Pages placeholders (template fields like "Name", "Date") ─────────────────
|
|
290
|
+
|
|
291
|
+
_US, _RS = "\x1f", "\x1e"
|
|
292
|
+
|
|
293
|
+
# Pages refuses AppleScript `open (POSIX file …)` on files outside its sandbox
|
|
294
|
+
# ("Operation not permitted"); JXA `app.open(Path(…))` is granted access. So the
|
|
295
|
+
# document is opened with JXA, and AppleScript then finds that open document by
|
|
296
|
+
# its exact file path — never "front document" — before reading or writing.
|
|
297
|
+
_PAGES_FIND = """ set d to missing value
|
|
298
|
+
repeat with x in documents
|
|
299
|
+
set fp to ""
|
|
300
|
+
try
|
|
301
|
+
set fp to POSIX path of ((file of x) as alias)
|
|
302
|
+
end try
|
|
303
|
+
if fp ends with "/" then set fp to text 1 thru -2 of fp
|
|
304
|
+
if fp is (item 1 of argv) then
|
|
305
|
+
set d to contents of x
|
|
306
|
+
exit repeat
|
|
307
|
+
end if
|
|
308
|
+
end repeat
|
|
309
|
+
if d is missing value then error "the document isn't open in Pages" number -10000
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _pages_open(path: Path, *, open_it: bool = True) -> bool:
|
|
314
|
+
"""Open `path` in Pages via JXA (if open_it). Returns True if it was already open."""
|
|
315
|
+
out = _jxa("Pages", _OPEN_CHECK + """ if (params.open_it) app.open(Path(params.path));
|
|
316
|
+
return JSON.stringify({open_in_app: false});""", {"path": str(path), "open_it": open_it}, timeout=180)
|
|
317
|
+
return bool(out.get("open_in_app"))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _pages_close(path: Path) -> None:
|
|
321
|
+
"""Close `path` in Pages without saving, if it is open (cleanup after a failure)."""
|
|
322
|
+
try:
|
|
323
|
+
_jxa("Pages", """ app.documents().forEach(d => {
|
|
324
|
+
try { const f = d.file(); if (f && f.toString() === params.path) app.close(d, {saving: 'no'}); } catch (e) {}
|
|
325
|
+
});
|
|
326
|
+
return JSON.stringify({});""", {"path": str(path)}, timeout=60)
|
|
327
|
+
except Exception: # noqa: BLE001 — best effort; the original error matters more
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _pages_placeholders(path: Path) -> list[dict]:
|
|
332
|
+
"""AppleScript: [{tag, text}] in document order (open → read → close, no save)."""
|
|
333
|
+
from iwork_studio import pages_io
|
|
334
|
+
|
|
335
|
+
pages_io._assert_aqua()
|
|
336
|
+
name = app_name("Pages")
|
|
337
|
+
if '"' in name:
|
|
338
|
+
raise AppOpError(f"refusing unsafe app name {name!r}")
|
|
339
|
+
keep_open = _pages_open(path)
|
|
340
|
+
script = f"""on run argv
|
|
341
|
+
tell application "{name}"
|
|
342
|
+
{_PAGES_FIND} set out to ""
|
|
343
|
+
try
|
|
344
|
+
repeat with ph in (every placeholder text of d)
|
|
345
|
+
set t to ""
|
|
346
|
+
try
|
|
347
|
+
set t to tag of ph
|
|
348
|
+
if t is missing value then set t to ""
|
|
349
|
+
end try
|
|
350
|
+
set x to ""
|
|
351
|
+
try
|
|
352
|
+
set x to (ph as text)
|
|
353
|
+
end try
|
|
354
|
+
set out to out & (t as text) & (character id 31) & x & (character id 30)
|
|
355
|
+
end repeat
|
|
356
|
+
on error errMsg number errNum
|
|
357
|
+
if (item 2 of argv) is "close" then close d saving no
|
|
358
|
+
error errMsg number errNum
|
|
359
|
+
end try
|
|
360
|
+
if (item 2 of argv) is "close" then close d saving no
|
|
361
|
+
return out
|
|
362
|
+
end tell
|
|
363
|
+
end run"""
|
|
364
|
+
r = subprocess.run(["osascript", "-e", script, str(path), "keep" if keep_open else "close"],
|
|
365
|
+
capture_output=True, text=True, timeout=180)
|
|
366
|
+
if r.returncode != 0:
|
|
367
|
+
if not keep_open:
|
|
368
|
+
_pages_close(path)
|
|
369
|
+
raise RuntimeError(f"AppleScript failed (rc={r.returncode}): {r.stderr.strip()[:400]}")
|
|
370
|
+
out = []
|
|
371
|
+
for rec in r.stdout.rstrip("\n").split(_RS):
|
|
372
|
+
if _US in rec:
|
|
373
|
+
tag, text = rec.split(_US, 1)
|
|
374
|
+
out.append({"tag": tag, "text": text})
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _pages_texts(path: Path) -> dict:
|
|
379
|
+
"""Every text the document holds: body (None for page-layout documents) and,
|
|
380
|
+
in order, each text box / shape's text (None if Pages won't list them)."""
|
|
381
|
+
body = """
|
|
382
|
+
const doc = app.open(Path(params.path));
|
|
383
|
+
const out = {body: null, items: null};
|
|
384
|
+
try {
|
|
385
|
+
try { const b = doc.bodyText(); out.body = (b === null || b === undefined) ? null : b.toString(); } catch (e) {}
|
|
386
|
+
try {
|
|
387
|
+
const items = [];
|
|
388
|
+
for (const coll of ['textItems', 'shapes']) {
|
|
389
|
+
const xs = doc[coll]();
|
|
390
|
+
for (let i = 0; i < xs.length; i++) {
|
|
391
|
+
let t = null;
|
|
392
|
+
try { t = xs[i].objectText().toString(); } catch (e) {}
|
|
393
|
+
items.push(t);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
out.items = items;
|
|
397
|
+
} catch (e) {}
|
|
398
|
+
} finally {
|
|
399
|
+
app.close(doc, {saving: 'no'});
|
|
400
|
+
}
|
|
401
|
+
return JSON.stringify(out);"""
|
|
402
|
+
return _jxa("Pages", body, {"path": str(path)})
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _expected_texts(before: dict, phs: list[dict], values: dict) -> dict:
|
|
406
|
+
"""Body: placeholders replaced in document order. Text boxes: every occurrence of a
|
|
407
|
+
filled placeholder's text replaced (longest first)."""
|
|
408
|
+
exp = {"body": before["body"], "items": before["items"]}
|
|
409
|
+
if before["body"] is not None:
|
|
410
|
+
body, cursor = before["body"], 0
|
|
411
|
+
for p in phs:
|
|
412
|
+
if p["tag"] in values and p["text"]:
|
|
413
|
+
i = body.find(p["text"], cursor)
|
|
414
|
+
if i >= 0:
|
|
415
|
+
body = body[:i] + values[p["tag"]] + body[i + len(p["text"]):]
|
|
416
|
+
cursor = i + len(values[p["tag"]])
|
|
417
|
+
exp["body"] = body
|
|
418
|
+
if before["items"] is not None:
|
|
419
|
+
subs = sorted({(p["text"], values[p["tag"]]) for p in phs if p["tag"] in values and p["text"]},
|
|
420
|
+
key=lambda x: -len(x[0]))
|
|
421
|
+
items = []
|
|
422
|
+
for t in before["items"]:
|
|
423
|
+
if t is not None:
|
|
424
|
+
for old, new in subs:
|
|
425
|
+
t = t.replace(old, new)
|
|
426
|
+
items.append(t)
|
|
427
|
+
exp["items"] = items
|
|
428
|
+
return exp
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def list_placeholders(path) -> dict:
|
|
432
|
+
target = Path(path).resolve()
|
|
433
|
+
if target.suffix.lower() != ".pages":
|
|
434
|
+
raise AppOpError(f"{target.name} is not a .pages document")
|
|
435
|
+
from iwork_studio import pages_io
|
|
436
|
+
|
|
437
|
+
pages_io.preflight()
|
|
438
|
+
phs = _pages_placeholders(target)
|
|
439
|
+
return {"file": str(target), "placeholders": phs, "tags": sorted({p["tag"] for p in phs})}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def fill_placeholders(path, values: dict, *, backup_dir=None, max_backups: int = 10) -> dict:
|
|
443
|
+
"""values = {"Name": "وليد", "Date": "3 Oct"} — fill every placeholder with that tag.
|
|
444
|
+
Works for word-processing and page-layout documents. The body and every text box
|
|
445
|
+
are checked to equal the originals with exactly those placeholders filled."""
|
|
446
|
+
from iwork_studio import pages_io
|
|
447
|
+
|
|
448
|
+
target = Path(path).resolve()
|
|
449
|
+
if not target.exists():
|
|
450
|
+
raise FileNotFoundError(target)
|
|
451
|
+
if target.suffix.lower() != ".pages":
|
|
452
|
+
raise AppOpError(f"{target.name} is not a .pages document")
|
|
453
|
+
if not values or not all(isinstance(k, str) and isinstance(v, str) for k, v in values.items()):
|
|
454
|
+
raise AppOpError('values must be {"Tag": "text", …}')
|
|
455
|
+
if "" in values:
|
|
456
|
+
raise AppOpError("untagged placeholders can't be targeted; use pages_list_placeholders for the tags")
|
|
457
|
+
pages_io.preflight()
|
|
458
|
+
if _pages_open(target, open_it=False):
|
|
459
|
+
from iwork_studio.keynote_slides import DocumentOpenError
|
|
460
|
+
|
|
461
|
+
raise DocumentOpenError(f"{target.name} is open in Pages. Save and close it first.")
|
|
462
|
+
phs = _pages_placeholders(target)
|
|
463
|
+
tags = {p["tag"] for p in phs}
|
|
464
|
+
unknown = sorted(set(values) - tags)
|
|
465
|
+
if unknown:
|
|
466
|
+
raise AppOpError(f"no placeholder tagged {unknown}; this document has {sorted(tags)}")
|
|
467
|
+
by_text: dict = {}
|
|
468
|
+
for p in phs:
|
|
469
|
+
by_text.setdefault(p["text"], set()).add(p["tag"])
|
|
470
|
+
clash = sorted({t for p in phs if p["tag"] in values for t in by_text[p["text"]] if t not in values})
|
|
471
|
+
if clash:
|
|
472
|
+
raise AppOpError(f"placeholders {clash} show the same text as ones being filled, so the result "
|
|
473
|
+
f"can't be checked; fill them too")
|
|
474
|
+
before = _pages_texts(target)
|
|
475
|
+
expected = _expected_texts(before, phs, values)
|
|
476
|
+
|
|
477
|
+
name = app_name("Pages")
|
|
478
|
+
if '"' in name:
|
|
479
|
+
raise AppOpError(f"refusing unsafe app name {name!r}")
|
|
480
|
+
pairs = [x for k, v in values.items() for x in (k, v)]
|
|
481
|
+
script = f"""on run argv
|
|
482
|
+
tell application "{name}"
|
|
483
|
+
{_PAGES_FIND} try
|
|
484
|
+
repeat with i from 2 to (count of argv) by 2
|
|
485
|
+
set t to item i of argv
|
|
486
|
+
set v to item (i + 1) of argv
|
|
487
|
+
tell d to set (every placeholder text whose tag is t) to v
|
|
488
|
+
end repeat
|
|
489
|
+
save d
|
|
490
|
+
on error errMsg number errNum
|
|
491
|
+
close d saving no
|
|
492
|
+
error errMsg number errNum
|
|
493
|
+
end try
|
|
494
|
+
close d saving no
|
|
495
|
+
end tell
|
|
496
|
+
end run"""
|
|
497
|
+
bdir = Path(backup_dir) if backup_dir else target.parent / f"{target.name}.backups"
|
|
498
|
+
backup = pages_io._versioned_backup(target, bdir)
|
|
499
|
+
pages_io._prune_backups(bdir, max_backups)
|
|
500
|
+
try:
|
|
501
|
+
if _pages_open(target):
|
|
502
|
+
raise RuntimeError(f"{target.name} was opened in Pages meanwhile; nothing written")
|
|
503
|
+
r = subprocess.run(["osascript", "-e", script, str(target), *pairs], capture_output=True, text=True, timeout=300)
|
|
504
|
+
if r.returncode != 0:
|
|
505
|
+
raise RuntimeError(f"AppleScript failed (rc={r.returncode}): {r.stderr.strip()[:400]}")
|
|
506
|
+
after = _pages_texts(target)
|
|
507
|
+
if after["body"] != expected["body"]:
|
|
508
|
+
raise pages_io.EditVerificationError("body text after filling is not the original with just the "
|
|
509
|
+
"placeholders filled — rolled back")
|
|
510
|
+
if expected["items"] is not None and after["items"] is not None and after["items"] != expected["items"]:
|
|
511
|
+
raise pages_io.EditVerificationError("text boxes after filling are not the originals with just the "
|
|
512
|
+
"placeholders filled — rolled back")
|
|
513
|
+
now = _pages_placeholders(target)
|
|
514
|
+
left = [p for p in now if p["tag"] in values and p["text"] != values[p["tag"]]]
|
|
515
|
+
if left:
|
|
516
|
+
raise pages_io.EditVerificationError(f"placeholders still unfilled: {[p['tag'] for p in left]} — rolled back")
|
|
517
|
+
others = lambda xs: Counter((p["tag"], p["text"]) for p in xs if p["tag"] not in values) # noqa: E731
|
|
518
|
+
if others(now) != others(phs):
|
|
519
|
+
gone, new = others(phs) - others(now), others(now) - others(phs)
|
|
520
|
+
raise pages_io.EditVerificationError(
|
|
521
|
+
f"other placeholders changed — rolled back (before: {sorted(gone)[:5]}, after: {sorted(new)[:5]})")
|
|
522
|
+
except Exception:
|
|
523
|
+
_pages_close(target) # never leave our window open over the restored file
|
|
524
|
+
_restore(target, backup)
|
|
525
|
+
raise
|
|
526
|
+
return {"ok": True, "file": str(target), "filled": sorted(values), "backup": str(backup)}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ── Pages tables (existing tables; AppleScript only — JXA can't see them) ────
|
|
530
|
+
|
|
531
|
+
_CELL = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,4}$")
|
|
532
|
+
_MAX_TABLE_CELLS = 10000
|
|
533
|
+
|
|
534
|
+
_PAGES_TABLE_HELPERS = """
|
|
535
|
+
property US : character id 31
|
|
536
|
+
property RS : character id 30
|
|
537
|
+
|
|
538
|
+
on pad(n)
|
|
539
|
+
set n to n as integer
|
|
540
|
+
if n < 10 then return "0" & (n as text)
|
|
541
|
+
return n as text
|
|
542
|
+
end pad
|
|
543
|
+
|
|
544
|
+
on ser(v)
|
|
545
|
+
if v is missing value then return "e" & US
|
|
546
|
+
set k to class of v
|
|
547
|
+
if k is integer or k is real then return "n" & US & (v as text)
|
|
548
|
+
if k is boolean then return "b" & US & (v as text)
|
|
549
|
+
if k is date then
|
|
550
|
+
set s to time of v
|
|
551
|
+
return "d" & US & ((year of v) as integer as text) & "-" & pad(month of v as integer) & "-" & pad(day of v) & "T" & pad(s div 3600) & ":" & pad((s mod 3600) div 60) & ":" & pad(s mod 60)
|
|
552
|
+
end if
|
|
553
|
+
return "t" & US & (v as text)
|
|
554
|
+
end ser
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
_PAGES_READ_TABLES = """ set out to ""
|
|
558
|
+
set ts to every table of d
|
|
559
|
+
repeat with t in ts
|
|
560
|
+
set nm to name of t
|
|
561
|
+
set rc to row count of t
|
|
562
|
+
set cc to column count of t
|
|
563
|
+
set out to out & "T" & US & nm & US & (rc as text) & US & (cc as text) & RS
|
|
564
|
+
if rc * cc <= (item 3 of argv) as integer then
|
|
565
|
+
set names to name of every cell of t
|
|
566
|
+
set vals to value of every cell of t
|
|
567
|
+
set fvals to formatted value of every cell of t
|
|
568
|
+
set fms to {}
|
|
569
|
+
try
|
|
570
|
+
set fms to formula of every cell of t
|
|
571
|
+
end try
|
|
572
|
+
repeat with i from 1 to count of names
|
|
573
|
+
set f to ""
|
|
574
|
+
try
|
|
575
|
+
set f to item i of fms
|
|
576
|
+
if f is missing value then set f to ""
|
|
577
|
+
end try
|
|
578
|
+
set fv to item i of fvals
|
|
579
|
+
if fv is missing value then set fv to ""
|
|
580
|
+
set out to out & "C" & US & (item i of names) & US & my ser(item i of vals) & US & (fv as text) & US & (f as text) & RS
|
|
581
|
+
end repeat
|
|
582
|
+
end if
|
|
583
|
+
end repeat
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _num(text: str):
|
|
588
|
+
t = text.strip().replace(",", ".")
|
|
589
|
+
f = float(t)
|
|
590
|
+
return int(f) if f.is_integer() and "e" not in t.lower() and abs(f) < 2 ** 53 else f
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _parse_tables(raw: str) -> list[dict]:
|
|
594
|
+
tables: list[dict] = []
|
|
595
|
+
for rec in raw.rstrip("\n").split(_RS):
|
|
596
|
+
parts = rec.split(_US)
|
|
597
|
+
if parts[0] == "T" and len(parts) >= 4:
|
|
598
|
+
tables.append({"index": len(tables) + 1, "name": parts[1], "rows": int(parts[2]),
|
|
599
|
+
"columns": int(parts[3]), "cells": {}})
|
|
600
|
+
elif parts[0] == "C" and len(parts) >= 6 and tables:
|
|
601
|
+
ref, kind, val, shown, formula = parts[1], parts[2], parts[3], parts[4], parts[5]
|
|
602
|
+
value = {"e": None, "n": lambda: _num(val), "b": lambda: val == "true", "d": val, "t": val}[kind]
|
|
603
|
+
value = value() if callable(value) else value
|
|
604
|
+
cell = {"value": value, "shown_as": shown}
|
|
605
|
+
if formula:
|
|
606
|
+
cell["formula"] = formula
|
|
607
|
+
if kind == "d":
|
|
608
|
+
cell["type"] = "date"
|
|
609
|
+
tables[-1]["cells"][ref] = cell
|
|
610
|
+
for t in tables:
|
|
611
|
+
if not t["cells"] and t["rows"] * t["columns"] > 0:
|
|
612
|
+
t["truncated"] = f"more than {_MAX_TABLE_CELLS} cells; not read"
|
|
613
|
+
return tables
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _pages_script(path: Path, inner: str, args: list[str], *, write: bool) -> str:
|
|
617
|
+
"""JXA opens the document (sandbox access); AppleScript finds it by path and runs
|
|
618
|
+
`inner`. Our window is closed afterwards; a document the user had open is refused
|
|
619
|
+
for writes and left open for reads."""
|
|
620
|
+
from iwork_studio import pages_io
|
|
621
|
+
|
|
622
|
+
pages_io._assert_aqua()
|
|
623
|
+
name = app_name("Pages")
|
|
624
|
+
if '"' in name:
|
|
625
|
+
raise AppOpError(f"refusing unsafe app name {name!r}")
|
|
626
|
+
was_open = _pages_open(path)
|
|
627
|
+
if was_open and write:
|
|
628
|
+
from iwork_studio.keynote_slides import DocumentOpenError
|
|
629
|
+
|
|
630
|
+
raise DocumentOpenError(f"{path.name} is open in Pages. Save and close it first.")
|
|
631
|
+
close = "close" if not was_open else "keep"
|
|
632
|
+
script = f"""{_PAGES_TABLE_HELPERS}
|
|
633
|
+
on run argv
|
|
634
|
+
tell application "{name}"
|
|
635
|
+
{_PAGES_FIND} try
|
|
636
|
+
{inner}
|
|
637
|
+
on error errMsg number errNum
|
|
638
|
+
if (item 2 of argv) is "close" then close d saving no
|
|
639
|
+
error errMsg number errNum
|
|
640
|
+
end try
|
|
641
|
+
if (item 2 of argv) is "close" then close d saving no
|
|
642
|
+
return out
|
|
643
|
+
end tell
|
|
644
|
+
end run"""
|
|
645
|
+
r = subprocess.run(["osascript", "-e", script, str(path), close, *map(str, args)],
|
|
646
|
+
capture_output=True, text=True, timeout=300)
|
|
647
|
+
if r.returncode != 0:
|
|
648
|
+
if not was_open:
|
|
649
|
+
_pages_close(path)
|
|
650
|
+
raise RuntimeError(f"AppleScript failed (rc={r.returncode}): {r.stderr.strip()[:400]}")
|
|
651
|
+
return r.stdout
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def read_tables(path) -> dict:
|
|
655
|
+
"""Every table in a .pages document: name, size, and each cell's value, shown text and formula."""
|
|
656
|
+
from iwork_studio import pages_io
|
|
657
|
+
|
|
658
|
+
target = Path(path).resolve()
|
|
659
|
+
if target.suffix.lower() != ".pages":
|
|
660
|
+
raise AppOpError(f"{target.name} is not a .pages document")
|
|
661
|
+
if not target.exists():
|
|
662
|
+
raise FileNotFoundError(target)
|
|
663
|
+
pages_io.preflight()
|
|
664
|
+
tables = _parse_tables(_pages_script(target, _PAGES_READ_TABLES, [_MAX_TABLE_CELLS], write=False))
|
|
665
|
+
return {"file": str(target), "tables": tables}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _same(a, b) -> bool:
|
|
669
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)) and not isinstance(a, bool):
|
|
670
|
+
return abs(float(a) - float(b)) <= 1e-9 * max(1.0, abs(float(a)))
|
|
671
|
+
return (a in (None, "") and b in (None, "")) or a == b
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def set_table_cells(path, table, cells: dict, *, backup_dir=None, max_backups: int = 10) -> dict:
|
|
675
|
+
"""Write cells of an existing table: table = name or 1-based number; cells = {"B2": 1200,
|
|
676
|
+
"C3": "تم", "D9": "=SUM(D2:D8)"}. Numbers stay numbers; "=…" makes a formula.
|
|
677
|
+
Every other cell of every table, and the body text, are checked unchanged."""
|
|
678
|
+
from iwork_studio import pages_io
|
|
679
|
+
|
|
680
|
+
target = Path(path).resolve()
|
|
681
|
+
if target.suffix.lower() != ".pages":
|
|
682
|
+
raise AppOpError(f"{target.name} is not a .pages document")
|
|
683
|
+
if not target.exists():
|
|
684
|
+
raise FileNotFoundError(target)
|
|
685
|
+
if not cells or len(cells) > 500:
|
|
686
|
+
raise AppOpError("give 1–500 cells, like {\"B2\": 1200}")
|
|
687
|
+
plan = {}
|
|
688
|
+
for ref, v in cells.items():
|
|
689
|
+
r = str(ref).strip().upper()
|
|
690
|
+
if not _CELL.match(r):
|
|
691
|
+
raise AppOpError(f"bad cell {ref!r}; use A1-style references like B2")
|
|
692
|
+
if isinstance(v, bool) or not (v is None or isinstance(v, (int, float, str))):
|
|
693
|
+
raise AppOpError(f"{r}: values must be text, a number, a formula (\"=…\") or null to clear")
|
|
694
|
+
if isinstance(v, (int, float)):
|
|
695
|
+
_as_number(v) # validates finite
|
|
696
|
+
plan[r] = v
|
|
697
|
+
pages_io.preflight()
|
|
698
|
+
if _pages_open(target, open_it=False):
|
|
699
|
+
from iwork_studio.keynote_slides import DocumentOpenError
|
|
700
|
+
|
|
701
|
+
raise DocumentOpenError(f"{target.name} is open in Pages. Save and close it first.")
|
|
702
|
+
|
|
703
|
+
before = read_tables(target)["tables"]
|
|
704
|
+
named = [t["index"] for t in before if t["name"] == str(table)]
|
|
705
|
+
if not named and (isinstance(table, int) or str(table).isdigit()):
|
|
706
|
+
idx = int(table)
|
|
707
|
+
if not 1 <= idx <= len(before):
|
|
708
|
+
raise AppOpError(f"table {table} out of range (document has {len(before)} tables)")
|
|
709
|
+
else:
|
|
710
|
+
hits = named
|
|
711
|
+
if len(hits) != 1:
|
|
712
|
+
raise AppOpError(f"{len(hits)} tables named {table!r}; tables: {[t['name'] for t in before]} "
|
|
713
|
+
"(pass the table number instead)")
|
|
714
|
+
idx = hits[0]
|
|
715
|
+
tb = before[idx - 1]
|
|
716
|
+
if tb.get("truncated"):
|
|
717
|
+
raise AppOpError(f"table {tb['name']!r} is too large to verify ({tb['rows']}×{tb['columns']})")
|
|
718
|
+
missing = [r for r in plan if r not in tb["cells"]]
|
|
719
|
+
if missing:
|
|
720
|
+
raise AppOpError(f"{missing[:5]} outside table {tb['name']!r} ({tb['rows']} rows × {tb['columns']} columns)")
|
|
721
|
+
try:
|
|
722
|
+
body_before = pages_io.read_body_text(target)
|
|
723
|
+
except pages_io.OutOfScopeError:
|
|
724
|
+
body_before = None # page layout
|
|
725
|
+
|
|
726
|
+
lines, args = [], []
|
|
727
|
+
for r, v in plan.items():
|
|
728
|
+
if isinstance(v, (int, float)):
|
|
729
|
+
rhs = _as_number(v)
|
|
730
|
+
else:
|
|
731
|
+
args.append("" if v is None else v)
|
|
732
|
+
rhs = f"(item {2 + len(args)} of argv)"
|
|
733
|
+
lines.append(f' set value of cell "{r}" of table {idx} of d to {rhs}')
|
|
734
|
+
inner = "\n".join(lines) + "\n save d\n set out to \"ok\""
|
|
735
|
+
|
|
736
|
+
bdir = Path(backup_dir) if backup_dir else target.parent / f"{target.name}.backups"
|
|
737
|
+
backup = pages_io._versioned_backup(target, bdir)
|
|
738
|
+
pages_io._prune_backups(bdir, max_backups)
|
|
739
|
+
try:
|
|
740
|
+
_pages_script(target, inner, args, write=True)
|
|
741
|
+
after = read_tables(target)["tables"]
|
|
742
|
+
if [(t["name"], t["rows"], t["columns"]) for t in after] != [(t["name"], t["rows"], t["columns"]) for t in before]:
|
|
743
|
+
raise pages_io.EditVerificationError("tables were added, removed or resized — rolled back")
|
|
744
|
+
for t_b, t_a in zip(before, after):
|
|
745
|
+
for ref, cb in t_b["cells"].items():
|
|
746
|
+
ca = t_a["cells"].get(ref, {})
|
|
747
|
+
if t_b["index"] == idx and ref in plan:
|
|
748
|
+
want = plan[ref]
|
|
749
|
+
if isinstance(want, str) and want.startswith("="):
|
|
750
|
+
if not ca.get("formula"):
|
|
751
|
+
raise pages_io.EditVerificationError(f"{ref} did not become a formula — rolled back")
|
|
752
|
+
elif not _same(ca.get("value"), want):
|
|
753
|
+
hint = (" (Pages turns text that looks like a number or date into one; pass a real "
|
|
754
|
+
"number, or different text)" if isinstance(want, str) else "")
|
|
755
|
+
raise pages_io.EditVerificationError(
|
|
756
|
+
f"{ref} reads {ca.get('value')!r}, wanted {want!r}{hint} — rolled back")
|
|
757
|
+
continue
|
|
758
|
+
if cb.get("formula"):
|
|
759
|
+
if ca.get("formula") != cb["formula"]:
|
|
760
|
+
raise pages_io.EditVerificationError(f"formula in {t_b['name']}!{ref} changed — rolled back")
|
|
761
|
+
elif not _same(ca.get("value"), cb.get("value")):
|
|
762
|
+
raise pages_io.EditVerificationError(
|
|
763
|
+
f"{t_b['name']}!{ref} changed ({cb.get('value')!r} → {ca.get('value')!r}) — rolled back")
|
|
764
|
+
if body_before is not None and pages_io.read_body_text(target) != body_before:
|
|
765
|
+
raise pages_io.EditVerificationError("the body text changed — rolled back")
|
|
766
|
+
except Exception:
|
|
767
|
+
_pages_close(target)
|
|
768
|
+
_restore(target, backup)
|
|
769
|
+
raise
|
|
770
|
+
got = after[idx - 1]["cells"]
|
|
771
|
+
return {"ok": True, "file": str(target), "table": tb["name"], "backup": str(backup),
|
|
772
|
+
"cells": {r: got[r] for r in plan}}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ── Keynote: transitions, images, slideshow ───────────────────────────────────
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def set_transition(path, slide: int, effect: str, *, duration: float | None = None, delay: float | None = None,
|
|
779
|
+
automatic: bool | None = None, **kw) -> dict:
|
|
780
|
+
"""Transition into `slide` (1-based). effect: one of TRANSITIONS. duration/delay in seconds;
|
|
781
|
+
automatic=True advances on its own after `delay`."""
|
|
782
|
+
from iwork_studio import keynote_slides as ks
|
|
783
|
+
from iwork_studio import keynote_theme as kt
|
|
784
|
+
|
|
785
|
+
eff = (effect or "").strip().lower()
|
|
786
|
+
if eff in ("none", "no transition"):
|
|
787
|
+
eff = "no transition effect"
|
|
788
|
+
if eff not in TRANSITIONS:
|
|
789
|
+
raise AppOpError(f"unknown effect {effect!r}; choose one of: {', '.join(TRANSITIONS)}")
|
|
790
|
+
for nm, v in (("duration", duration), ("delay", delay)):
|
|
791
|
+
if v is not None and not 0 <= float(v) <= 60:
|
|
792
|
+
raise AppOpError(f"{nm} must be 0–60 seconds")
|
|
793
|
+
|
|
794
|
+
def plan(before):
|
|
795
|
+
n = len(before["slides"])
|
|
796
|
+
if not isinstance(slide, int) or not 1 <= slide <= n:
|
|
797
|
+
raise kt.ThemeError(f"slide {slide!r} out of range (deck has {n})")
|
|
798
|
+
|
|
799
|
+
def expect(b, a):
|
|
800
|
+
kt._same_slide_count(b, a)
|
|
801
|
+
for i, (x, y) in enumerate(zip(b["slides"], a["slides"]), start=1):
|
|
802
|
+
if kt._sig(x) != kt._sig(y) or x.get("images") != y.get("images"):
|
|
803
|
+
raise ks.SlideOpVerificationError(f"slide {i} content changed")
|
|
804
|
+
if i != slide and x.get("transition") != y.get("transition"):
|
|
805
|
+
raise ks.SlideOpVerificationError(f"slide {i} transition changed collaterally")
|
|
806
|
+
t = a["slides"][slide - 1].get("transition") or {}
|
|
807
|
+
if str(t.get("effect", "")).lower().replace("_", " ") != eff:
|
|
808
|
+
raise ks.SlideOpVerificationError(f"transition reads {t.get('effect')!r}, wanted {eff!r}")
|
|
809
|
+
if duration is not None and abs(float(t.get("duration") or 0) - float(duration)) > 0.05:
|
|
810
|
+
raise ks.SlideOpVerificationError(f"duration reads {t.get('duration')!r}, wanted {duration}")
|
|
811
|
+
if automatic is not None and bool(t.get("automatic")) != bool(automatic):
|
|
812
|
+
raise ks.SlideOpVerificationError(f"auto-advance reads {t.get('automatic')!r}")
|
|
813
|
+
|
|
814
|
+
props = {"transitionEffect": eff}
|
|
815
|
+
if duration is not None:
|
|
816
|
+
props["transitionDuration"] = float(duration)
|
|
817
|
+
if delay is not None:
|
|
818
|
+
props["transitionDelay"] = float(delay)
|
|
819
|
+
if automatic is not None:
|
|
820
|
+
props["automaticTransition"] = bool(automatic)
|
|
821
|
+
script = " doc.slides[params.n - 1].transitionProperties = params.props;\n"
|
|
822
|
+
return script, {"n": slide, "props": props}, expect, {
|
|
823
|
+
"slide": slide, "effect": eff, "duration": duration, "delay": delay, "automatic": automatic,
|
|
824
|
+
"previous": before["slides"][slide - 1].get("transition")}
|
|
825
|
+
|
|
826
|
+
return kt._run(path, "set_transition", plan, **kw)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def add_image(path, slide: int, image, *, x: float | None = None, y: float | None = None,
|
|
830
|
+
width: float | None = None, **kw) -> dict:
|
|
831
|
+
"""Place an image file on `slide` (1-based). Position x/y and width in points (height keeps the ratio)."""
|
|
832
|
+
from iwork_studio import keynote_slides as ks
|
|
833
|
+
from iwork_studio import keynote_theme as kt
|
|
834
|
+
|
|
835
|
+
img = Path(image).expanduser().resolve()
|
|
836
|
+
if not img.is_file():
|
|
837
|
+
raise FileNotFoundError(img)
|
|
838
|
+
if img.suffix.lower() not in _IMAGE_EXTS:
|
|
839
|
+
raise AppOpError(f"{img.name}: use an image file ({', '.join(sorted(_IMAGE_EXTS))})")
|
|
840
|
+
if img.stat().st_size > 100 * 1024 * 1024:
|
|
841
|
+
raise AppOpError("image is larger than 100 MB")
|
|
842
|
+
if width is not None and not 1 <= float(width) <= 10000:
|
|
843
|
+
raise AppOpError("width must be 1–10000 pt")
|
|
844
|
+
|
|
845
|
+
def plan(before):
|
|
846
|
+
n = len(before["slides"])
|
|
847
|
+
if not isinstance(slide, int) or not 1 <= slide <= n:
|
|
848
|
+
raise kt.ThemeError(f"slide {slide!r} out of range (deck has {n})")
|
|
849
|
+
if before["slides"][slide - 1].get("images") is None:
|
|
850
|
+
raise kt.ThemeError("this Keynote doesn't report slide images; can't verify, refusing")
|
|
851
|
+
|
|
852
|
+
def expect(b, a):
|
|
853
|
+
kt._same_slide_count(b, a)
|
|
854
|
+
for i, (bx, ax) in enumerate(zip(b["slides"], a["slides"]), start=1):
|
|
855
|
+
if kt._sig(bx) != kt._sig(ax):
|
|
856
|
+
raise ks.SlideOpVerificationError(f"slide {i} text changed")
|
|
857
|
+
want = (bx["images"] or 0) + (1 if i == slide else 0)
|
|
858
|
+
if ax.get("images") != want:
|
|
859
|
+
raise ks.SlideOpVerificationError(f"slide {i} has {ax.get('images')} images, expected {want}")
|
|
860
|
+
|
|
861
|
+
script = (" const s = doc.slides[params.n - 1];\n"
|
|
862
|
+
" const im = app.Image({file: Path(params.image)});\n"
|
|
863
|
+
" s.images.push(im);\n"
|
|
864
|
+
" const placed = s.images[s.images.length - 1];\n"
|
|
865
|
+
" if (params.width !== null) placed.width = params.width;\n"
|
|
866
|
+
" if (params.x !== null && params.y !== null) placed.position = {x: params.x, y: params.y};\n")
|
|
867
|
+
return script, {"n": slide, "image": str(img), "x": x, "y": y, "width": width}, expect, {
|
|
868
|
+
"slide": slide, "image": img.name}
|
|
869
|
+
|
|
870
|
+
return kt._run(path, "add_image", plan, **kw)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
CHART_TYPES = {
|
|
874
|
+
"bar": "vertical_bar_2d", "stacked_bar": "stacked_vertical_bar_2d",
|
|
875
|
+
"horizontal_bar": "horizontal_bar_2d", "stacked_horizontal_bar": "stacked_horizontal_bar_2d",
|
|
876
|
+
"line": "line_2d", "area": "area_2d", "stacked_area": "stacked_area_2d",
|
|
877
|
+
"pie": "pie_2d", "scatter": "scatterplot_2d",
|
|
878
|
+
"bar_3d": "vertical_bar_3d", "stacked_bar_3d": "stacked_vertical_bar_3d",
|
|
879
|
+
"horizontal_bar_3d": "horizontal_bar_3d", "stacked_horizontal_bar_3d": "stacked_horizontal_bar_3d",
|
|
880
|
+
"line_3d": "line_3d", "area_3d": "area_3d", "stacked_area_3d": "stacked_area_3d", "pie_3d": "pie_3d",
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _as_number(v) -> str:
|
|
885
|
+
"""A number as an AppleScript literal (plain decimal, no exponent)."""
|
|
886
|
+
from decimal import Decimal
|
|
887
|
+
|
|
888
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or v != v or v in (float("inf"), float("-inf")):
|
|
889
|
+
raise AppOpError(f"{v!r} isn't a finite number")
|
|
890
|
+
d = Decimal(repr(float(v))) if isinstance(v, float) else Decimal(v)
|
|
891
|
+
return format(d, "f")
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def add_chart(path, slide: int, rows: list[str], columns: list[str], data: list[list], *,
|
|
895
|
+
type: str = "bar", group_by: str = "row", **kw) -> dict:
|
|
896
|
+
"""Add a chart to `slide` (1-based) from data: one data row per entry in `rows` (series
|
|
897
|
+
or categories, depending on group_by), one value per entry in `columns`.
|
|
898
|
+
type: bar, stacked_bar, horizontal_bar, stacked_horizontal_bar, line, area,
|
|
899
|
+
stacked_area, pie, scatter (and *_3d). Every slide's text and other charts are checked."""
|
|
900
|
+
from iwork_studio import keynote_slides as ks
|
|
901
|
+
from iwork_studio import keynote_theme as kt
|
|
902
|
+
|
|
903
|
+
kind = CHART_TYPES.get(str(type).lower())
|
|
904
|
+
if not kind:
|
|
905
|
+
raise AppOpError(f"unknown chart type {type!r}; choose one of: {', '.join(CHART_TYPES)}")
|
|
906
|
+
if group_by not in ("row", "column"):
|
|
907
|
+
raise AppOpError('group_by must be "row" or "column"')
|
|
908
|
+
if not rows or not columns:
|
|
909
|
+
raise AppOpError("give at least one row name and one column name")
|
|
910
|
+
if len(rows) > 100 or len(columns) > 100:
|
|
911
|
+
raise AppOpError("at most 100 rows × 100 columns")
|
|
912
|
+
if len(data) != len(rows) or any(len(r) != len(columns) for r in data):
|
|
913
|
+
raise AppOpError(f"data must be {len(rows)} rows × {len(columns)} values (one row per row name)")
|
|
914
|
+
literal = "{" + ", ".join("{" + ", ".join(_as_number(v) for v in r) + "}" for r in data) + "}"
|
|
915
|
+
names = [str(x) for x in rows] + [str(x) for x in columns]
|
|
916
|
+
|
|
917
|
+
def plan(before):
|
|
918
|
+
n = len(before["slides"])
|
|
919
|
+
if not isinstance(slide, int) or not 1 <= slide <= n:
|
|
920
|
+
raise kt.ThemeError(f"slide {slide!r} out of range (deck has {n})")
|
|
921
|
+
if before["slides"][slide - 1].get("charts") is None:
|
|
922
|
+
raise kt.ThemeError("this Keynote doesn't report slide charts; can't verify, refusing")
|
|
923
|
+
|
|
924
|
+
def expect(b, a):
|
|
925
|
+
kt._same_slide_count(b, a)
|
|
926
|
+
for i, (x, y) in enumerate(zip(b["slides"], a["slides"]), start=1):
|
|
927
|
+
if kt._sig(x) != kt._sig(y) or x.get("images") != y.get("images"):
|
|
928
|
+
raise ks.SlideOpVerificationError(f"slide {i} content changed")
|
|
929
|
+
want = (x["charts"] or 0) + (1 if i == slide else 0)
|
|
930
|
+
if y.get("charts") != want:
|
|
931
|
+
raise ks.SlideOpVerificationError(f"slide {i} has {y.get('charts')} charts, expected {want}")
|
|
932
|
+
|
|
933
|
+
# values are embedded as validated number literals; names travel as argv
|
|
934
|
+
body = f""" set n to (item 2 of argv) as integer
|
|
935
|
+
set nr to (item 3 of argv) as integer
|
|
936
|
+
set nc to (item 4 of argv) as integer
|
|
937
|
+
set rn to {{}}
|
|
938
|
+
repeat with i from 1 to nr
|
|
939
|
+
set end of rn to item (4 + i) of argv
|
|
940
|
+
end repeat
|
|
941
|
+
set cn to {{}}
|
|
942
|
+
repeat with i from 1 to nc
|
|
943
|
+
set end of cn to item (4 + nr + i) of argv
|
|
944
|
+
end repeat
|
|
945
|
+
add chart slide n row names rn column names cn data {literal} type {kind} group by chart {group_by}"""
|
|
946
|
+
script = {"applescript": body, "args": [slide, len(rows), len(columns), *names]}
|
|
947
|
+
return script, {}, expect, {"slide": slide, "type": str(type).lower(), "rows": len(rows),
|
|
948
|
+
"columns": len(columns)}
|
|
949
|
+
|
|
950
|
+
return kt._run(path, "add_chart", plan, **kw)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def slideshow(action: str, path=None, *, from_slide: int = 1) -> dict:
|
|
954
|
+
"""start (needs path) · stop · next · previous. Doesn't change the file."""
|
|
955
|
+
act = (action or "").lower()
|
|
956
|
+
if act == "start":
|
|
957
|
+
if not path:
|
|
958
|
+
raise AppOpError("start needs the deck's path")
|
|
959
|
+
target = Path(path).resolve()
|
|
960
|
+
if not target.exists():
|
|
961
|
+
raise FileNotFoundError(target)
|
|
962
|
+
body = """
|
|
963
|
+
let doc = app.documents().find(d => { try { const f = d.file(); return f && f.toString() === params.path; } catch (e) { return false; } });
|
|
964
|
+
if (!doc) doc = app.open(Path(params.path));
|
|
965
|
+
const n = doc.slides().length;
|
|
966
|
+
if (params.from < 1 || params.from > n) return JSON.stringify({error: `slide ${params.from} out of range (1–${n})`});
|
|
967
|
+
app.activate();
|
|
968
|
+
app.start(doc, {from: doc.slides[params.from - 1]});
|
|
969
|
+
return JSON.stringify({playing: app.playing()});"""
|
|
970
|
+
out = _jxa("Keynote", body, {"path": str(target), "from": int(from_slide)})
|
|
971
|
+
if out.get("error"):
|
|
972
|
+
raise AppOpError(out["error"])
|
|
973
|
+
return {"ok": True, "action": "start", "file": str(target), "from_slide": int(from_slide), **out}
|
|
974
|
+
bodies = {
|
|
975
|
+
"stop": " if (app.playing()) app.stop(app.documents[0]);\n return JSON.stringify({playing: app.playing()});",
|
|
976
|
+
"next": " if (!app.playing()) return JSON.stringify({error: 'no slideshow is playing'});\n"
|
|
977
|
+
" app.showNext();\n return JSON.stringify({playing: true});",
|
|
978
|
+
"previous": " if (!app.playing()) return JSON.stringify({error: 'no slideshow is playing'});\n"
|
|
979
|
+
" app.showPrevious();\n return JSON.stringify({playing: true});",
|
|
980
|
+
}
|
|
981
|
+
if act not in bodies:
|
|
982
|
+
raise AppOpError("action must be start, stop, next or previous")
|
|
983
|
+
out = _jxa("Keynote", bodies[act], {})
|
|
984
|
+
if out.get("error"):
|
|
985
|
+
raise AppOpError(out["error"])
|
|
986
|
+
return {"ok": True, "action": act, **out}
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# ── new document from a built-in template ─────────────────────────────────────
|
|
990
|
+
|
|
991
|
+
_KINDS = {".numbers": "Numbers", ".key": "Keynote", ".pages": "Pages"}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def create_document(path, template: str | None = None) -> dict:
|
|
995
|
+
"""New .numbers / .key / .pages from Apple's built-in templates (Keynote: themes).
|
|
996
|
+
The app saves the new document into a temp folder; it's checked, then moved into place."""
|
|
997
|
+
dst = Path(path).expanduser().resolve()
|
|
998
|
+
kind = _KINDS.get(dst.suffix.lower())
|
|
999
|
+
if not kind:
|
|
1000
|
+
raise AppOpError("the new file must end in .numbers, .key or .pages")
|
|
1001
|
+
if dst.exists():
|
|
1002
|
+
raise AppOpError(f"{dst} already exists; choose another name")
|
|
1003
|
+
coll, prop = ("themes", "documentTheme") if kind == "Keynote" else ("templates", "documentTemplate")
|
|
1004
|
+
work = Path(tempfile.mkdtemp(prefix="iwork-new-"))
|
|
1005
|
+
tmp = work / dst.name
|
|
1006
|
+
body = f"""
|
|
1007
|
+
const names = app.{coll}().map(t => t.name());
|
|
1008
|
+
const want = params.template || (names.includes('Blank') ? 'Blank' : (names.includes('Basic White') ? 'Basic White' : names[0]));
|
|
1009
|
+
if (!names.includes(want)) return JSON.stringify({{unknown: want, available: names}});
|
|
1010
|
+
const doc = app.Document({{{prop}: app.{coll}.byName(want)}});
|
|
1011
|
+
app.documents.push(doc);
|
|
1012
|
+
const made = app.documents[0];
|
|
1013
|
+
try {{
|
|
1014
|
+
app.save(made, {{in: Path(params.out)}});
|
|
1015
|
+
}} finally {{
|
|
1016
|
+
app.close(made, {{saving: 'no'}});
|
|
1017
|
+
}}
|
|
1018
|
+
return JSON.stringify({{template: want}});"""
|
|
1019
|
+
try:
|
|
1020
|
+
out = _jxa(kind, body, {"template": template, "out": str(tmp)})
|
|
1021
|
+
if out.get("unknown"):
|
|
1022
|
+
raise AppOpError(f"unknown template {out['unknown']!r}; available: {out['available']}")
|
|
1023
|
+
if not tmp.exists():
|
|
1024
|
+
raise AppOpError(f"{kind} reported success but saved no file")
|
|
1025
|
+
if kind == "Numbers":
|
|
1026
|
+
from iwork_studio.numbers_io import read_numbers
|
|
1027
|
+
|
|
1028
|
+
read_numbers(tmp)
|
|
1029
|
+
elif kind == "Keynote":
|
|
1030
|
+
from iwork_studio.keynote_io import read_key
|
|
1031
|
+
|
|
1032
|
+
read_key(tmp)
|
|
1033
|
+
else:
|
|
1034
|
+
from iwork_studio.pages_io import document_info
|
|
1035
|
+
|
|
1036
|
+
document_info(tmp) # opens in Pages; page-layout templates have no body text
|
|
1037
|
+
if dst.exists():
|
|
1038
|
+
raise AppOpError(f"{dst} appeared while creating; not overwriting")
|
|
1039
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1040
|
+
shutil.move(str(tmp), str(dst))
|
|
1041
|
+
finally:
|
|
1042
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
1043
|
+
return {"ok": True, "file": str(dst), "app": kind, "template": out["template"]}
|