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/render.py ADDED
@@ -0,0 +1,288 @@
1
+ # /// script
2
+ # dependencies = ["playwright"]
3
+ # ///
4
+ """Render a deck's slides to PNG at 1920×1080 in headless Chrome, and check them: `glideshow render` / `glideshow check` (an
5
+ agent looking at its work) and test/snap.py (references) all run this. A uv script, so Playwright comes with it and the
6
+ package keeps no dependencies.
7
+
8
+ uv run glideshow/render.py DECK_DIR OUT_DIR [N ...] [--check] [--json] [--morph] [--full] [--sheet]
9
+
10
+ Writes OUT_DIR/slide-NN.png (960 × 540; --full: 1920 × 1080) and prints each path (--sheet: contact sheets of nine
11
+ slides each, OUT_DIR/sheet-K.png, instead), then one line per problem found ("slide 3: ..."): text spilling out
12
+ of its box, code cut off, a box off the slide, text overlapping other text (or partly over a picture, or crossed by a
13
+ line), text under 24 px or hard to see against its background, a picture that didn't load, has no alt text or is
14
+ squeezed into a box of another shape, a box without an id (or a duplicate), a line attached to a box that isn't there
15
+ (or to a side that isn't t/r/b/l), a Morph pairing with nothing on the slide before, and notes for the AI still on a slide. Each slide is shown as it looks
16
+ after its last build step, with transitions and Morph off, so nothing is caught mid-animation. --check only checks (no
17
+ pictures; exit status 1 when it finds something). --json prints {"slides": [paths], "problems": [{slide, box, kind,
18
+ message}]}. --morph adds, for each Morph step, which boxes pair with the slide before and how they move, resize,
19
+ recolour or fade, and a picture halfway through the animation (slide-NN-morph.png). Serves DECK_DIR over http so assets
20
+ and fonts load as in a browser.
21
+ """
22
+ import base64, functools, http.server, json, os, signal, sys, threading
23
+ from pathlib import Path
24
+ from playwright.sync_api import sync_playwright
25
+
26
+
27
+ # An animated GIF's first frame (createImageBitmap decodes it), so a screenshot never catches it mid-animation.
28
+ FIRST_FRAME_JS = r"""async () => {
29
+ const still = async src => { const bm = await createImageBitmap(await (await fetch(src)).blob()); const c = document.createElement('canvas');
30
+ c.width = bm.width; c.height = bm.height; c.getContext('2d').drawImage(bm, 0, 0); return c.toDataURL('image/png'); };
31
+ for (const im of document.querySelectorAll('img')) if (/^data:image\/gif|\.gif(\?|$)/i.test(im.src)) { im.src = await still(im.src); await im.decode().catch(() => 0); }
32
+ }"""
33
+
34
+
35
+ def serve(root: Path) -> int:
36
+
37
+ class Quiet(http.server.SimpleHTTPRequestHandler):
38
+ def log_message(self, *a): pass # (setting it on the partial below never reached the handler)
39
+ handler = functools.partial(Quiet, directory=str(root))
40
+ httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
41
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
42
+ return httpd.server_address[1]
43
+
44
+
45
+ # What `check` looks for, on the slide on show (built to its last step). Geometry is the deck's own (px on the canvas);
46
+ # text is measured where it is drawn. `authored`: whether the deck itself gives this slide data-auto-animate (the
47
+ # slideshow adds it to the slide before a Morph slide as it loads, which must not count as a Morph step).
48
+ CHECK_JS = r"""([i, ids, authored]) => {
49
+ const sec = Reveal.getCurrentSlide(), W = Reveal.getConfig().width, H = Reveal.getConfig().height, out = [];
50
+ const S = sec.getBoundingClientRect(), k = S.width / W || 1;
51
+ const toCanvas = r => ({ x: (r.left - S.left) / k, y: (r.top - S.top) / k, w: r.width / k, h: r.height / k });
52
+ const px = (e, key) => parseFloat(e.style[key]) || 0, name = b => b.id ? '#' + b.id : `box ${[...sec.querySelectorAll('.box')].indexOf(b) + 1} (no id)`;
53
+ const at = Reveal.getIndices(sec), label = at.v ? `${at.h + 1}.${at.v}` : String(at.h + 1); // the deck's own number (8.1: a stack's sub-slide)
54
+ const say = (b, kind, message) => out.push({ slide: i + 1, label, box: b?.id || '', kind, message });
55
+ const text = b => (b.querySelector(':scope > .content')?.textContent || '').replace(/\s+/g, ' ').trim();
56
+ const quote = b => { const t = text(b); return t ? ` ("${t.slice(0, 40)}${t.length > 40 ? '…' : ''}")` : ''; };
57
+ const isLine = b => /^(line|arrowline|elbow|curve|path)$/.test(b.dataset.shape || '');
58
+ const top = [...sec.children].filter(b => b.classList?.contains('box'));
59
+ const all = [...sec.querySelectorAll('.box')];
60
+ const textual = all.filter(b => text(b) && !b.classList.contains('note') && !isLine(b) && !b.classList.contains('group') && b.offsetParent !== null);
61
+ const textRect = b => { // where the box's text is drawn (the union of its lines), on the canvas
62
+ const r = document.createRange(); r.selectNodeContents(b.querySelector(':scope > .content'));
63
+ const rs = [...r.getClientRects()].filter(q => q.width > 0.5 && q.height > 0.5); if (!rs.length) return null;
64
+ const u = { left: Math.min(...rs.map(q => q.left)), top: Math.min(...rs.map(q => q.top)), right: Math.max(...rs.map(q => q.right)), bottom: Math.max(...rs.map(q => q.bottom)) };
65
+ return toCanvas({ left: u.left, top: u.top, width: u.right - u.left, height: u.bottom - u.top });
66
+ };
67
+ const boxRect = b => toCanvas(b.getBoundingClientRect());
68
+ const meet = (A, B) => ({ w: Math.min(A.x + A.w, B.x + B.w) - Math.max(A.x, B.x), h: Math.min(A.y + A.h, B.y + B.h) - Math.max(A.y, B.y) });
69
+ const inside = (P, Q) => P.x >= Q.x - 1 && P.y >= Q.y - 1 && P.x + P.w <= Q.x + Q.w + 1 && P.y + P.h <= Q.y + Q.h + 1;
70
+
71
+ for (const b of sec.querySelectorAll('.box.note')) say(b, 'note', `a note for you is still on the slide: "${text(b)}" (do it, then delete the note)`);
72
+ for (const b of all) {
73
+ if (!b.id) say(b, 'id', `${name(b)}${quote(b)} has no id`);
74
+ else if (ids[b.id] > 1) say(b, 'id', `#${b.id} is used by ${ids[b.id]} boxes in the deck; ids must be unique`);
75
+ }
76
+ for (const b of top) { // off the slide
77
+ if (b.classList.contains('note')) continue;
78
+ const x = px(b, 'left'), y = px(b, 'top'), w = px(b, 'width'), h = px(b, 'height');
79
+ const over = Math.max(-x, -y, x + w - W, y + h - H);
80
+ if (over > 2) say(b, 'off-slide', `${name(b)}${quote(b)} reaches ${Math.round(over)} px past the edge of the slide (it's at ${Math.round(x)},${Math.round(y)} size ${Math.round(w)}×${Math.round(h)}; the slide is ${W}×${H})`
81
+ + (b.classList.contains('image') ? '; to keep the picture where it shows now, crop the hidden part (data-crop="top right bottom left", shares 0–1) and shrink the box to the visible part' : ''));
82
+ }
83
+ for (const b of textual) { // text that doesn't fit, small text
84
+ const c = b.querySelector(':scope > .content'), fit = parseFloat(b.style.getPropertyValue('--fit')) || 1, cs = getComputedStyle(c);
85
+ const T = textRect(b), B = boxRect(b); // where the lines are drawn, against the box
86
+ if (T) {
87
+ const bottom = T.y + T.h, spill = Math.round(Math.max(bottom - (B.y + B.h), B.y - T.y)), pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
88
+ if (bottom > H + 2) say(b, 'off-slide', `text in ${name(b)}${quote(b)} runs ${Math.round(bottom - H)} px off the bottom of the slide (and ${spill} px out of its box): it must be shorter or smaller, or start higher`);
89
+ else if (spill > 6) say(b, 'overflow', `text in ${name(b)}${quote(b)} spills ${spill} px out of its box: make the box at least ${Math.ceil(bottom - B.y + parseFloat(cs.paddingBottom))} px tall (it is ${Math.round(B.h)}), or the text about ${Math.max(10, Math.floor((B.h - pad) / T.h * 100))}% of its size, or set data-autofit="shrink"`);
90
+ }
91
+ else if (fit < 0.7) say(b, 'overflow', `text in ${name(b)}${quote(b)} is shrunk to ${Math.round(fit * 100)}% to fit; consider less text or a bigger box`);
92
+ for (const pre of c.querySelectorAll('pre')) if (pre.scrollWidth > pre.clientWidth + 2) say(b, 'cut-off', `code in ${name(b)} is cut off on the right (needs ${pre.scrollWidth - pre.clientWidth} px more width, or shorter lines / a smaller font)`);
93
+ if (!c.querySelector('pre') && c.scrollWidth > c.clientWidth + 2) say(b, 'cut-off', `text in ${name(b)}${quote(b)} is wider than its box by ${c.scrollWidth - c.clientWidth} px (a long word or URL)`);
94
+ let small = Infinity;
95
+ const w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT);
96
+ while (w.nextNode()) if (w.currentNode.textContent.trim() && !w.currentNode.parentElement.closest('.katex, pre, sub, sup')) small = Math.min(small, parseFloat(getComputedStyle(w.currentNode.parentElement).fontSize) * fit);
97
+ if (small < 23.5) say(b, 'small-text', `text in ${name(b)}${quote(b)} is ${Math.round(small)} px; under 24 px (9 pt) is hard to read when presenting`);
98
+ }
99
+ // contrast: the text against what is behind it (the box's own fill, else the slide's colour; pictures behind are skipped)
100
+ const rgb = v => { const e = document.createElement('i'); e.style.color = v; document.body.append(e); const m = getComputedStyle(e).color.match(/[\d.]+/g)?.map(Number); e.remove(); return m; };
101
+ const lum = c => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]); };
102
+ if (!sec.dataset.backgroundImage && !sec.dataset.backgroundVideo && !sec.dataset.backgroundIframe) for (const b of textual) {
103
+ const leaf = [...b.querySelectorAll('.content *')].find(e => e.textContent.trim() && !e.children.length) || b.querySelector('.content');
104
+ const fg = rgb(getComputedStyle(leaf).color), fill = getComputedStyle(b).getPropertyValue('--fill').trim(), f = fill && fill !== 'transparent' ? rgb(fill) : null;
105
+ const bg = f && (f[3] ?? 1) > 0.5 ? f : rgb(sec.dataset.backgroundColor || '#ffffff');
106
+ if (!fg || !bg || (fg[3] ?? 1) < 0.5) continue;
107
+ const [L1, L2] = [lum(fg), lum(bg)].sort((a, z) => z - a), ratio = (L1 + 0.05) / (L2 + 0.05);
108
+ if (ratio < 3) say(b, 'contrast', `text in ${name(b)}${quote(b)} is hard to see against what is behind it (contrast ${ratio.toFixed(1)}:1; aim for 4.5:1)`);
109
+ }
110
+ for (const b of sec.querySelectorAll('.box.image')) {
111
+ const im = b.querySelector('img'); if (!im) continue;
112
+ if (!(im.getAttribute('alt') || '').trim() && !b.getAttribute('aria-label')) say(b, 'alt', `the picture in ${name(b)} (${im.getAttribute('src')}) has no alt text`);
113
+ if (!im.naturalWidth) { say(b, 'picture', `the picture in ${name(b)} didn't load (${im.getAttribute('src')})`); continue; }
114
+ const ar = im.naturalWidth / im.naturalHeight, br = px(b, 'width') / (px(b, 'height') || 1);
115
+ if (!b.dataset.crop && Math.abs(Math.log(ar / br)) > 0.04) say(b, 'shape', `the picture in ${name(b)} is ${im.naturalWidth}×${im.naturalHeight} but its box is ${Math.round(px(b, 'width'))}×${Math.round(px(b, 'height'))}, so it is squeezed or letterboxed and its text shows smaller than planned; make the figure that shape (glideshow figure size … --box ${b.id || 'ID'}) or refit the box (glideshow figure add … --replace ${b.id || 'ID'})`);
116
+ }
117
+ for (const b of sec.querySelectorAll('.box[data-from], .box[data-to]'))
118
+ for (const ref of [b.dataset.from, b.dataset.to]) {
119
+ if (!ref) continue;
120
+ const [id, side] = ref.split(':');
121
+ if (!'t r b l'.split(' ').includes(side)) say(b, 'connector', `${name(b)} is attached to "${ref}": the side after the colon must be t, r, b or l`);
122
+ else if (!sec.querySelector('#' + CSS.escape(id))) say(b, 'connector', `${name(b)} is attached to #${id}, which isn't on this slide`);
123
+ }
124
+ if (authored) { // Morph partners (only slides the deck makes Morph steps)
125
+ const prev = Reveal.getSlides()[Reveal.getSlides().indexOf(sec) - 1];
126
+ for (const b of all) { const m = b.dataset.morph; if (m && !prev?.querySelector('#' + CSS.escape(m))) say(b, 'morph', `${name(b)} has data-morph="${m}" but the slide before has no box with id "${m}", so it fades in instead of moving`); }
127
+ if (prev && !all.some(b => prev.querySelector('#' + CSS.escape(b.dataset.morph || b.id || '-')))) say(null, 'morph', 'this Morph step shares no box with the slide before (pair boxes with data-morph="<id on the slide before>", or drop data-auto-animate if this is the first step)');
128
+ }
129
+ // text on text (even a label inside a shape with words of its own), text partly over a picture, lines through text
130
+ const shown = textual.filter(b => !b.classList.contains('fragment')), rects = new Map(shown.map(b => [b, textRect(b)]));
131
+ for (let a = 0; a < shown.length; a++) for (let z = a + 1; z < shown.length; z++) {
132
+ const A = rects.get(shown[a]), B = rects.get(shown[z]); if (!A || !B) continue;
133
+ if (shown[a].contains(shown[z]) || shown[z].contains(shown[a])) continue;
134
+ const m = meet(A, B);
135
+ if (m.w > 2 && m.h > 2) say(shown[z], 'overlap', `the text of ${name(shown[a])}${quote(shown[a])} and of ${name(shown[z])}${quote(shown[z])} overlap (${Math.round(m.w)}×${Math.round(m.h)} px)`);
136
+ }
137
+ for (const im of top.filter(b => b.classList.contains('image') && !b.classList.contains('fragment')))
138
+ for (const b of shown) {
139
+ const T = rects.get(b), P = boxRect(im); if (!T) continue;
140
+ const m = meet(T, P);
141
+ if (m.w > 4 && m.h > 4 && !inside(T, P)) say(b, 'overlap', `the text of ${name(b)}${quote(b)} runs partly over the picture ${name(im)} (${Math.round(m.w)}×${Math.round(m.h)} px)`);
142
+ }
143
+ for (const l of all.filter(isLine)) {
144
+ const path = l.querySelector('svg.shape path.l'), M = path?.getScreenCTM(); if (!path || !M) continue;
145
+ const len = path.getTotalLength(), hits = new Set();
146
+ for (let d = 0; d <= len; d += 6) {
147
+ const q = path.getPointAtLength(d), sx = M.a * q.x + M.c * q.y + M.e, sy = M.b * q.x + M.d * q.y + M.f, cx = (sx - S.left) / k, cy = (sy - S.top) / k;
148
+ for (const b of shown) { const T = rects.get(b); if (T && cx > T.x + 2 && cx < T.x + T.w - 2 && cy > T.y + 2 && cy < T.y + T.h - 2) hits.add(b); }
149
+ }
150
+ for (const b of hits) { // a label with a solid background drawn over the line (later on the slide) covers it: a deliberate look
151
+ const f = getComputedStyle(b).getPropertyValue('--fill').trim(), solid = f && f !== 'transparent' && ((rgb(f) || [])[3] ?? 1) > 0.5;
152
+ if (solid && (l.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)) continue;
153
+ say(l, 'overlap', `${name(l)} runs through the text of ${name(b)}${quote(b)}`);
154
+ }
155
+ }
156
+ return out;
157
+ }"""
158
+
159
+ # Inside figures saved with glideshow.figure.save (a PNG with OUT.json beside it): where its text lands on the slide.
160
+ FIGURE_JS = r"""async (i) => {
161
+ const sec = Reveal.getCurrentSlide(), at = Reveal.getIndices(sec), label = at.v ? `${at.h + 1}.${at.v}` : String(at.h + 1), out = [], say = (b, kind, message) => out.push({ slide: i + 1, label, box: b.id || '', kind, message });
162
+ for (const b of sec.querySelectorAll('.box.image')) {
163
+ const im = b.querySelector('img'); if (!im || b.dataset.crop || /^data:/.test(im.src)) continue;
164
+ let f; try { const r = await fetch(im.src + '.json'); if (!r.ok) continue; f = await r.json(); } catch { continue; }
165
+ const w = parseFloat(b.style.width), h = parseFloat(b.style.height), k = Math.min(w / (f.inches[0] * 192), h / (f.inches[1] * 192)); // slide px per figure px at 192/in
166
+ const name = '#' + (b.id || '?'), q = t => `"${t.text.slice(0, 30)}"`, T = f.texts || [];
167
+ if (f.missing?.length) say(b, 'figure', `the figure in ${name} has characters its font lacks, drawn as empty boxes: ${f.missing.join(' ')} (use words, or a font that has them)`);
168
+ const cut = T.filter(t => t.x < -0.002 || t.y < -0.002 || t.x + t.w > 1.002 || t.y + t.h > 1.002);
169
+ if (cut.length) say(b, 'figure', `in the figure in ${name}, ${cut.map(q).join(', ')} ${cut.length > 1 ? 'are' : 'is'} cut off at the figure's edge (layout="constrained" keeps labels inside)`);
170
+ const small = T.filter(t => t.pt * 2.667 * k < 23.5);
171
+ if (small.length) { const s = small.reduce((a, z) => a.pt < z.pt ? a : z); say(b, 'figure', `text in the figure in ${name} shows at ${Math.round(s.pt * 2.667 * k)} px on the slide (${q(s)}, ${s.pt} pt${k < 0.99 ? `, shrunk to ${Math.round(k * 100)}% to fit its box` : ''}); under 24 px is hard to read when presenting`); }
172
+ for (let a = 0; a < T.length; a++) for (let z = a + 1; z < T.length; z++) {
173
+ const A = T[a], B = T[z], ow = Math.min(A.x + A.w, B.x + B.w) - Math.max(A.x, B.x), oh = Math.min(A.y + A.h, B.y + B.h) - Math.max(A.y, B.y);
174
+ if (ow * w > 2 && oh * h > 2) say(b, 'figure', `in the figure in ${name}, ${q(A)} and ${q(B)} overlap`);
175
+ }
176
+ }
177
+ return out;
178
+ }"""
179
+
180
+
181
+ # A Morph step's pairs, from the slide before: what moves, resizes or recolours into place, what fades in, what fades out.
182
+ MORPH_JS = r"""(i) => {
183
+ const all = Reveal.getSlides(), sec = all[i], prev = all[i - 1], out = [];
184
+ const g = b => ['left', 'top', 'width', 'height'].map(k => Math.round(parseFloat(b.style[k]) || 0));
185
+ const fill = b => getComputedStyle(b).getPropertyValue('--fill').trim(), label = b => '#' + (b.id || '?') + ((t => t ? ` ("${t.slice(0, 30)}")` : '')((b.querySelector(':scope > .content')?.textContent || '').trim()));
186
+ const used = new Set();
187
+ for (const b of sec.querySelectorAll(':scope > .box')) {
188
+ const p = prev?.querySelector('#' + CSS.escape(b.dataset.morph || b.id || '-'));
189
+ if (!p) { out.push(`${label(b)} fades in`); continue; }
190
+ used.add(p);
191
+ const [a, z] = [g(p), g(b)], how = [];
192
+ if (a[0] !== z[0] || a[1] !== z[1]) how.push(`moves ${a[0]},${a[1]} → ${z[0]},${z[1]}`);
193
+ if (a[2] !== z[2] || a[3] !== z[3]) how.push(`resizes ${a[2]}×${a[3]} → ${z[2]}×${z[3]}`);
194
+ if (fill(p) !== fill(b)) how.push(`recolours ${fill(p) || 'none'} → ${fill(b) || 'none'}`);
195
+ out.push(`${label(b)} ← #${p.id}: ${how.join(', ') || 'stays'}`);
196
+ }
197
+ for (const p of prev?.querySelectorAll(':scope > .box') || []) if (!used.has(p)) out.push(`${label(p)} fades out`);
198
+ return out;
199
+ }"""
200
+
201
+
202
+ # Every build step done, transitions and Morph off and finished, so the picture is the slide's final state.
203
+ SETTLE_JS = r"""async () => {
204
+ const sec = Reveal.getCurrentSlide();
205
+ sec.querySelectorAll('.fragment').forEach(f => { f.classList.add('visible'); f.classList.remove('current-fragment'); });
206
+ await Promise.all(document.getAnimations().map(a => { try { a.finish(); } catch { } return a.finished.catch(() => 0); }));
207
+ await Promise.all([...document.images].map(im => im.complete ? 0 : new Promise(r => im.onload = im.onerror = r)));
208
+ document.querySelectorAll('.box[data-autofit="shrink"]').forEach(b => window.fitText?.(b));
209
+ }"""
210
+
211
+
212
+ def main():
213
+ if hasattr(signal, "SIGPIPE"): signal.signal(signal.SIGPIPE, signal.SIG_DFL) # `glideshow render … | head` just stops, no traceback
214
+ flags = {a for a in sys.argv[1:] if a.startswith("--")}
215
+ argv = [a for a in sys.argv[1:] if not a.startswith("--")]
216
+ check_only, as_json, morph, full, sheet = "--check" in flags, "--json" in flags, "--morph" in flags, "--full" in flags, "--sheet" in flags
217
+ deck, out = Path(argv[0]).expanduser().resolve(), Path(argv[1]).expanduser().resolve()
218
+ only = {int(a) for a in argv[2:]}
219
+ if not (deck / "deck.html").exists(): raise SystemExit(f"no deck.html in {deck}")
220
+ if not check_only:
221
+ out.mkdir(parents=True, exist_ok=True)
222
+ for old in out.glob("slide-*.png"): # this render's slides replace their old pictures; others stay
223
+ if not only or int(old.stem.split("-")[1]) in only: old.unlink()
224
+ port = serve(deck)
225
+ paths, morphs, tiles = [], [], []
226
+ problems = [{"slide": 0, "label": "", "box": "", "kind": "structure", "message": m} for m in json.loads(os.environ.get("GLIDESHOW_STRUCTURE") or "[]")] # (cli.py reads them from the file)
227
+ with sync_playwright() as p:
228
+ try: browser = p.chromium.launch() # Playwright's Chromium, as the fidelity references were measured with
229
+ except Exception: browser = p.chromium.launch(channel="chrome") # not downloaded: the installed Chrome
230
+ pg = browser.new_page(viewport={"width": 1920, "height": 1080}, device_scale_factor=1 if full or check_only else 0.5) # 960 × 540 pictures unless --full
231
+ pg.goto(f"http://127.0.0.1:{port}/deck.html")
232
+ pg.wait_for_function("window.Reveal && Reveal.isReady()")
233
+ pg.evaluate("Reveal.configure({margin: 0, controls: false, progress: false, slideNumber: false, transition: 'none', backgroundTransition: 'none', autoAnimate: false})")
234
+ pg.add_style_tag(content=".sl-bar, .sl-pop { display: none !important; } .reveal .fragment { transition: none !important; }") # the slideshow's own pill; builds without fades
235
+ pg.evaluate(FIRST_FRAME_JS) # animated GIFs at their first frame, as gslides2deck's references show them
236
+ pg.evaluate("document.fonts.ready")
237
+ ids = pg.evaluate("(() => { const n = {}; document.querySelectorAll('.slides .box[id]').forEach(b => n[b.id] = (n[b.id] || 0) + 1); return n; })()")
238
+ authored = pg.evaluate("""async () => { // which slides the deck itself makes Morph steps (the slideshow adds more as it loads)
239
+ const raw = new DOMParser().parseFromString(await (await fetch('deck.html')).text(), 'text/html');
240
+ return [...raw.querySelectorAll('.slides section')].filter(s => !s.querySelector('section')).map(s => s.hasAttribute('data-auto-animate')); }""")
241
+ n = pg.evaluate("Reveal.getTotalSlides()") # every slide, a stack's sub-slides counted in deck order (as `glideshow slides` numbers them)
242
+ go = lambda i: pg.evaluate("i => { const x = Reveal.getIndices(Reveal.getSlides()[i]); Reveal.slide(x.h, x.v); }", i) # Reveal.slide(i) alone takes only the horizontal index
243
+ for i in range(n):
244
+ if only and i + 1 not in only:
245
+ continue
246
+ go(i)
247
+ pg.evaluate(SETTLE_JS)
248
+ pg.wait_for_timeout(100)
249
+ problems += pg.evaluate(CHECK_JS, [i, ids, bool(authored[i]) if i < len(authored) else False])
250
+ problems += pg.evaluate(FIGURE_JS, i)
251
+ if check_only: continue
252
+ path = out / f"slide-{i + 1:02d}.png"
253
+ pg.screenshot(path=str(path)); paths.append(str(path))
254
+ at = pg.evaluate("Reveal.getIndices()"); tiles.append((i + 1, f"{at['h'] + 1}.{at['v']}" if at.get("v") else str(at["h"] + 1), path))
255
+ if not as_json and not sheet: print(path, flush=True)
256
+ if morph: # each Morph step: its pairs, and a picture halfway through the animation from the slide before
257
+ for i in range(1, n):
258
+ if (only and i + 1 not in only) or not (i < len(authored) and authored[i]): continue
259
+ pairs = pg.evaluate(MORPH_JS, i)
260
+ pg.evaluate("Reveal.configure({ autoAnimate: true })"); go(i - 1); pg.evaluate(SETTLE_JS); pg.wait_for_timeout(100)
261
+ dur = pg.evaluate(f"parseFloat(Reveal.getSlides()[{i}].dataset.autoAnimateDuration) || Reveal.getConfig().autoAnimateDuration || 1")
262
+ go(i); pg.wait_for_timeout(int(dur * 500))
263
+ mid = out / f"slide-{i + 1:02d}-morph.png"
264
+ if not check_only: pg.screenshot(path=str(mid))
265
+ pg.wait_for_timeout(int(dur * 600)); pg.evaluate("Reveal.configure({ autoAnimate: false })")
266
+ morphs.append({"slide": i + 1, "pairs": pairs, "midway": "" if check_only else str(mid)})
267
+ if sheet and tiles: # contact sheets: nine slides to a picture, each labelled with its number
268
+ for old in out.glob("sheet-*.png"): old.unlink()
269
+ pg2 = browser.new_page(viewport={"width": 1920, "height": 300}) # (full_page: as tall as its rows)
270
+ for k in range(0, len(tiles), 9):
271
+ cells = "".join(f'<div><img src="data:image/png;base64,{base64.b64encode(Path(f).read_bytes()).decode()}"><b>{n if lab == str(n) else f"{n} = {lab}"}</b></div>' for n, lab, f in tiles[k:k + 9])
272
+ pg2.set_content('<style>body{margin:0;background:#9aa0a6;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;padding:8px;width:1920px;box-sizing:border-box}'
273
+ 'div{position:relative;aspect-ratio:16/9}img{width:100%;height:100%;display:block}b{position:absolute;right:0;bottom:0;background:#202124d0;color:#fff;font:600 24px/1 Arial;padding:5px 10px}</style>' + cells)
274
+ pg2.wait_for_load_state("load")
275
+ path = out / f"sheet-{k // 9 + 1}.png"; pg2.screenshot(path=str(path), full_page=True); paths.append(str(path))
276
+ if not as_json: print(path, f"(slides {tiles[k][0]}–{tiles[min(k + 8, len(tiles) - 1)][0]})", flush=True)
277
+ browser.close()
278
+ if as_json: print(json.dumps({"slides": paths, "problems": problems, **({"morph": morphs} if morph else {})}, indent=2))
279
+ else:
280
+ for m in morphs: print(f"slide {m['slide']} (Morph from slide {m['slide'] - 1}):" + "".join(f"\n {p}" for p in m["pairs"]) + (f"\n halfway: {m['midway']}" if m["midway"] else ""))
281
+ if not n: print(f"{deck / 'deck.html'} has no slides")
282
+ for q in problems: print(q["message"] if not q["slide"] else f"slide {q['slide']}{'' if q['label'] == str(q['slide']) else ' = ' + q['label']}: {q['message']}")
283
+ print(f"{len(problems)} problem{'s' if len(problems) != 1 else ''} found" if problems else "no problems found")
284
+ if check_only and problems: sys.exit(1)
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()