glideshow 0.1.2__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.
Files changed (103) hide show
  1. glideshow/__init__.py +15 -0
  2. glideshow/__main__.py +3 -0
  3. glideshow/agent.py +681 -0
  4. glideshow/build.py +54 -0
  5. glideshow/cli.py +320 -0
  6. glideshow/comments.py +35 -0
  7. glideshow/decks.py +230 -0
  8. glideshow/docs/agents.md +223 -0
  9. glideshow/docs/deck-format.md +125 -0
  10. glideshow/editor/.assetsignore +5 -0
  11. glideshow/editor/apple-touch-icon.png +0 -0
  12. glideshow/editor/data/font-metrics.json +1 -0
  13. glideshow/editor/data/fonts.json +1 -0
  14. glideshow/editor/data/icons.json +1 -0
  15. glideshow/editor/data/symbols.js +42 -0
  16. glideshow/editor/data/themes.js +15 -0
  17. glideshow/editor/deck/base.css +92 -0
  18. glideshow/editor/deck/present.js +222 -0
  19. glideshow/editor/deck/shapes.js +377 -0
  20. glideshow/editor/deck/template.html +37 -0
  21. glideshow/editor/editor.css +657 -0
  22. glideshow/editor/glideshow-1024.png +0 -0
  23. glideshow/editor/glideshow.svg +10 -0
  24. glideshow/editor/globals.d.ts +21 -0
  25. glideshow/editor/home.html +358 -0
  26. glideshow/editor/index.html +386 -0
  27. glideshow/editor/js/arrange.js +122 -0
  28. glideshow/editor/js/boot.js +66 -0
  29. glideshow/editor/js/boxes.js +25 -0
  30. glideshow/editor/js/comments.js +85 -0
  31. glideshow/editor/js/context-menus.js +93 -0
  32. glideshow/editor/js/dialogs.js +168 -0
  33. glideshow/editor/js/dock.js +22 -0
  34. glideshow/editor/js/draw.js +107 -0
  35. glideshow/editor/js/file.js +318 -0
  36. glideshow/editor/js/find.js +72 -0
  37. glideshow/editor/js/format-panel.js +101 -0
  38. glideshow/editor/js/geometry.js +153 -0
  39. glideshow/editor/js/guides.js +37 -0
  40. glideshow/editor/js/insert.js +73 -0
  41. glideshow/editor/js/keyboard.js +256 -0
  42. glideshow/editor/js/live.js +48 -0
  43. glideshow/editor/js/menubar.js +290 -0
  44. glideshow/editor/js/menus.js +93 -0
  45. glideshow/editor/js/motion.js +148 -0
  46. glideshow/editor/js/notes.js +63 -0
  47. glideshow/editor/js/objects.js +202 -0
  48. glideshow/editor/js/people.js +73 -0
  49. glideshow/editor/js/phone-comments.js +43 -0
  50. glideshow/editor/js/phone-edit.js +253 -0
  51. glideshow/editor/js/phone-home.js +51 -0
  52. glideshow/editor/js/phone-mode.js +73 -0
  53. glideshow/editor/js/phone-sheets.js +153 -0
  54. glideshow/editor/js/phone-touch.js +145 -0
  55. glideshow/editor/js/phone-view.js +150 -0
  56. glideshow/editor/js/pointer.js +407 -0
  57. glideshow/editor/js/render.js +353 -0
  58. glideshow/editor/js/shortcuts.js +67 -0
  59. glideshow/editor/js/slides.js +120 -0
  60. glideshow/editor/js/spacing.js +23 -0
  61. glideshow/editor/js/state.js +58 -0
  62. glideshow/editor/js/storage.js +250 -0
  63. glideshow/editor/js/text.js +366 -0
  64. glideshow/editor/js/theme-toggle.js +41 -0
  65. glideshow/editor/js/theme.js +173 -0
  66. glideshow/editor/js/toolbar-groups.js +109 -0
  67. glideshow/editor/js/toolbar.js +372 -0
  68. glideshow/editor/lib/browser-db.js +47 -0
  69. glideshow/editor/lib/library.js +612 -0
  70. glideshow/editor/lib/merge.js +174 -0
  71. glideshow/editor/lib/move-local.js +51 -0
  72. glideshow/editor/lib/onefile.js +108 -0
  73. glideshow/editor/lib/store-browser.js +75 -0
  74. glideshow/editor/lib/store-folder.js +109 -0
  75. glideshow/editor/local/sw.js +66 -0
  76. glideshow/editor/local/tsconfig.json +5 -0
  77. glideshow/editor/looks/glider.svg +9 -0
  78. glideshow/editor/looks/thermal-phone.css +144 -0
  79. glideshow/editor/looks/thermal.css +268 -0
  80. glideshow/editor/manifest.json +23 -0
  81. glideshow/editor/phone.css +345 -0
  82. glideshow/editor/share/share.css +124 -0
  83. glideshow/editor/share/share.js +256 -0
  84. glideshow/editor/share/speaker.html +88 -0
  85. glideshow/editor/tsconfig.json +6 -0
  86. glideshow/figure.py +33 -0
  87. glideshow/fonts.py +63 -0
  88. glideshow/gslides.py +1065 -0
  89. glideshow/history.py +210 -0
  90. glideshow/merge.py +210 -0
  91. glideshow/osutil.py +44 -0
  92. glideshow/pptx_export.py +207 -0
  93. glideshow/pptx_import.py +115 -0
  94. glideshow/publish.py +106 -0
  95. glideshow/render.py +288 -0
  96. glideshow/server.py +549 -0
  97. glideshow/service.py +65 -0
  98. glideshow/skills/glideshow/SKILL.md +85 -0
  99. glideshow-0.1.2.dist-info/METADATA +69 -0
  100. glideshow-0.1.2.dist-info/RECORD +103 -0
  101. glideshow-0.1.2.dist-info/WHEEL +4 -0
  102. glideshow-0.1.2.dist-info/entry_points.txt +2 -0
  103. glideshow-0.1.2.dist-info/licenses/LICENSE +662 -0
