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/pptx_export.py
ADDED
|
@@ -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]))
|
glideshow/pptx_import.py
ADDED
|
@@ -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}")
|