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.
@@ -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"]}