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.
- glideshow/__init__.py +15 -0
- glideshow/__main__.py +3 -0
- glideshow/agent.py +681 -0
- glideshow/build.py +54 -0
- glideshow/cli.py +320 -0
- glideshow/comments.py +35 -0
- glideshow/decks.py +230 -0
- glideshow/docs/agents.md +223 -0
- glideshow/docs/deck-format.md +125 -0
- glideshow/editor/.assetsignore +5 -0
- glideshow/editor/apple-touch-icon.png +0 -0
- glideshow/editor/data/font-metrics.json +1 -0
- glideshow/editor/data/fonts.json +1 -0
- glideshow/editor/data/icons.json +1 -0
- glideshow/editor/data/symbols.js +42 -0
- glideshow/editor/data/themes.js +15 -0
- glideshow/editor/deck/base.css +92 -0
- glideshow/editor/deck/present.js +222 -0
- glideshow/editor/deck/shapes.js +377 -0
- glideshow/editor/deck/template.html +37 -0
- glideshow/editor/editor.css +657 -0
- glideshow/editor/glideshow-1024.png +0 -0
- glideshow/editor/glideshow.svg +10 -0
- glideshow/editor/globals.d.ts +21 -0
- glideshow/editor/home.html +358 -0
- glideshow/editor/index.html +386 -0
- glideshow/editor/js/arrange.js +122 -0
- glideshow/editor/js/boot.js +66 -0
- glideshow/editor/js/boxes.js +25 -0
- glideshow/editor/js/comments.js +85 -0
- glideshow/editor/js/context-menus.js +93 -0
- glideshow/editor/js/dialogs.js +168 -0
- glideshow/editor/js/dock.js +22 -0
- glideshow/editor/js/draw.js +107 -0
- glideshow/editor/js/file.js +318 -0
- glideshow/editor/js/find.js +72 -0
- glideshow/editor/js/format-panel.js +101 -0
- glideshow/editor/js/geometry.js +153 -0
- glideshow/editor/js/guides.js +37 -0
- glideshow/editor/js/insert.js +73 -0
- glideshow/editor/js/keyboard.js +256 -0
- glideshow/editor/js/live.js +48 -0
- glideshow/editor/js/menubar.js +290 -0
- glideshow/editor/js/menus.js +93 -0
- glideshow/editor/js/motion.js +148 -0
- glideshow/editor/js/notes.js +63 -0
- glideshow/editor/js/objects.js +202 -0
- glideshow/editor/js/people.js +73 -0
- glideshow/editor/js/phone-comments.js +43 -0
- glideshow/editor/js/phone-edit.js +253 -0
- glideshow/editor/js/phone-home.js +51 -0
- glideshow/editor/js/phone-mode.js +73 -0
- glideshow/editor/js/phone-sheets.js +153 -0
- glideshow/editor/js/phone-touch.js +145 -0
- glideshow/editor/js/phone-view.js +150 -0
- glideshow/editor/js/pointer.js +407 -0
- glideshow/editor/js/render.js +353 -0
- glideshow/editor/js/shortcuts.js +67 -0
- glideshow/editor/js/slides.js +120 -0
- glideshow/editor/js/spacing.js +23 -0
- glideshow/editor/js/state.js +58 -0
- glideshow/editor/js/storage.js +250 -0
- glideshow/editor/js/text.js +366 -0
- glideshow/editor/js/theme-toggle.js +41 -0
- glideshow/editor/js/theme.js +173 -0
- glideshow/editor/js/toolbar-groups.js +109 -0
- glideshow/editor/js/toolbar.js +372 -0
- glideshow/editor/lib/browser-db.js +47 -0
- glideshow/editor/lib/library.js +612 -0
- glideshow/editor/lib/merge.js +174 -0
- glideshow/editor/lib/move-local.js +51 -0
- glideshow/editor/lib/onefile.js +108 -0
- glideshow/editor/lib/store-browser.js +75 -0
- glideshow/editor/lib/store-folder.js +109 -0
- glideshow/editor/local/sw.js +66 -0
- glideshow/editor/local/tsconfig.json +5 -0
- glideshow/editor/looks/glider.svg +9 -0
- glideshow/editor/looks/thermal-phone.css +144 -0
- glideshow/editor/looks/thermal.css +268 -0
- glideshow/editor/manifest.json +23 -0
- glideshow/editor/phone.css +345 -0
- glideshow/editor/share/share.css +124 -0
- glideshow/editor/share/share.js +256 -0
- glideshow/editor/share/speaker.html +88 -0
- glideshow/editor/tsconfig.json +6 -0
- glideshow/figure.py +33 -0
- glideshow/fonts.py +63 -0
- glideshow/gslides.py +1065 -0
- glideshow/history.py +210 -0
- glideshow/merge.py +210 -0
- glideshow/osutil.py +44 -0
- glideshow/pptx_export.py +207 -0
- glideshow/pptx_import.py +115 -0
- glideshow/publish.py +106 -0
- glideshow/render.py +288 -0
- glideshow/server.py +549 -0
- glideshow/service.py +65 -0
- glideshow/skills/glideshow/SKILL.md +85 -0
- glideshow-0.1.2.dist-info/METADATA +69 -0
- glideshow-0.1.2.dist-info/RECORD +103 -0
- glideshow-0.1.2.dist-info/WHEEL +4 -0
- glideshow-0.1.2.dist-info/entry_points.txt +2 -0
- 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("&", "&").replace('"', """) # (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']}")
|