glideshow/agent.py ADDED
@@ -0,0 +1,681 @@
1
+ """What the `glideshow` command gives an agent writing deck.html by hand, none of which needs the editor open (or touches
2
+ it): the exact placeholder boxes of each layout, a deck's slides without the 180 KB of inlined CSS and JS around them,
3
+ finding a deck, figures that remember the script that made them, and stopping the server.
4
+
5
+ glideshow layout [NAME] [--deck DECK] a layout's slide, ready to paste (no NAME: the table of layouts)
6
+ glideshow slides DECK [N ...] [--outline] the <section>s only, each headed by its number and line in deck.html
7
+ glideshow put DECK N | --after N | --delete N replace, insert or remove slides (the new <section>s on stdin)
8
+ glideshow find WORDS the folder of the deck that best matches (nothing is opened)
9
+ glideshow figure add DECK FILE --slide N [--replace ID | --at body|x,y,w,h] [--id ID] [--alt TEXT] [--source CMD]
10
+ glideshow figure refresh DECK [ID ...] re-run each figure's data-source and put the new picture in place
11
+ glideshow figure size DECK --slide N [--box ID | --at …] the matplotlib figsize / dpi / font sizes for that space
12
+ glideshow stop stop the background server
13
+ """
14
+ import datetime, html as htmllib, json, re, shlex, subprocess, sys, time
15
+ from pathlib import Path
16
+
17
+ from .decks import EDITOR, image_size
18
+
19
+ PT = 192 / 72 # one point on the 1920 px (10 in) canvas, as in Slides
20
+ INSET = "9.6px 19.2px" # Slides' text insets, as the editor writes them (makeBox)
21
+ PROMPT = {"title": "Click to add title", "sub": "Click to add subtitle", "body": "Click to add text"}
22
+ REGION = (65, 242, 1790, 721) # Title and body's body: where a figure goes when the slide has no body placeholder
23
+
24
+
25
+ def layouts(deck: Path | None = None) -> list:
26
+ """[name, label, [[role, x, y, w, h, pt, valign, align], …]] from editor/js/theme.js (the editor's own table), with a
27
+ deck's edited layouts (its theme block's "layouts") in their place."""
28
+ src = (EDITOR / "js" / "theme.js").read_text()
29
+ body = re.search(r"const LAYOUTS = (\[.*?\n\]);", src, re.S)
30
+ if not body: raise SystemExit("couldn't read the layouts from editor/js/theme.js")
31
+ table = json.loads(re.sub(r",\s*\]", "]", body.group(1).replace("'", '"')))
32
+ if deck and (deck / "deck.html").exists():
33
+ m = re.search(r"/\* slides-theme (\{.*?\}) \*/", (deck / "deck.html").read_text())
34
+ try: edited = json.loads(m.group(1)).get("layouts", {}) if m else {}
35
+ except ValueError: edited = {}
36
+ table = [[n, label, edited.get(n, boxes)] for n, label, boxes in table]
37
+ return table
38
+
39
+
40
+ def _styles_like(text: str, n: int) -> dict:
41
+ """The title's and body's text styles on slide n, for a new slide that matches an imported deck (whose sizes, fonts and
42
+ colours sit inline on each box, not in the theme): {role: (box id, .content style, the first text run's style)}. The
43
+ title is the placeholder titled so, else the top text box set largest; the body the biggest other text box."""
44
+ a, z = _slide_span(text, n)
45
+ found = []
46
+ for m in _boxes(text[a:z]):
47
+ at = _attrs(m.group()); cls = at.get("class", "").split()
48
+ if {"image", "note", "group", "video", "embed", "live"} & set(cls) or re.match(r"(line|arrowline|elbow|curve|path)$", at.get("data-shape", "")): continue
49
+ start = a + m.start(); inner = text[start:_close_box(text, start)]
50
+ words = re.sub(r"<[^>]+>", " ", inner).split()
51
+ if not words: continue
52
+ content = re.search(r'<div class="content"[^>]*>', inner)
53
+ run = next((t for t in re.findall(r'<(?:span|p|li|font)\b[^>]*\bstyle="([^"]*)"', inner) if "font" in t or "color" in t), "")
54
+ sizes = [float(v) for v in re.findall(r"font-size:\s*([\d.]+)px", inner)] or [0]
55
+ x, y, w, h = _box_geo(m.group())
56
+ found.append({"id": at.get("id", ""), "role": at.get("data-role"), "content": _attrs(content.group()).get("style", "") if content else "", "run": htmllib.unescape(run), "size": max(sizes), "y": y, "area": w * h})
57
+ if not found: raise SystemExit(f"slide {n} has no text to take styles from")
58
+ title = next((f for f in found if f["role"] == "title"), None) or max([f for f in found if f["y"] < 324] or found, key=lambda f: (f["size"], -f["y"]))
59
+ rest = [f for f in found if f is not title]
60
+ body = next((f for f in rest if f["role"] == "body"), None) or (max(rest, key=lambda f: f["area"]) if rest else None)
61
+ return {"title": title, "sub": body or title, "body": body or title}
62
+
63
+
64
+ def layout_section(name: str, deck: Path | None = None, prefix: str = "", like: int | None = None) -> str:
65
+ """A slide in that layout as the editor makes it: the placeholder boxes with ids (PREFIX-title …; with a deck, ids it
66
+ doesn't use yet), sizes following the theme."""
67
+ lay = next((x for x in layouts(deck) if x[0] == name), None)
68
+ if not lay: raise SystemExit(f"no layout {name!r}; `glideshow layout` lists them")
69
+ taken = (deck / "deck.html").read_text() if deck and (deck / "deck.html").exists() else ""
70
+ if like and not taken: raise SystemExit("--like N needs --deck DECK")
71
+ styles = _styles_like(taken, like) if like else None
72
+ out, seen = [f'<section data-background-color="#ffffff" data-layout="{name}">'], {}
73
+ if styles: out.append(f" <!-- text styled like slide {like}: title as #{styles['title']['id']}, body as #{styles['body']['id']} -->")
74
+ for role, x, y, w, h, pt, valign, align in lay[2]:
75
+ seen[role] = seen.get(role, 0) + 1
76
+ bid = (f"{prefix}-" if prefix else "") + {"sub": "subtitle"}.get(role, role) + (f"-{seen[role]}" if seen[role] > 1 else "")
77
+ if taken: bid = _unique_id(taken, bid); taken += f' id="{bid}"'
78
+ var, base = ("--th-title-size", 28) if role == "title" else ("--th-body-size", 18)
79
+ size = f"calc(var({var}, {round(base * PT, 2):g}px) * {round(pt / base, 3):g})"
80
+ va = f' data-valign="{valign}"' if valign != "middle" else ""
81
+ word = role.upper() if role != "body" else "…"
82
+ if styles: # the deck's own look: that box's .content style (alignment as the layout says) and its text's run style
83
+ st = styles[role]; cstyle = re.sub(r"text-align:\s*[^;]+;?\s*", "", st["content"]).strip().rstrip(";")
84
+ attr = lambda v: v.replace("&", "&amp;").replace('"', "&quot;") # (a font's 'quotes' stay readable)
85
+ inner = f'<span style="{attr(st["run"])}">{word}</span>' if st["run"] else word
86
+ out.append(f' <div class="box" id="{bid}" data-placeholder="{PROMPT[role]}" data-role="{role}"{va} style="left: {x}px; top: {y}px; width: {w}px; height: {h}px;">'
87
+ f'<div class="content" style="{attr(cstyle + "; " if cstyle else "")}text-align: {align};">{inner}</div></div>')
88
+ continue
89
+ out.append(f' <div class="box" id="{bid}" data-placeholder="{PROMPT[role]}" data-role="{role}"{va} style="left: {x}px; top: {y}px; width: {w}px; height: {h}px;">'
90
+ f'<div class="content" style="font-size: {size}; text-align: {align}; padding: {INSET};">{word}</div></div>')
91
+ out.append("</section>")
92
+ return "\n".join(out)
93
+
94
+
95
+ def layout_table() -> str:
96
+ """The layouts as a Markdown table (for the docs: written from the editor's own table, so they never disagree)."""
97
+ rows = ["| layout | placeholder boxes: role (id) at x, y, size; text size; vertical / text alignment |", "|---|---|"]
98
+ for n, label, boxes in layouts():
99
+ seen, cells = {}, []
100
+ for r, x, y, w, h, pt, va, al in boxes:
101
+ seen[r] = seen.get(r, 0) + 1
102
+ bid = {"sub": "subtitle"}.get(r, r) + (f"-{seen[r]}" if seen[r] > 1 else "")
103
+ cells.append(f"{r} (`{bid}`) at {x}, {y}, {w} × {h}; {pt} pt; {va} / {al}")
104
+ rows.append(f"| `{n}` ({label}) | " + ("<br>".join(cells) or "none") + " |")
105
+ return "\n".join(rows)
106
+
107
+
108
+ def layout_cmd(args: list[str]):
109
+ deck = Path(args[args.index("--deck") + 1]).expanduser() if "--deck" in args else None
110
+ names = [a for i, a in enumerate(args) if not a.startswith("--") and (i == 0 or args[i - 1] not in ("--deck", "--prefix", "--like"))]
111
+ like_arg = _opt(args, "--like"); like = slide_number((deck / "deck.html").read_text(), like_arg) if like_arg and deck else (int(like_arg) if like_arg else None)
112
+ if names: return print(layout_section(names[0], deck, _opt(args, "--prefix") or "", like))
113
+ rows = ["layout placeholders: role x,y w×h (pt, vertical align, text align)"]
114
+ for n, label, boxes in layouts(deck):
115
+ rows.append(f"{n:<24}" + (" ".join(f"{r} {x},{y} {w}×{h} ({pt}pt {va} {al})" for r, x, y, w, h, pt, va, al in boxes) or "(none)") + f" [{label}]")
116
+ print("\n".join(rows) + "\n\n`glideshow layout NAME --prefix results [--deck DECK]` prints a slide in that layout, ready to paste into"
117
+ " deck.html, its boxes' ids starting results- (with --deck, ids the deck doesn't use yet): replace each placeholder's text"
118
+ " (keep the boxes and sizes), and give boxes you add their own unique ids.")
119
+
120
+
121
+ # ---------- a deck's slides only ----------
122
+
123
+ def scan(text: str) -> tuple:
124
+ """The deck's slides and what is wrong with its structure: ([{n, a, z, label, stack}, …], [problem, …]). Slides are the
125
+ leaf <section>s inside <div class="slides"> (a stack's sub-slides too), numbered n in order; label is the deck's own
126
+ number (reveal's: main slide 8, its sub-slides 8.1, 8.2); stack is the span of the stack a sub-slide sits in."""
127
+ start = text.find('<div class="slides"')
128
+ if start < 0: return [], []
129
+ line = lambda at: text.count("\n", 0, at) + 1
130
+ depth, end = 0, len(text) # where .slides closes: its own </div>
131
+ for m in re.finditer(r"<div\b[^>]*>|</div>", text[start:]):
132
+ depth += -1 if m.group() == "</div>" else 1
133
+ if depth == 0: end = start + m.end(); break
134
+ slides, problems, open_at, h = [], [], [], 0
135
+ for m in re.finditer(r"<section\b[^>]*>|</section>", text[start:end]):
136
+ at = start + m.start()
137
+ if m.group().startswith("</"):
138
+ if not open_at: problems.append(f"deck.html line {line(at)}: a </section> that closes nothing"); continue
139
+ s = open_at.pop()
140
+ if s["leaf"]:
141
+ stack = (open_at[-1]["a"], None) if open_at else None
142
+ slides.append({"n": len(slides) + 1, "a": s["a"], "z": at + len("</section>"), "label": s["label"], "stack": stack})
143
+ elif not open_at: # a stack closing: its sub-slides learn where it ends
144
+ for sl in slides:
145
+ if sl["stack"] and sl["stack"][0] == s["a"]: sl["stack"] = (s["a"], at + len("</section>"))
146
+ else:
147
+ if open_at:
148
+ parent = open_at[-1]; parent["leaf"] = False
149
+ v = parent["kids"]; parent["kids"] += 1
150
+ label = parent["label"] + (f".{v}" if v else "")
151
+ else:
152
+ h += 1; label = str(h)
153
+ open_at.append({"a": at, "leaf": True, "kids": 0, "label": label})
154
+ for s in open_at: problems.append(f"deck.html line {line(s['a'])}: a <section> that is never closed")
155
+ for sl in slides:
156
+ body = text[sl["a"]:sl["z"]]
157
+ extra = len(re.findall(r"<div\b", body)) - body.count("</div>")
158
+ if extra: problems.append(f"slide {sl['n']} (deck.html line {line(sl['a'])}): {abs(extra)} <div> {'never closed' if extra > 0 else 'closed too often'}; boxes after it land on the wrong slide or outside the deck")
159
+ tail = text[end:]; tail = tail[:min([i for i in (tail.find("<script"), tail.find("</body>")) if i >= 0] or [len(tail)])]
160
+ if "<section" in tail: problems.append(f"deck.html line {line(end)}: {tail.count('<section')} <section>s after the end of the slides (<div class=\"slides\"> closed early): they never show; remove them, or move them inside")
161
+ return slides, problems
162
+
163
+
164
+ def sections(text: str) -> list:
165
+ """[(number, start offset, end offset), …] for each slide: top-level <section>s, and the inner ones of a stack."""
166
+ return [(s["n"], s["a"], s["z"]) for s in scan(text)[0]]
167
+
168
+
169
+ def slide_number(text: str, arg: str) -> int:
170
+ """A slide named by its number in the file (9) or the deck's own (8.1, a stack's sub-slide), as a number in the file."""
171
+ if re.fullmatch(r"\d+", arg): return int(arg)
172
+ hit = next((s["n"] for s in scan(text)[0] if s["label"] == arg), None)
173
+ if hit is None: raise SystemExit(f"there is no slide {arg} (`glideshow slides DECK --outline` lists them)")
174
+ return hit
175
+
176
+
177
+ def _name(s: dict) -> str:
178
+ return f"{s['n']}" if s["label"] == str(s["n"]) else f"{s['n']} = {s['label']}"
179
+
180
+
181
+ def _words(html: str, n: int = 40) -> str:
182
+ t = " ".join(htmllib.unescape(re.sub(r"<[^>]+>", " ", html)).split())
183
+ return t[:n] + ("…" if len(t) > n else "")
184
+
185
+
186
+ def outline_line(text: str, s: dict) -> str:
187
+ """One slide in a line or two: its numbers, layout, title, then each box's id and first words (pictures by their alt;
188
+ lines counted; notes for the AI in full, as they are instructions)."""
189
+ a, z = s["a"], s["z"]; body = text[a:z]
190
+ lay = re.search(r'\bdata-layout="([^"]+)"', body.split(">", 1)[0])
191
+ boxes, lines, notes, title, bare = [], 0, [], "", 0
192
+ for m in _boxes(body):
193
+ at = _attrs(m.group()); bid = at.get("id", "(no id)"); cls = at.get("class", "").split()
194
+ inner = body[m.start():_close_box(body, m.start())]
195
+ if "note" in cls: notes.append(f" NOTE #{bid}: {_words(inner, 400)}"); continue
196
+ if re.match(r"(line|arrowline|elbow|curve|path)$", at.get("data-shape", "")): lines += 1; continue
197
+ if "image" in cls:
198
+ alt = re.search(r'\balt="([^"]*)"', inner); boxes.append(f"#{bid} [picture: {_words(alt.group(1) if alt else '', 30)}]"); continue
199
+ words = _words(inner)
200
+ if at.get("data-role") == "title" and not title: title = _words(inner, 60); continue
201
+ if not words and at.get("data-role"): boxes.append(f"#{bid} (empty placeholder)"); continue
202
+ if not words: bare += 1; continue # a shape with no words (a dot, a bar): counted, not listed
203
+ boxes.append(f'#{bid} "{words}"')
204
+ extra = ([f"{bare} shape{'s' if bare > 1 else ''} without text"] if bare else []) + ([f"{lines} line{'s' if lines > 1 else ''}"] if lines else []) + (["notes"] if "<aside" in body else [])
205
+ head = f"{_name(s)} {lay.group(1) if lay else '-'}" + (f' "{title}"' if title else "")
206
+ return "\n".join([head + "\n " + " · ".join(boxes + extra) if boxes or extra else head] + notes)
207
+
208
+
209
+ def slides_cmd(args: list[str]):
210
+ if not args: raise SystemExit("usage: glideshow slides DECK [N ...] [--outline]")
211
+ deck = _deck(args[0])
212
+ ensure_server(deck) # so what the agent then writes is kept as versions
213
+ text = (deck / "deck.html").read_text()
214
+ found, problems = scan(text)
215
+ want = {slide_number(text, a) for a in args[1:] if not a.startswith("--")}
216
+ if "--outline" in args:
217
+ print(f"{len(found)} slides (N = the number to use; \"9 = 8.1\": a stack's sub-slide, 8.1 in the editor). Full HTML: glideshow slides {args[0]} N")
218
+ for s in found:
219
+ if want and s["n"] not in want: continue
220
+ if "--outline" in args: print(outline_line(text, s)); continue
221
+ print(f"<!-- slide {_name(s)}: deck.html line {text.count(chr(10), 0, s['a']) + 1} -->\n{text[s['a']:s['z']]}\n")
222
+ if not found: print(f"{deck / 'deck.html'} has no slides")
223
+ for p in problems: print(f"problem: {p}", file=sys.stderr)
224
+
225
+
226
+ PUT_HELP = """glideshow put DECK N replace slide N with the <section>(s) on stdin (or --file F)
227
+ glideshow put DECK --after N insert them after slide N (0: before the first; inside N's stack if it is in one)
228
+ glideshow put DECK --delete N … remove those slides
229
+ glideshow put DECK --all replace every slide with them (a script that builds the whole deck: pipe its output in)
230
+ N is the number `glideshow slides DECK --outline` gives (9, or the deck's own 8.1). The input is only <section>s; it is
231
+ checked before anything is written (every <div> closed, ids unique in the deck, no other slide's data-sid). A replaced
232
+ slide keeps its data-sid if the new one has none. deck.html is written in one go, so an open editor reloads it.
233
+ Boxes may leave their place to put: data-flow="row" (or "column", "grid 3"; then an area, body or x,y,w,h, and "gap N")
234
+ lays out the boxes sharing that value in the layout's body area, equal sizes 40 px apart, and writes their pixels."""
235
+
236
+
237
+ def _top_sections(html: str) -> tuple:
238
+ """The top-level <section>…</section> spans of a fragment, and whatever else is in it (comments aside)."""
239
+ spans, depth, at = [], 0, 0
240
+ for m in re.finditer(r"<section\b[^>]*>|</section>", html):
241
+ if m.group().startswith("</"):
242
+ depth -= 1
243
+ if depth == 0: spans.append((at, m.end()))
244
+ if depth < 0: raise SystemExit("put: a </section> that closes nothing")
245
+ else:
246
+ if depth == 0: at = m.start()
247
+ depth += 1
248
+ if depth: raise SystemExit("put: a <section> that is never closed")
249
+ rest, last = [], 0
250
+ for a, z in spans: rest.append(html[last:a]); last = z
251
+ rest.append(html[last:])
252
+ return spans, re.sub(r"<!--.*?-->", "", "".join(rest), flags=re.S).strip()
253
+
254
+
255
+ def arrange(html: str) -> str:
256
+ """Boxes with data-flow, solved into plain left/top/width/height (put does this before writing, so the deck itself
257
+ only ever holds pixels). data-flow="row|column|grid C [AREA] [gap N]": the boxes of a slide sharing one data-flow
258
+ value share its AREA (body, the layout's body area, the default; or x,y,w,h) in that order: side by side, one under
259
+ another, or C to a row; equal sizes, N px apart (default 40)."""
260
+ def one(sec: str) -> str:
261
+ lay = re.search(r'\bdata-layout="([\w-]+)"', sec.split(">", 1)[0])
262
+ body = next((b[1:5] for n, _, boxes in layouts() if lay and n == lay.group(1) for b in boxes if b[0] == "body"), REGION)
263
+ groups = {}
264
+ for m in _boxes(sec):
265
+ flow = _attrs(m.group()).get("data-flow")
266
+ if flow: groups.setdefault(flow, []).append(m.group())
267
+ for flow, tags in groups.items():
268
+ words = flow.split()
269
+ kind, cols = words[0], int(words[1]) if words[0] == "grid" and len(words) > 1 and words[1].isdigit() else 0
270
+ if kind not in ("row", "column", "grid") or (kind == "grid" and not cols): raise SystemExit(f'put: data-flow="{flow}": row, column or grid C, then an area (body or x,y,w,h) and gap N, as in "row body gap 40"')
271
+ rest = words[2 if cols else 1:]
272
+ gap = float(rest[rest.index("gap") + 1]) if "gap" in rest and rest.index("gap") + 1 < len(rest) else 40
273
+ area = next((w for w in rest if re.fullmatch(r"[\d.]+(,[\d.]+){3}", w)), None)
274
+ x, y, w, h = (float(v) for v in area.split(",")) if area else body
275
+ n = len(tags); c = n if kind == "row" else 1 if kind == "column" else cols; r = -(-n // c)
276
+ cw, ch = (w - gap * (c - 1)) / c, (h - gap * (r - 1)) / r
277
+ for k, tag in enumerate(tags):
278
+ geo = f"left: {round(x + (k % c) * (cw + gap))}px; top: {round(y + (k // c) * (ch + gap))}px; width: {round(cw)}px; height: {round(ch)}px;"
279
+ style = re.sub(r"\b(left|top|width|height):\s*[^;]*;?\s*", "", _attrs(tag).get("style", "")).strip()
280
+ new = re.sub(r'\s+data-flow="[^"]*"', "", tag)
281
+ new = re.sub(r'\sstyle="[^"]*"', "", new)[:-1] + f' style="{geo}{" " + style if style else ""}">'
282
+ sec = sec.replace(tag, new, 1)
283
+ return sec
284
+ return re.sub(r"<section\b.*?</section>", lambda m: one(m.group()), html, flags=re.S) if "data-flow" in html else html
285
+
286
+
287
+ def put_cmd(args: list[str]):
288
+ if not args or args[0] in ("-h", "--help", "help"): return print(PUT_HELP)
289
+ deck = _deck(args[0]); ensure_server(deck)
290
+ text = (deck / "deck.html").read_text()
291
+ found, problems = scan(text)
292
+ if problems: raise SystemExit("put: deck.html has structure problems; fix those first (by hand):\n " + "\n ".join(problems))
293
+ rest = [a for i, a in enumerate(args[1:], 1) if args[i - 1] != "--file" and a not in ("--after", "--delete", "--file", "--all")]
294
+ if "--delete" in args:
295
+ gone = sorted({slide_number(text, a) for a in rest}, reverse=True)
296
+ if not gone: raise SystemExit("usage: glideshow put DECK --delete N …")
297
+ if any(n > len(found) for n in gone): raise SystemExit(f"put: the deck has {len(found)} slides")
298
+ cut = []
299
+ for n in gone:
300
+ s = found[n - 1]
301
+ kin = [x for x in found if s["stack"] and x["stack"] == s["stack"]]
302
+ if kin and all(x["n"] in gone for x in kin): cut.append(s["stack"]) # every sub-slide of a stack: the stack goes too
303
+ else: cut.append((s["a"], s["z"]))
304
+ for a, z in sorted(set(cut), reverse=True):
305
+ if text[z:z + 1] == "\n": z += 1
306
+ text = text[:a] + text[z:]
307
+ _write(deck, text)
308
+ return print(f"deleted slide{'s' if len(gone) > 1 else ''} {', '.join(map(str, sorted(gone)))}; the deck has {len(scan(text)[0])} slides")
309
+ src = _opt(args, "--file")
310
+ new = Path(src).expanduser().read_text() if src else sys.stdin.read()
311
+ spans, junk = _top_sections(new)
312
+ if not spans: raise SystemExit("put: no <section> on stdin (or --file)")
313
+ if junk: raise SystemExit(f"put: only <section>s, please; this is outside them: {junk[:120]!r}")
314
+ new = "\n".join(new[a:z].strip() for a, z in spans)
315
+ made, bad = scan(f'<div class="slides">\n{new}\n</div>')
316
+ if bad: raise SystemExit("put: " + "; ".join(re.sub(r"deck\.html line \d+: | \(deck\.html line \d+\)", "", b) for b in bad))
317
+ new = arrange(new)
318
+ for m in reversed(list(_boxes(new))): # every box has its place: attached lines get one at load (written as zeros)
319
+ at = _attrs(m.group()); style = at.get("style", "")
320
+ if all(re.search(rf"\b{k}:\s*-?[\d.]+px", style) for k in ("left", "top", "width", "height")): continue
321
+ if at.get("data-from") and at.get("data-to"):
322
+ tag = re.sub(r'\sstyle="[^"]*"', "", m.group())[:-1] + f' style="left: 0px; top: 0px; width: 0px; height: 0px;{" " + style if style else ""}">'
323
+ new = new[:m.start()] + tag + new[m.end():]
324
+ else: raise SystemExit(f"put: #{at.get('id', '?')} has no place: give it left / top / width / height in px, or data-flow")
325
+ if "--all" in args: # every slide replaced (a script that builds the whole deck pipes it all in)
326
+ if found: a, z = (found[0]["stack"] or (found[0]["a"],))[0], max((x["stack"] or (0, x["z"]))[1] for x in found)
327
+ else: a = z = text.find(">", text.find('<div class="slides"')) + 1
328
+ first = 1
329
+ elif "--after" in args:
330
+ after = _opt(args, "--after")
331
+ if not after: raise SystemExit("usage: glideshow put DECK --after N (0: before the first slide)")
332
+ n = slide_number(text, after) if after != "0" else 0
333
+ if n > len(found): raise SystemExit(f"put: the deck has {len(found)} slides")
334
+ if n: a = z = found[n - 1]["z"]
335
+ elif found: a = z = found[0]["a"]
336
+ else: a = z = text.find(">", text.find('<div class="slides"')) + 1
337
+ first = n + 1
338
+ else:
339
+ if len(rest) != 1: raise SystemExit(PUT_HELP)
340
+ n = slide_number(text, rest[0])
341
+ if not 1 <= n <= len(found): raise SystemExit(f"put: there is no slide {rest[0]} (the deck has {len(found)})")
342
+ a, z = found[n - 1]["a"], found[n - 1]["z"]; first = n
343
+ old_sid = re.search(r'\bdata-sid="([^"]+)"', text[a:a + 400].split(">", 1)[0])
344
+ if old_sid and not re.search(r'\bdata-sid="', new.split(">", 1)[0]):
345
+ new = re.sub(r"<section\b", f'<section data-sid="{old_sid.group(1)}"', new, count=1)
346
+ start = text.find('<div class="slides"'); end = max(x["z"] for x in found) if found else a
347
+ others = text[start:a] + text[z:end]
348
+ ids = re.findall(r'\bid="([^"]+)"', new)
349
+ dup = sorted({i for i in ids if ids.count(i) > 1})
350
+ if dup: raise SystemExit(f"put: these ids are used twice in what you sent: {', '.join(dup)}")
351
+ taken = sorted({i for i in ids if re.search(rf'\bid="{re.escape(i)}"', others)})
352
+ if taken: raise SystemExit(f"put: the deck already has boxes with these ids: {', '.join(taken)} (ids are unique in the whole deck; give the new slide's boxes a prefix of their own)")
353
+ sids = [x for x in re.findall(r'\bdata-sid="([^"]+)"', new) if f'data-sid="{x}"' in others]
354
+ if sids: raise SystemExit(f"put: data-sid {', '.join(sids)} belongs to another slide; leave data-sid out of new slides")
355
+ pre = "" if text[:a].endswith("\n") else "\n"
356
+ text = text[:a] + pre + new + ("\n" if a == z and not text[z:].startswith("\n") else "") + text[z:]
357
+ _write(deck, text)
358
+ now = scan(text)[0]
359
+ mine = now[first - 1:first - 1 + len(made)]
360
+ for s in mine: print(outline_line(text, s))
361
+ print(f"{'inserted' if '--after' in args else 'replaced every slide' if '--all' in args else 'replaced'}; `glideshow check {args[0]} {' '.join(str(s['n']) for s in mine)}` checks {'them' if len(mine) > 1 else 'it'}")
362
+
363
+
364
+ # ---------- figures ----------
365
+
366
+ def _deck(arg: str) -> Path:
367
+ from .cli import match
368
+ p = Path(arg).expanduser()
369
+ return p if (p / "deck.html").exists() else match(arg)
370
+
371
+
372
+ def _opt(args: list[str], name: str, default=None):
373
+ return args[args.index(name) + 1] if name in args and args.index(name) + 1 < len(args) else default
374
+
375
+
376
+ def _slide_span(text: str, n: int) -> tuple:
377
+ found = sections(text)
378
+ if not found: raise SystemExit("the deck has no slides")
379
+ if not 1 <= n <= len(found): raise SystemExit(f"there is no slide {n} (the deck has {len(found)})")
380
+ return found[n - 1][1:]
381
+
382
+
383
+ def _box_geo(tag: str) -> tuple:
384
+ g = {k: float(re.search(rf"\b{k}:\s*(-?[\d.]+)px", tag).group(1)) for k in ("left", "top", "width", "height")}
385
+ return g["left"], g["top"], g["width"], g["height"]
386
+
387
+
388
+ def _attrs(tag: str) -> dict:
389
+ return {k: htmllib.unescape(v) for k, v in re.findall(r'([\w-]+)="([^"]*)"', tag)}
390
+
391
+
392
+ def _boxes(text: str):
393
+ """Each box's opening tag (any attribute order)."""
394
+ for m in re.finditer(r"<div\b[^>]*>", text):
395
+ if "box" in _attrs(m.group()).get("class", "").split(): yield m
396
+
397
+
398
+ def _box(text: str, box_id: str) -> re.Match:
399
+ m = next((m for m in _boxes(text) if _attrs(m.group()).get("id") == box_id), None)
400
+ if not m: raise SystemExit(f"no box with id {box_id!r} in the deck")
401
+ return m
402
+
403
+
404
+ def _region(text: str, a: int, z: int, at: str | None) -> tuple:
405
+ """Where a figure goes: --at x,y,w,h, or the slide's body placeholder (its layout's body area), else Title and body's."""
406
+ if at and at != "body":
407
+ try: x, y, w, h = (float(v) for v in at.split(","))
408
+ except ValueError: raise SystemExit("--at takes body or x,y,w,h (px on the 1920 × 1080 canvas)")
409
+ return x, y, w, h
410
+ body = next((m for m in _boxes(text[a:z]) if _attrs(m.group()).get("data-role") == "body"), None)
411
+ if body: return _box_geo(body.group())
412
+ lay = re.search(r'data-layout="([\w-]+)"', text[a:a + 300])
413
+ for n, _, boxes in layouts():
414
+ if lay and n == lay.group(1):
415
+ for role, x, y, w, h, *_ in boxes:
416
+ if role == "body": return x, y, w, h
417
+ return REGION
418
+
419
+
420
+ def _fit(w0: float, h0: float, region: tuple) -> tuple:
421
+ x, y, w, h = region
422
+ k = min(w / w0, h / h0); fw, fh = round(w0 * k), round(h0 * k)
423
+ return round(x + (w - fw) / 2), round(y + (h - fh) / 2), fw, fh
424
+
425
+
426
+ def _unique_id(text: str, want: str) -> str:
427
+ base = re.sub(r"[^A-Za-z0-9_-]+", "-", want).strip("-").lower() or "figure"
428
+ if base[0].isdigit(): base = "fig-" + base
429
+ bid, n = base, 2
430
+ while re.search(rf'\bid="{re.escape(bid)}"', text): bid, n = f"{base}-{n}", n + 1
431
+ return bid
432
+
433
+
434
+ def _write(deck: Path, text: str):
435
+ tmp = deck / "deck.html.tmp"; tmp.write_text(text); tmp.replace(deck / "deck.html") # one write, as the editor expects
436
+
437
+
438
+ def figure_add(args: list[str]):
439
+ if len(args) < 2: raise SystemExit("usage: glideshow figure add DECK FILE --slide N [--replace ID | --at body|x,y,w,h] [--id ID] [--alt TEXT] [--source CMD]")
440
+ deck, src = _deck(args[0]), Path(args[1]).expanduser()
441
+ if not src.is_absolute() and not src.is_file() and (deck / src).is_file(): src = deck / src # a path inside the deck (assets/x.png)
442
+ if not src.is_file(): raise SystemExit(f"no file {args[1]} (here or in {deck})")
443
+ text, data = (deck / "deck.html").read_text(), src.read_bytes()
444
+ replace, slide = _opt(args, "--replace"), _opt(args, "--slide")
445
+ (deck / "assets").mkdir(exist_ok=True)
446
+ old = _box(text, replace) if replace else None
447
+ old_src = None
448
+ if old:
449
+ tail = text[old.end():old.end() + 2000]
450
+ m = re.search(r'<img\b[^>]*\bsrc="([^"]+)"', tail); old_src = m.group(1) if m else None
451
+ # the file keeps its own name (a figure's name says what it is); a different picture of that name gets -2, -3…
452
+ stem, ext = re.sub(r"[^A-Za-z0-9_.-]+", "-", src.stem) or "figure", src.suffix.lower() or ".png"
453
+ name, n = f"{stem}{ext}", 2
454
+ while (deck / "assets" / name).exists() and (deck / "assets" / name).read_bytes() != data and f"assets/{name}" != old_src:
455
+ name, n = f"{stem}-{n}{ext}", n + 1
456
+ if (deck / "assets" / name).resolve() != src.resolve(): (deck / "assets" / name).write_bytes(data)
457
+ side = src.with_name(src.name + ".json") # where the figure's labels are (glideshow.figure.save), for check
458
+ if side.is_file() and side.resolve() != (deck / "assets" / f"{name}.json").resolve(): (deck / "assets" / f"{name}.json").write_bytes(side.read_bytes())
459
+ w0, h0 = image_size(data) or (1600, 900)
460
+ alt = _opt(args, "--alt") or re.sub(r"[-_]+", " ", src.stem).strip().capitalize()
461
+ source, made = _opt(args, "--source"), datetime.datetime.now().isoformat(timespec="seconds")
462
+ if old: # in place: the old box's spot, the new picture fitted inside it; its id, alt and source carried over unless given
463
+ a = text.rfind("<section", 0, old.start())
464
+ close = _close_box(text, old.start())
465
+ tag = old.group()
466
+ x, y, w, h = _fit(w0, h0, _box_geo(tag))
467
+ keep_alt = re.search(r'\balt="([^"]*)"', text[old.start():close])
468
+ alt = _opt(args, "--alt") or (htmllib.unescape(keep_alt.group(1)) if keep_alt and keep_alt.group(1) else alt)
469
+ source = source or _attrs(tag).get("data-source")
470
+ new = _figure_html(replace, x, y, w, h, name, alt, source, made, indent="")
471
+ text = text[:old.start()] + new + text[close:]
472
+ _write(deck, text)
473
+ if old_src and old_src != f"assets/{name}" and old_src.startswith("assets/") and old_src not in text: (deck / old_src).unlink(missing_ok=True)
474
+ return print(f"replaced #{replace} on slide {_slide_of(text, a)} with assets/{name} ({w}×{h} at {x},{y})")
475
+ n_slide = slide_number(text, slide) if slide else len(sections(text))
476
+ a, z = _slide_span(text, n_slide)
477
+ x, y, w, h = _fit(w0, h0, _region(text, a, z, _opt(args, "--at")))
478
+ bid = _unique_id(text, _opt(args, "--id") or stem)
479
+ box = _figure_html(bid, x, y, w, h, name, alt, source, made)
480
+ notes = text.rfind("<aside", a, z)
481
+ at = notes if notes > 0 else z - len("</section>")
482
+ text = text[:at] + box + text[at:]
483
+ _write(deck, text)
484
+ print(f"added #{bid} (assets/{name}, {w}×{h} at {x},{y}) to slide {n_slide}")
485
+
486
+
487
+ def _figure_html(bid, x, y, w, h, name, alt, source, made, indent=" ") -> str:
488
+ attrs = f' data-source="{htmllib.escape(source)}"' if source else ""
489
+ return (f'{indent}<div class="box image" id="{bid}"{attrs} data-made="{made}" style="left: {x}px; top: {y}px; width: {w}px; height: {h}px;">'
490
+ f'<div class="content"><img src="assets/{name}" alt="{htmllib.escape(alt)}"></div></div>' + ("\n" if indent else ""))
491
+
492
+
493
+ def _close_box(text: str, start: int) -> int:
494
+ """The end of the box element that opens at `start` (counting nested divs)."""
495
+ depth = 0
496
+ for m in re.finditer(r"<div\b[^>]*>|</div>", text[start:]):
497
+ depth += -1 if m.group() == "</div>" else 1
498
+ if depth == 0: return start + m.end()
499
+ raise SystemExit("the box isn't closed in deck.html")
500
+
501
+
502
+ def _slide_of(text: str, at: int) -> int:
503
+ return next((n for n, a, z in sections(text) if a <= at < z), 0)
504
+
505
+
506
+ def figure_refresh(args: list[str]):
507
+ """Each figure's data-source run again in the deck's folder ({out} is its picture's path), then put back in place."""
508
+ if not args: raise SystemExit("usage: glideshow figure refresh DECK [ID ...]")
509
+ deck, ids = _deck(args[0]), {a for i, a in enumerate(args[1:], 1) if not a.startswith("--") and args[i - 1] != "--alt"}
510
+ text = (deck / "deck.html").read_text()
511
+ figs = [(m, _attrs(m.group())) for m in _boxes(text)]
512
+ figs = [(m, a) for m, a in figs if "image" in a["class"].split() and a.get("data-source") and a.get("id") and (not ids or a["id"] in ids)]
513
+ if not figs: raise SystemExit("no figure with a data-source" + (f" among {', '.join(sorted(ids))}" if ids else "") + " (glideshow figure add … --source CMD records one)")
514
+ failed = 0
515
+ for m, at in figs:
516
+ bid, cmd = at["id"], at["data-source"]
517
+ img = re.search(r'<img\b[^>]*\bsrc="([^"]+)"', text[m.end():m.end() + 2000])
518
+ out = img.group(1) if img else f"assets/{bid}.png"
519
+ run = cmd.replace("{out}", shlex.quote(out))
520
+ print(f"#{bid}: {run}", flush=True)
521
+ before = (deck / out).read_bytes() if (deck / out).is_file() else None
522
+ r = subprocess.run(run, shell=True, cwd=deck, capture_output=True, text=True)
523
+ glyphs = sorted({chr(int(g)) for g in re.findall(r"Glyph (\d+)\b[^\n]*?missing from", r.stdout + r.stderr)})
524
+ if glyphs: print(f"#{bid}: the figure's font has no {' '.join(glyphs)}: they are drawn as empty boxes (use words, or a font that has them)")
525
+ if r.returncode:
526
+ failed += 1; print(f"#{bid}: `{run}` failed (exit {r.returncode}):\n{(r.stderr or r.stdout).strip()[-800:]}", file=sys.stderr); continue
527
+ if not (deck / out).is_file(): failed += 1; print(f"#{bid}: `{run}` ran but wrote no {out}", file=sys.stderr); continue
528
+ side = deck / f"{out}.json" # glideshow.figure.save keeps matplotlib's warnings there
529
+ lacks = sorted(set(json.loads(side.read_text()).get("missing", [])) - set(glyphs)) if side.is_file() and side.stat().st_mtime >= time.time() - 600 else []
530
+ if lacks: print(f"#{bid}: the figure's font has no {' '.join(lacks)}: they are drawn as empty boxes (use words, or a font that has them)")
531
+ new_alt = _opt(args, "--alt")
532
+ if before is not None and not new_alt and _same_picture(before, (deck / out).read_bytes()): print(f"#{bid}: unchanged"); continue
533
+ figure_add([str(deck), str(deck / out), "--replace", bid] + (["--alt", new_alt] if new_alt else []))
534
+ text = (deck / "deck.html").read_text()
535
+ at = _box(text, bid).start(); alt = re.search(r'\balt="([^"]*)"', text[at:_close_box(text, at)])
536
+ if not new_alt and alt: print(f'#{bid}: its alt text is still "{htmllib.unescape(alt.group(1))}"; if the new data changed what it shows, pass --alt "…" (and fix any caption on the slide)')
537
+ if failed: raise SystemExit(1)
538
+
539
+
540
+ def _same_picture(a: bytes, b: bytes) -> bool:
541
+ """Do two pictures have the same pixels? PNGs by their header and image data only (not the metadata a save adds)."""
542
+ def pixels(d: bytes):
543
+ if d[:8] != b"\x89PNG\r\n\x1a\n": return d
544
+ out, i = [], 8
545
+ while i + 8 <= len(d):
546
+ n, kind = int.from_bytes(d[i:i + 4], "big"), d[i + 4:i + 8]
547
+ if kind in (b"IHDR", b"PLTE", b"IDAT"): out.append(d[i + 8:i + 8 + n])
548
+ i += 12 + n
549
+ return b"".join(out)
550
+ return a == b or pixels(a) == pixels(b)
551
+
552
+
553
+ FIGURE_DEP = "glideshow @ git+https://github.com/heyodog0/glideshow" # (PyPI's "glideshow" once it is published there)
554
+
555
+
556
+ def mplstyle(th: dict) -> str:
557
+ """deck.mplstyle: the deck's theme for matplotlib (font, ink, accents, sizes that read on a slide, KaTeX's maths font)."""
558
+ font = (th.get("font") or "Arial").split(",")[0].strip("'\" ")
559
+ ink, title = (th.get("text") or "#595959").lstrip("#"), (th.get("title") or "#000000").lstrip("#")
560
+ return f"""# This deck's look for matplotlib (written by `glideshow figure size`): plt.style.use("deck.mplstyle")
561
+ font.family: sans-serif
562
+ font.sans-serif: {font}, Arial, Helvetica, DejaVu Sans
563
+ font.size: 14
564
+ axes.titlesize: 16
565
+ axes.labelsize: 14
566
+ xtick.labelsize: 12
567
+ ytick.labelsize: 12
568
+ legend.fontsize: 12
569
+ legend.frameon: False
570
+ text.color: {ink}
571
+ axes.labelcolor: {ink}
572
+ axes.titlecolor: {title}
573
+ axes.edgecolor: {ink}
574
+ xtick.color: {ink}
575
+ ytick.color: {ink}
576
+ axes.spines.top: False
577
+ axes.spines.right: False
578
+ axes.prop_cycle: cycler('color', ['1a73e8', 'd93025', '188038', 'f9ab00', '9334e6', '12b5cb', 'e8710a', '5f6368'])
579
+ mathtext.fontset: cm
580
+ savefig.dpi: 384
581
+ """
582
+
583
+
584
+ def figure_size(args: list[str]):
585
+ """matplotlib settings for a figure that fills a space exactly: 1 in of figure = 192 px of slide, so a point in the
586
+ figure is a point on the slide (and the 24 px reading floor is 9 pt)."""
587
+ if not args: raise SystemExit("usage: glideshow figure size DECK --slide N [--box ID | --at body|x,y,w,h]")
588
+ deck = _deck(args[0]); text = (deck / "deck.html").read_text()
589
+ box_id, slide_n = _opt(args, "--box"), slide_number(text, _opt(args, "--slide") or str(len(sections(text))))
590
+ if box_id:
591
+ m = _box(text, box_id); x, y, w, h = _box_geo(m.group()); slide_n = _slide_of(text, m.start())
592
+ else:
593
+ a, z = _slide_span(text, slide_n)
594
+ x, y, w, h = _region(text, a, z, _opt(args, "--at"))
595
+ m = re.search(r"/\* slides-theme (\{.*?\}) \*/", text)
596
+ try: th = json.loads(m.group(1)) if m else {}
597
+ except ValueError: th = {}
598
+ (deck / "deck.mplstyle").write_text(mplstyle(th))
599
+ print(f"""# the space: {w:g}×{h:g} px at {x:g},{y:g} on slide {slide_n}; wrote {deck / "deck.mplstyle"} (the deck's font, colours, sizes)
600
+ # a script in the deck's folder (e.g. src/plot.py, run there by `uv run src/plot.py --out assets/NAME.png`):
601
+ # /// script
602
+ # dependencies = ["matplotlib", "{FIGURE_DEP}"]
603
+ # ///
604
+ import sys, matplotlib.pyplot as plt
605
+ from glideshow.figure import save
606
+ plt.style.use("deck.mplstyle")
607
+ fig, ax = plt.subplots(figsize=({w / 192:.2f}, {h / 192:.2f}), layout="constrained") # 192 px of slide per inch: 1 pt here = 1 pt on the slide
608
+ # … plot … (constrained layout keeps the axis labels inside the figure; no bbox_inches="tight", it changes the shape)
609
+ save(fig, sys.argv[sys.argv.index("--out") + 1]) # the PNG at 2× the slide's pixels, and beside it where its labels are, for glideshow check
610
+ # then: glideshow figure add {deck} assets/NAME.png --slide {slide_n} --at {x:g},{y:g},{w:g},{h:g} --id NAME --alt "WHAT IT SHOWS" --source "uv run src/plot.py --out {{out}}"
611
+ # text under 9 pt (24 px) is hard to read when presenting; 12–16 pt reads well""")
612
+
613
+
614
+ FIGURE_HELP = """glideshow figure add DECK FILE --slide N [--at body|x,y,w,h] [--id ID] [--alt TEXT] [--source CMD]
615
+ place a picture (FILE: a path, or one inside the deck such as assets/x.png), fitted into the slide's body area or
616
+ --at; it keeps its file name, gets an id and alt text, and records --source (the command that makes it: {out} is the
617
+ picture's path, and it runs in the deck's folder)
618
+ glideshow figure add DECK FILE --replace ID [--alt TEXT]
619
+ swap a picture into box ID, fitted inside its place; its id, alt text and source are kept unless given
620
+ glideshow figure refresh DECK [ID ...] [--alt TEXT]
621
+ re-run each figure's recorded source (or only those named) and put the new picture in place
622
+ glideshow figure size DECK --slide N [--box ID | --at body|x,y,w,h]
623
+ matplotlib settings for a figure that fills that space exactly (1 pt in the figure = 1 pt on the slide)"""
624
+
625
+
626
+ def ensure_server(deck: Path):
627
+ """For a deck in the library: the background server, started if it isn't running (nothing is opened or switched). It
628
+ commits each change to a deck as its own version, so an agent's edits are versions even with no editor open."""
629
+ from .cli import HOME, running, start
630
+ if HOME.resolve() not in deck.resolve().parents: return # a folder elsewhere (a test's, a download): nothing to version
631
+ if not running(): start(deck)
632
+
633
+
634
+ def figure_cmd(args: list[str]):
635
+ sub, rest = (args[0], args[1:]) if args else ("", [])
636
+ if sub in ("add", "refresh") and rest: ensure_server(_deck(rest[0]))
637
+ if sub == "add": return figure_add(rest)
638
+ if sub == "refresh": return figure_refresh(rest)
639
+ if sub == "size": return figure_size(rest)
640
+ print(FIGURE_HELP)
641
+ if sub not in ("help", "-h", "--help"): raise SystemExit(1)
642
+
643
+
644
+ def slide_ids(text: str) -> list:
645
+ """Each slide's data-sid (or ""), in order."""
646
+ return [(re.search(r'\bdata-sid="([^"]+)"', text[a:a + 400].split(">", 1)[0]) or [None, ""])[1] for _, a, _ in sections(text)]
647
+
648
+
649
+ def comments_cmd(args: list[str]):
650
+ """The comments in comments.json, each on the slide it is on now (found by the slide's id, so slides added or moved
651
+ since don't misplace it), open ones first: what an agent reads before answering them."""
652
+ if not args: raise SystemExit("usage: glideshow comments DECK [--all] [--json]")
653
+ deck = _deck(args[0]); f = deck / "comments.json"
654
+ items = json.loads(f.read_text()).get("comments", []) if f.exists() else []
655
+ sids = slide_ids((deck / "deck.html").read_text())
656
+ for c in items:
657
+ c["now"] = sids.index(c["sid"]) + 1 if c.get("sid") in sids else c.get("slide")
658
+ shown = [c for c in items if "--all" in args or not c.get("resolved")]
659
+ if "--json" in args: return print(json.dumps(shown, indent=2, ensure_ascii=False))
660
+ if not shown: return print("no open comments" + ("" if "--all" in args else " (--all shows resolved ones)"))
661
+ for c in sorted(shown, key=lambda c: (bool(c.get("resolved")), c.get("now") or 0)):
662
+ moved = f" (left on slide {c['slide']})" if c.get("now") != c.get("slide") else ""
663
+ where = f"slide {c.get('now')}{moved}" + (f", box #{c['box']}" if c.get("box") else "") + (f', on "{c["quote"]}"' if c.get("quote") else "")
664
+ print(f"[{c['id']}] {where} · {(c.get('by') or {}).get('n') or 'someone'} · {'resolved' if c.get('resolved') else 'open'}\n {c.get('text', '')}")
665
+ for r in c.get("replies", []): print(f" ↳ {(r.get('by') or {}).get('n') or 'someone'}: {r.get('text', '')}")
666
+ print(f"\nTo answer: make the change, add a reply to that comment's \"replies\" in {f} and, when done, set \"resolved\": true (see `glideshow docs`).")
667
+
668
+
669
+ def find_cmd(args: list[str]):
670
+ from .cli import match
671
+ if not args: raise SystemExit("usage: glideshow find WORDS")
672
+ print(match(" ".join(args)))
673
+
674
+
675
+ def stop_cmd():
676
+ from .service import live, post
677
+ s = live()
678
+ if not s: return print("no Glideshow server is running")
679
+ try: post(s["url"], "api/quit", {})
680
+ except Exception: pass
681
+ print(f"stopped the server on {s['url']}")