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
@@ -0,0 +1,207 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["python-pptx"]
4
+ # ///
5
+ """deck.html → .pptx, best effort: slide backgrounds, text boxes (paragraphs, size, bold / italic, colour, alignment),
6
+ pictures, tables, the common shapes with their fill and outline, straight lines, and speaker notes. Videos and web
7
+ embeds are left out.
8
+
9
+ uv run glideshow/pptx_export.py DECK/deck.html OUT.pptx
10
+
11
+ The server runs this for File → Download → PowerPoint; python-pptx stays out of the server itself.
12
+ """
13
+ import re, sys
14
+ from html.parser import HTMLParser
15
+ from pathlib import Path
16
+
17
+ from pptx import Presentation
18
+ from pptx.dml.color import RGBColor
19
+ from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
20
+ from pptx.enum.text import PP_ALIGN
21
+ from pptx.util import Emu, Pt
22
+
23
+ EMU_PX = 914400 * 10 / 1920 # the 1920 px canvas is 10 in wide
24
+ PX_PT = 192 / 72
25
+ SHAPES = {"rect": "RECTANGLE", "roundrect": "ROUNDED_RECTANGLE", "ellipse": "OVAL", "triangle": "ISOSCELES_TRIANGLE", "diamond": "DIAMOND",
26
+ "parallelogram": "PARALLELOGRAM", "trapezoid": "TRAPEZOID", "pentagon": "REGULAR_PENTAGON", "hexagon": "HEXAGON", "octagon": "OCTAGON",
27
+ "star": "STAR_5_POINT", "cloud": "CLOUD", "arrow": "RIGHT_ARROW", "leftarrow": "LEFT_ARROW", "uparrow": "UP_ARROW", "downarrow": "DOWN_ARROW",
28
+ "leftrightarrow": "LEFT_RIGHT_ARROW", "chevron": "CHEVRON", "callout": "RECTANGULAR_CALLOUT", "roundcallout": "ROUNDED_RECTANGULAR_CALLOUT",
29
+ "cross": "CROSS", "heart": "HEART", "donut": "DONUT", "can": "CAN", "cube": "CUBE", "homeplate": "PENTAGON", "rttriangle": "RIGHT_TRIANGLE"}
30
+ BLOCK = {"div", "p", "li", "h1", "h2", "h3", "pre", "tr"}
31
+ # every other shape: its OOXML preset name from shapes.js' PRESET_OF table, which PowerPoint knows natively
32
+ _js = (next(d for d in (Path(__file__).resolve().parent / "editor", Path(__file__).resolve().parent.parent / "editor") if d.is_dir()) / "deck" / "shapes.js").read_text()
33
+ PRESET_OF = dict(re.findall(r"['\"]?([\w-]+)['\"]?:\s*'(\w+)'", _js[_js.index("PRESET_OF = {"):_js.index("};", _js.index("PRESET_OF = {"))]))
34
+
35
+
36
+ def mso(shape_name):
37
+ if shape_name in SHAPES: return getattr(MSO_SHAPE, SHAPES[shape_name])
38
+ try: return MSO_SHAPE.from_xml(PRESET_OF.get(shape_name, ""))
39
+ except (ValueError, KeyError): return None
40
+
41
+
42
+ class Node:
43
+ def __init__(self, tag, attrs, parent=None):
44
+ self.tag, self.attrs, self.parent, self.kids = tag, dict(attrs), parent, []
45
+
46
+ def cls(self):
47
+ return self.attrs.get("class", "").split()
48
+
49
+ def find_all(self, pred):
50
+ for k in self.kids:
51
+ if isinstance(k, Node):
52
+ if pred(k): yield k
53
+ yield from k.find_all(pred)
54
+
55
+ def text(self):
56
+ return "".join(k if isinstance(k, str) else k.text() for k in self.kids)
57
+
58
+
59
+ class Tree(HTMLParser):
60
+ VOID = {"img", "br", "meta", "link", "input", "hr", "source"}
61
+
62
+ def __init__(self):
63
+ super().__init__(convert_charrefs=True)
64
+ self.root = self.cur = Node("root", {})
65
+
66
+ def handle_starttag(self, tag, attrs):
67
+ n = Node(tag, attrs, self.cur); self.cur.kids.append(n)
68
+ if tag not in self.VOID: self.cur = n
69
+
70
+ def handle_endtag(self, tag):
71
+ n = self.cur
72
+ while n is not self.root and n.tag != tag: n = n.parent
73
+ if n is not self.root: self.cur = n.parent
74
+
75
+ def handle_data(self, data):
76
+ self.cur.kids.append(data)
77
+
78
+
79
+ def style(n):
80
+ return {k.strip(): v.strip() for k, v in (d.split(":", 1) for d in n.attrs.get("style", "").split(";") if ":" in d)}
81
+
82
+
83
+ def px(v, default=0.0):
84
+ m = re.match(r"(-?[\d.]+)px", v or "")
85
+ return float(m.group(1)) if m else default
86
+
87
+
88
+ def rgb(v):
89
+ m = re.fullmatch(r"#([0-9a-fA-F]{6})(?:[0-9a-fA-F]{2})?", (v or "").strip()) or re.fullmatch(r"#([0-9a-fA-F]{3})", (v or "").strip())
90
+ if not m: return None
91
+ h = m.group(1)
92
+ return RGBColor.from_string(h if len(h) == 6 else "".join(c * 2 for c in h))
93
+
94
+
95
+ def paragraphs(content):
96
+ """[(text, block style)] — one per block child / list item / <br>, inline text joined."""
97
+ out, line = [], []
98
+ def walk(n, inherited):
99
+ for k in n.kids:
100
+ if isinstance(k, str): line.append(k)
101
+ elif k.tag == "br": flush(inherited)
102
+ elif k.tag in ("ul", "ol"): walk(k, inherited)
103
+ elif k.tag in BLOCK: flush(inherited); walk(k, {**inherited, **style(k)}); flush({**inherited, **style(k)}, k.tag == "li")
104
+ elif k.tag not in ("svg", "aside", "script", "style"): walk(k, inherited)
105
+ def flush(st, bullet=False):
106
+ t = re.sub(r"\s+", " ", "".join(line)).strip()
107
+ if t: out.append((("• " if bullet else "") + t, st))
108
+ line.clear()
109
+ walk(content, {}); flush({})
110
+ return out
111
+
112
+
113
+ def add_text(shape, content, box_style):
114
+ st = style(content)
115
+ tf = shape.text_frame; tf.word_wrap = True
116
+ paras = paragraphs(content)
117
+ if not paras: return
118
+ tf.text = ""
119
+ for i, (t, pst) in enumerate(paras):
120
+ p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
121
+ r = p.add_run(); r.text = t
122
+ size = px(pst.get("font-size") or st.get("font-size"), 37.33)
123
+ r.font.size = Pt(round(size / PX_PT, 1))
124
+ r.font.bold = (pst.get("font-weight") or st.get("font-weight")) in ("bold", "700")
125
+ r.font.italic = (pst.get("font-style") or st.get("font-style")) == "italic"
126
+ c = rgb(pst.get("color") or st.get("color"))
127
+ if c: r.font.color.rgb = c
128
+ fam = (st.get("font-family") or "").split(",")[0].strip("'\" ")
129
+ if fam: r.font.name = fam
130
+ p.alignment = {"center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT, "justify": PP_ALIGN.JUSTIFY}.get(pst.get("text-align") or st.get("text-align") or "", PP_ALIGN.LEFT if not box_style.get("shape") else PP_ALIGN.CENTER)
131
+
132
+
133
+ def export(deck: Path, out: Path):
134
+ t = Tree(); t.feed(deck.read_text()); root = t.root
135
+ slides_el = next(root.find_all(lambda n: n.tag == "div" and "slides" in n.cls()))
136
+ m = re.match(r"(\d+)x(\d+)$", slides_el.attrs.get("data-size", ""))
137
+ W, H = (int(m.group(1)), int(m.group(2))) if m else (1920, 1080)
138
+ prs = Presentation(); prs.slide_width, prs.slide_height = Emu(int(W * EMU_PX)), Emu(int(H * EMU_PX))
139
+ blank = prs.slide_layouts[6]
140
+ sections = [s for top in slides_el.kids if isinstance(top, Node) and top.tag == "section"
141
+ for s in ([k for k in top.kids if isinstance(k, Node) and k.tag == "section"] or [top])]
142
+ for sec in sections:
143
+ slide = prs.slides.add_slide(blank)
144
+ bg = rgb(sec.attrs.get("data-background-color"))
145
+ if bg: slide.background.fill.solid(); slide.background.fill.fore_color.rgb = bg
146
+ for box in [k for k in sec.kids if isinstance(k, Node) and "box" in k.cls()]:
147
+ add_box(slide, box, deck.parent, 0, 0)
148
+ notes = [k for k in sec.kids if isinstance(k, Node) and k.tag == "aside" and "notes" in k.cls()]
149
+ if notes: slide.notes_slide.notes_text_frame.text = notes[0].text().strip()
150
+ prs.save(out)
151
+
152
+
153
+ def add_box(slide, box, base, ox, oy):
154
+ st, a = style(box), box.attrs
155
+ x, y, w, h = ox + px(st.get("left")), oy + px(st.get("top")), px(st.get("width"), 1), px(st.get("height"), 1)
156
+ E = lambda v: Emu(int(v * EMU_PX))
157
+ content = next((k for k in box.kids if isinstance(k, Node) and "content" in k.cls()), None)
158
+ shape_name, cls = a.get("data-shape", ""), box.cls()
159
+ if "group" in cls:
160
+ for k in box.kids:
161
+ if isinstance(k, Node) and "box" in k.cls(): add_box(slide, k, base, x, y)
162
+ return
163
+ if "image" in cls:
164
+ img = next((n for n in box.find_all(lambda n: n.tag == "img")), None)
165
+ src = img and img.attrs.get("src", "")
166
+ if src and not src.startswith(("data:", "http")) and (base / src).exists():
167
+ slide.shapes.add_picture(str(base / src), E(x), E(y), E(w), E(h))
168
+ return
169
+ if "video" in cls or "embed" in cls:
170
+ return # PowerPoint can't take these from HTML; left out
171
+ if "table" in cls:
172
+ rows = [r for r in box.find_all(lambda n: n.tag == "tr")]
173
+ cols = max((len([c for c in r.kids if isinstance(c, Node) and c.tag in ("td", "th")]) for r in rows), default=0)
174
+ if rows and cols:
175
+ tb = slide.shapes.add_table(len(rows), cols, E(x), E(y), E(w), E(h)).table
176
+ size = px(style(content).get("font-size") if content else None, 37.33)
177
+ for i, r in enumerate(rows):
178
+ for j, c in enumerate([c for c in r.kids if isinstance(c, Node) and c.tag in ("td", "th")]):
179
+ cell = tb.cell(i, j); cell.text = re.sub(r"\s+", " ", c.text()).strip()
180
+ for p in cell.text_frame.paragraphs:
181
+ for run in p.runs: run.font.size = Pt(round(size / PX_PT, 1))
182
+ return
183
+ sw = px(st.get("--sw"), 4)
184
+ if shape_name in ("line", "arrowline", "elbow", "curve"):
185
+ up = a.get("data-dir") == "up"
186
+ ln = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, E(x), E(y + h if up else y), E(x + w), E(y if up else y + h))
187
+ ln.line.width = Emu(int(sw * EMU_PX)); c = rgb(st.get("--stroke"))
188
+ if c: ln.line.color.rgb = c
189
+ return
190
+ if shape_name and mso(shape_name) is not None:
191
+ sh = slide.shapes.add_shape(mso(shape_name), E(x), E(y), E(w), E(h))
192
+ f, s = rgb(st.get("--fill")), rgb(st.get("--stroke"))
193
+ if f: sh.fill.solid(); sh.fill.fore_color.rgb = f
194
+ else: sh.fill.background()
195
+ if s: sh.line.color.rgb = s; sh.line.width = Emu(int(sw * EMU_PX))
196
+ else: sh.line.fill.background()
197
+ elif shape_name:
198
+ return # a shape PowerPoint export doesn't know yet
199
+ else:
200
+ sh = slide.shapes.add_textbox(E(x), E(y), E(w), E(h))
201
+ f = rgb(st.get("--fill"))
202
+ if f: sh.fill.solid(); sh.fill.fore_color.rgb = f
203
+ if content is not None: add_text(sh, content, {"shape": shape_name})
204
+
205
+
206
+ if __name__ == "__main__":
207
+ export(Path(sys.argv[1]), Path(sys.argv[2]))
@@ -0,0 +1,115 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["python-pptx"]
4
+ # ///
5
+ """A .pptx → deck sections, best effort: slide backgrounds, text boxes and placeholders (paragraphs with size, bold /
6
+ italic, colour, alignment), pictures (saved into the deck's assets/), tables, shapes PowerPoint and shapes.js share
7
+ (by OOXML preset name), straight lines and speaker notes. Groups are flattened. Prints JSON: {"slides": [html, …]}.
8
+
9
+ uv run glideshow/pptx_import.py IN.pptx DECK/assets
10
+ """
11
+ import hashlib, html, json, re, sys
12
+ from pathlib import Path
13
+
14
+ from pptx import Presentation
15
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
16
+ from pptx.enum.text import PP_ALIGN
17
+
18
+ _js = (next(d for d in (Path(__file__).resolve().parent / "editor", Path(__file__).resolve().parent.parent / "editor") if d.is_dir()) / "deck" / "shapes.js").read_text()
19
+ _po = _js[_js.index("PRESET_OF = {"):_js.index("};", _js.index("PRESET_OF = {"))]
20
+ OURS = {prst: name for name, prst in re.findall(r"['\"]?([\w-]+)['\"]?:\s*'(\w+)'", _po)} # OOXML preset → our data-shape
21
+ OURS.update({"rect": "rect", "roundRect": "roundrect", "ellipse": "ellipse"})
22
+ ALIGN = {PP_ALIGN.CENTER: "center", PP_ALIGN.RIGHT: "right", PP_ALIGN.JUSTIFY: "justify"}
23
+
24
+
25
+ def hexcolor(fmt):
26
+ try:
27
+ return "#" + str(fmt.rgb) if fmt.type is not None and fmt.rgb is not None else None
28
+ except (AttributeError, KeyError, TypeError, ValueError):
29
+ return None
30
+
31
+
32
+ def text_html(tf, k):
33
+ paras, sizes = [], []
34
+ for p in tf.paragraphs:
35
+ runs = []
36
+ for r in p.runs:
37
+ st = []
38
+ if r.font.size: st.append(f"font-size: {round(r.font.size.pt * 192 / 72, 2)}px"); sizes.append(r.font.size.pt)
39
+ if r.font.bold: st.append("font-weight: bold")
40
+ if r.font.italic: st.append("font-style: italic")
41
+ c = hexcolor(r.font.color) if r.font.color and r.font.color.type is not None else None
42
+ if c: st.append(f"color: {c}")
43
+ t = html.escape(r.text)
44
+ runs.append(f'<span style="{"; ".join(st)}">{t}</span>' if st else t)
45
+ align = ALIGN.get(p.alignment)
46
+ st = f' style="text-align: {align}"' if align else '' # (no quotes or backslashes inside an f-string's braces before Python 3.12)
47
+ paras.append(f'<div{st}>{"".join(runs) or "<br>"}</div>')
48
+ return "".join(paras)
49
+
50
+
51
+ def walk(shapes, k, assets, out):
52
+ for sh in shapes:
53
+ if sh.shape_type == MSO_SHAPE_TYPE.GROUP:
54
+ walk(sh.shapes, k, assets, out); continue
55
+ if sh.left is None or sh.width is None:
56
+ continue
57
+ x, y, w, h = (round(v * k, 1) for v in (sh.left, sh.top, sh.width, sh.height))
58
+ pos = f"left: {x}px; top: {y}px; width: {w}px; height: {h}px;"
59
+ if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
60
+ blob = sh.image.blob
61
+ name = f"{hashlib.sha1(blob).hexdigest()[:12]}.{sh.image.ext}"
62
+ (assets / name).write_bytes(blob)
63
+ out.append(f'<div class="box image" style="{pos}"><div class="content"><img src="assets/{name}" alt=""></div></div>'); continue
64
+ if sh.has_table if hasattr(sh, "has_table") else False:
65
+ rows = "".join("<tr>" + "".join(f"<td>{html.escape(c.text)}</td>" for c in r.cells) + "</tr>" for r in sh.table.rows)
66
+ out.append(f'<div class="box table" data-valign="top" style="{pos}"><div class="content" style="padding: 0px;"><table>{rows}</table></div></div>'); continue
67
+ if sh.shape_type in (MSO_SHAPE_TYPE.LINE,) or sh.__class__.__name__ == "Connector":
68
+ col = hexcolor(sh.line.color) if sh.line.fill.type is not None else None
69
+ sw = round(sh.line.width * k, 2) if sh.line.width else 2.67
70
+ up = sh.begin_y > sh.end_y if hasattr(sh, "begin_y") else False
71
+ updir = ' data-dir="up"' if up else ''
72
+ out.append(f'<div class="box" data-shape="line"{updir} style="{pos} --sw: {sw}px;{f" --stroke: {col};" if col else ""}"><div class="content"></div></div>'); continue
73
+ attrs, style = "", pos
74
+ if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE:
75
+ try: prst = sh.auto_shape_type and sh.auto_shape_type.xml_value
76
+ except (NotImplementedError, ValueError, AttributeError): prst = None
77
+ name = OURS.get(prst or "", "rect")
78
+ attrs = f' data-shape="{name}"'
79
+ try:
80
+ if sh.fill.type is not None: style += f" --fill: {hexcolor(sh.fill.fore_color) or '#eeeeee'};"
81
+ else: style += " --fill: transparent;"
82
+ except (TypeError, AttributeError): pass
83
+ try:
84
+ if sh.line.fill.type is not None:
85
+ c = hexcolor(sh.line.color)
86
+ style += f" --stroke: {c or '#595959'}; --sw: {round((sh.line.width or 12700) * k, 2)}px;"
87
+ except (TypeError, AttributeError): pass
88
+ inner = text_html(sh.text_frame, k) if sh.has_text_frame and sh.text_frame.text.strip() else ""
89
+ if not attrs and not inner:
90
+ continue
91
+ valign = "" if attrs else ' data-valign="top"' # text boxes hang from the top; shapes centre their text, as in both apps
92
+ left = '' if attrs else ' style="text-align: left;"'
93
+ out.append(f'<div class="box"{attrs}{valign} style="{style}"><div class="content"{left}>{inner}</div></div>')
94
+
95
+
96
+ def convert(src: Path, assets: Path):
97
+ prs = Presentation(src)
98
+ assets.mkdir(parents=True, exist_ok=True)
99
+ k = 1920 / prs.slide_width # px per EMU
100
+ slides = []
101
+ for s in prs.slides:
102
+ bg = ""
103
+ try:
104
+ if s.background.fill.type is not None: bg = hexcolor(s.background.fill.fore_color) or ""
105
+ except (TypeError, AttributeError): pass
106
+ out = []
107
+ walk(s.shapes, k, assets, out)
108
+ if s.has_notes_slide and s.notes_slide.notes_text_frame.text.strip():
109
+ out.append(f'<aside class="notes">{html.escape(s.notes_slide.notes_text_frame.text.strip())}</aside>')
110
+ slides.append(f'<section data-background-color="{bg or "#ffffff"}">\n ' + "\n ".join(out) + "\n</section>")
111
+ return {"slides": slides, "size": [1920, round(prs.slide_height * k)]}
112
+
113
+
114
+ if __name__ == "__main__":
115
+ print(json.dumps(convert(Path(sys.argv[1]), Path(sys.argv[2]))))
glideshow/publish.py ADDED
@@ -0,0 +1,106 @@
1
+ """`glideshow publish` / Share: a link to a deck at Glideshow (glideshow.org/p/<owner>/<slug>/), docs/self-hosting.md.
2
+
3
+ The library's published.json says which decks are shared, under which link, as of which commit, and with whom:
4
+ "Lab/talk": {"slug": "talk-k3x9…", "rev": "<commit>", "access": "link" | "people", "people": ["a@b.org", "@harvard.edu", "octocat"],
5
+ "expires": "2026-12-31", "title": …, "url": …}
6
+ Sharing is that entry, committed and pushed with the library; glideshow.org reads it from the library's GitHub repo
7
+ (with the Glideshow app installed on it) and serves the deck's latest pushed version (`rev` only records the version it
8
+ was shared at). So the library must be on GitHub (`glideshow init-remote`), and the link follows each save once it's pushed. No index of decks exists, and robots are told
9
+ to stay out, so a deck is reachable only by its link."""
10
+
11
+ import json, secrets
12
+ from pathlib import Path
13
+
14
+ from .decks import HOME, deck_title
15
+ from .history import commit_library, commit_now, git, github_url
16
+
17
+ CONFIG = Path.home() / ".glideshow" / "config.json"
18
+ LEDGER = HOME / "published.json"
19
+ SITE = "https://glideshow.org"
20
+
21
+
22
+ def config() -> dict:
23
+ try: return json.loads(CONFIG.read_text())
24
+ except (OSError, ValueError): return {}
25
+
26
+
27
+ def ledger() -> dict:
28
+ try: return json.loads(LEDGER.read_text())
29
+ except (OSError, ValueError): return {}
30
+
31
+
32
+ def _rel(deck: Path) -> str:
33
+ if HOME.resolve() not in deck.resolve().parents: raise SystemExit(f"Only decks in your library ({HOME}) can be shared; move this one there first.")
34
+ return deck.resolve().relative_to(HOME.resolve()).as_posix()
35
+
36
+
37
+ def _owner() -> str:
38
+ """Whose library the link names: the GitHub owner of the library's repo (config "share_owner" overrides)."""
39
+ if config().get("share_owner"): return config()["share_owner"].lower()
40
+ web = github_url(HOME)
41
+ if not web: raise SystemExit("Sharing needs the library on GitHub (`glideshow init-remote`), with the Glideshow app installed on that repo.")
42
+ return web.rstrip("/").split("/")[-2].lower()
43
+
44
+
45
+ def _people(v) -> list:
46
+ items = v if isinstance(v, list) else str(v or "").replace(";", ",").replace("\n", ",").replace(" ", ",").split(",")
47
+ return list(dict.fromkeys(x.strip().lower() for x in items if x.strip()))[:200]
48
+
49
+
50
+ def state(entry: dict | None, in_library: bool = True) -> dict:
51
+ e = entry or {}
52
+ return {"url": e.get("url", ""), "access": e.get("access", "link"), "people": e.get("people", []), "editors": e.get("editors", []), "expires": e.get("expires", ""), "inLibrary": in_library}
53
+
54
+
55
+ def publish(deck: Path, op: str = "publish", access=None, people=None, expires=None, editors=None) -> dict:
56
+ """Share the deck (op "publish": its link, which always shows the latest pushed version) or change who can open it
57
+ (op "settings": the link stays). Returns state()."""
58
+ rel, entries = _rel(deck), ledger()
59
+ e: dict = dict(entries.get(rel) or {"slug": f"{deck.name[:40]}-{secrets.token_urlsafe(8).lower().replace('_', '').replace('-', '')[:10]}"})
60
+ if access is not None: e["access"] = "people" if access == "people" else "link"
61
+ if people is not None: e["people"] = _people(people)
62
+ if editors is not None: e["editors"] = _people(editors) # who may edit it at glideshow.org/e/…
63
+ if expires is not None: e["expires"] = expires if len(expires or "") == 10 else ""
64
+ e.setdefault("access", "link"); e.setdefault("people", []); e.setdefault("editors", [])
65
+ if op == "publish" or "rev" not in e: # pin the deck as it is now, its waiting save committed first
66
+ commit_now(deck)
67
+ e["rev"] = git(HOME, "rev-parse", "HEAD").stdout.strip()
68
+ e["title"] = deck_title(deck)
69
+ e["url"] = f"{config().get('share_site', SITE)}/p/{_owner()}/{e['slug']}/"
70
+ entries[rel] = e
71
+ LEDGER.write_text(json.dumps(entries, indent=2) + "\n")
72
+ commit_library(HOME, f"library: share {deck.name}") # pushed with it, which is when the link starts to work
73
+ return state(e)
74
+
75
+
76
+ def rekey(old: Path, new: Path) -> None:
77
+ """A deck (or a folder of decks) moved within the library: its shared entries follow, keeping their links."""
78
+ try: o, n = (p.resolve().relative_to(HOME.resolve()).as_posix() for p in (old, new))
79
+ except ValueError: return
80
+ entries = ledger()
81
+ moved = {(n + k[len(o):] if k == o or k.startswith(o + "/") else k): v for k, v in entries.items()}
82
+ if moved != entries: LEDGER.write_text(json.dumps(moved, indent=2) + "\n")
83
+
84
+
85
+ def drop(deck: Path) -> None:
86
+ """A deck (or a folder of decks) removed from the library stops being shared; committed with the removal."""
87
+ try: rel = deck.resolve().relative_to(HOME.resolve()).as_posix()
88
+ except ValueError: return
89
+ entries = ledger()
90
+ kept = {k: v for k, v in entries.items() if k != rel and not k.startswith(rel + "/")}
91
+ if kept != entries: LEDGER.write_text(json.dumps(kept, indent=2) + "\n")
92
+
93
+
94
+ def status(deck: Path) -> dict:
95
+ """Share's view of the deck: its link ('' when not shared), who can open it, until when."""
96
+ try: rel = deck.resolve().relative_to(HOME.resolve()).as_posix()
97
+ except ValueError: return state(None, in_library=False)
98
+ return state(ledger().get(rel))
99
+
100
+
101
+ def unpublish(deck: Path):
102
+ rel, entries = _rel(deck), ledger()
103
+ if rel not in entries: raise SystemExit(f"{rel} is not shared")
104
+ del entries[rel]
105
+ LEDGER.write_text(json.dumps(entries, indent=2) + "\n")
106
+ commit_library(HOME, f"library: stop sharing {deck.name}")