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/gslides.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"""`glideshow import-google`: a Google Slides deck → a deck folder, through the Google Slides API, standard library only.
|
|
2
|
+
|
|
3
|
+
glideshow import-google <url | presentation id | saved .json> [DECK] [--pptx FILE] [--report] [--ir] [--no-open]
|
|
4
|
+
glideshow import-google --login # the one-time Google sign-in (prints the console steps if no client exists)
|
|
5
|
+
glideshow import-google --bookmarklet # the no-setup fallback: a bookmarklet that saves the deck from a signed-in tab
|
|
6
|
+
|
|
7
|
+
The API (`presentations.get`) gives every element with its transform in EMU, its text as runs (font, size, weight,
|
|
8
|
+
colour, bullets), fills, outlines, connectors, pictures (a short-lived download URL), tables, groups and the notes
|
|
9
|
+
page. Those become boxes one to one: `shapeType` names are the OOXML presets shapes.js draws. Click builds aren't in
|
|
10
|
+
the API: pass the deck's own .pptx download (File → Download → PowerPoint) as --pptx and they become reveal fragments;
|
|
11
|
+
the .pptx also gives each shape's adjust values (a dragged corner radius, arrow head …), which the API leaves out.
|
|
12
|
+
--ir also writes source/ir.json: what the importer drew from, inheritance followed.
|
|
13
|
+
|
|
14
|
+
Sign-in: an OAuth "Desktop app" client of your own (~/.glideshow/google.json, see LOGIN_STEPS), the consent page in the
|
|
15
|
+
browser once, the refresh token in ~/.glideshow/google-token.json. Scope: presentations.readonly only.
|
|
16
|
+
"""
|
|
17
|
+
import base64, hashlib, html, json, math, os, re, secrets, sys, threading, time, urllib.error, urllib.parse, urllib.request, webbrowser
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .decks import EDITOR, HOME, new_deck
|
|
22
|
+
|
|
23
|
+
CACHE = Path(os.environ.get("GLIDESHOW_CACHE") or Path.home() / ".glideshow")
|
|
24
|
+
CLIENT = CACHE / "google.json"
|
|
25
|
+
TOKEN = CACHE / "google-token.json"
|
|
26
|
+
SCOPE = "https://www.googleapis.com/auth/presentations.readonly"
|
|
27
|
+
API = "https://slides.googleapis.com/v1/presentations/"
|
|
28
|
+
EMU_PT = 12700
|
|
29
|
+
PX = 1920 / 9144000 # px per EMU on the default 10 in wide page (so 1 pt = 2.667 px, as everywhere in the editor)
|
|
30
|
+
|
|
31
|
+
LOGIN_STEPS = f"""Google import needs an OAuth client of your own (one-time, about 10 minutes; a Claude in Chrome session can do it):
|
|
32
|
+
1. console.cloud.google.com → New project (e.g. "slides-import"), with the Google account that owns the decks.
|
|
33
|
+
2. APIs & Services → Library → enable "Google Slides API" (only that one).
|
|
34
|
+
3. APIs & Services → OAuth consent screen → User type Internal if offered (a Workspace account), else External and
|
|
35
|
+
then Publish app. Not Testing mode: its sign-ins expire after 7 days.
|
|
36
|
+
4. Scopes → add {SCOPE}
|
|
37
|
+
5. Credentials → Create credentials → OAuth client ID → Desktop app → Download JSON → save it as
|
|
38
|
+
{CLIENT}
|
|
39
|
+
Then run `glideshow import-google --login` and click Allow in the browser."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------- sign-in
|
|
43
|
+
|
|
44
|
+
def _post(url: str, data: dict) -> dict:
|
|
45
|
+
req = urllib.request.Request(url, data=urllib.parse.urlencode(data).encode(), headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read())
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
raise SystemExit(f"Google sign-in failed: {e.read().decode()[:300]}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def client() -> dict:
|
|
53
|
+
try: c = json.loads(CLIENT.read_text())
|
|
54
|
+
except (OSError, ValueError): raise SystemExit(LOGIN_STEPS)
|
|
55
|
+
c = c.get("installed") or c.get("web") or c
|
|
56
|
+
if not c.get("client_id"): raise SystemExit(f"{CLIENT} isn't an OAuth client file.\n\n{LOGIN_STEPS}")
|
|
57
|
+
return c
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def login(scope: str = SCOPE, token: Path = TOKEN) -> dict:
|
|
61
|
+
"""The consent page in the browser, the code caught on localhost (PKCE), tokens cached. Returns the token record."""
|
|
62
|
+
c = client()
|
|
63
|
+
verifier = secrets.token_urlsafe(64)
|
|
64
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
65
|
+
state, got = secrets.token_urlsafe(16), {}
|
|
66
|
+
|
|
67
|
+
class Catch(BaseHTTPRequestHandler):
|
|
68
|
+
def do_GET(self):
|
|
69
|
+
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
70
|
+
ok = q.get("state", [""])[0] == state and "code" in q
|
|
71
|
+
if ok: got["code"] = q["code"][0]
|
|
72
|
+
self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.end_headers()
|
|
73
|
+
self.wfile.write(("<p style='font: 16px system-ui; margin: 40px'>Signed in. You can close this tab and go back to the terminal.</p>" if ok
|
|
74
|
+
else "<p style='font: 16px system-ui; margin: 40px'>Sign-in failed: " + html.escape(q.get("error", ["?"])[0]) + "</p>").encode())
|
|
75
|
+
def log_message(self, *a): pass
|
|
76
|
+
|
|
77
|
+
srv = HTTPServer(("127.0.0.1", 0), Catch)
|
|
78
|
+
redirect = f"http://127.0.0.1:{srv.server_address[1]}/"
|
|
79
|
+
url = c.get("auth_uri", "https://accounts.google.com/o/oauth2/v2/auth") + "?" + urllib.parse.urlencode({
|
|
80
|
+
"client_id": c["client_id"], "redirect_uri": redirect, "response_type": "code", "scope": scope, "state": state,
|
|
81
|
+
"access_type": "offline", "prompt": "consent", "code_challenge": challenge, "code_challenge_method": "S256"})
|
|
82
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
83
|
+
print("Opening Google's sign-in page; click Allow. If no browser opens, visit:\n" + url)
|
|
84
|
+
webbrowser.open(url)
|
|
85
|
+
for _ in range(600): # up to 5 minutes
|
|
86
|
+
if got: break
|
|
87
|
+
time.sleep(0.5)
|
|
88
|
+
srv.shutdown()
|
|
89
|
+
if not got: raise SystemExit("no sign-in within 5 minutes")
|
|
90
|
+
tok = _post(c.get("token_uri", "https://oauth2.googleapis.com/token"), {
|
|
91
|
+
"code": got["code"], "client_id": c["client_id"], "client_secret": c.get("client_secret", ""), "redirect_uri": redirect,
|
|
92
|
+
"grant_type": "authorization_code", "code_verifier": verifier})
|
|
93
|
+
tok["expires_at"] = time.time() + tok.get("expires_in", 3600) - 60
|
|
94
|
+
CACHE.mkdir(parents=True, exist_ok=True); token.write_text(json.dumps(tok, indent=2))
|
|
95
|
+
try: token.chmod(0o600)
|
|
96
|
+
except OSError: pass
|
|
97
|
+
print(f"Signed in; token saved in {token}")
|
|
98
|
+
return tok
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def access_token(scope: str = SCOPE, token: Path = TOKEN) -> str:
|
|
102
|
+
"""A valid access token: cached, refreshed, or (interactively) a new sign-in."""
|
|
103
|
+
try: tok = json.loads(token.read_text())
|
|
104
|
+
except (OSError, ValueError): tok = None
|
|
105
|
+
if not tok:
|
|
106
|
+
if not sys.stdin.isatty() and not os.environ.get("GLIDESHOW_GOOGLE_LOGIN"): raise SystemExit("not signed in to Google: run `glideshow import-google --login`")
|
|
107
|
+
tok = login(scope, token)
|
|
108
|
+
if tok.get("expires_at", 0) > time.time(): return tok["access_token"]
|
|
109
|
+
c = client()
|
|
110
|
+
try:
|
|
111
|
+
fresh = _post(c.get("token_uri", "https://oauth2.googleapis.com/token"), {
|
|
112
|
+
"refresh_token": tok.get("refresh_token", ""), "client_id": c["client_id"], "client_secret": c.get("client_secret", ""), "grant_type": "refresh_token"})
|
|
113
|
+
except SystemExit as e:
|
|
114
|
+
if "invalid_grant" in str(e):
|
|
115
|
+
raise SystemExit("Google sign-in expired (a consent screen still in Testing mode does this every 7 days: publish it, "
|
|
116
|
+
"or set the user type to Internal). Run `glideshow import-google --login` again.")
|
|
117
|
+
raise
|
|
118
|
+
tok.update(fresh); tok["expires_at"] = time.time() + fresh.get("expires_in", 3600) - 60
|
|
119
|
+
token.write_text(json.dumps(tok, indent=2))
|
|
120
|
+
return tok["access_token"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def presentation_id(s: str) -> str:
|
|
124
|
+
m = re.search(r"/presentation/d/([\w-]+)", s)
|
|
125
|
+
return m.group(1) if m else s.strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def api_get(path: str) -> dict:
|
|
129
|
+
"""One API call; a 429 (thumbnails count as "expensive reads", 60 a minute) or a 5xx waits and retries, up to ~2 minutes."""
|
|
130
|
+
req = urllib.request.Request(API + path, headers={"Authorization": "Bearer " + access_token()})
|
|
131
|
+
for attempt in range(6):
|
|
132
|
+
try:
|
|
133
|
+
with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read())
|
|
134
|
+
except urllib.error.HTTPError as e:
|
|
135
|
+
msg = e.read().decode()[:400]
|
|
136
|
+
if e.code == 429 or e.code >= 500:
|
|
137
|
+
wait = min(60, 5 * 2 ** attempt)
|
|
138
|
+
if attempt < 5: print(f" Google says slow down ({e.code}); waiting {wait}s"); time.sleep(wait); continue
|
|
139
|
+
if e.code == 403 and "has not been used" in msg: raise SystemExit("the Google Slides API isn't enabled on your project (step 2 of the setup):\n" + msg)
|
|
140
|
+
if e.code == 404: raise SystemExit("no such presentation (or this account can't open it)")
|
|
141
|
+
raise SystemExit(f"Google Slides API: {e.code} {msg}")
|
|
142
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
143
|
+
if attempt < 5: time.sleep(5 * 2 ** attempt); continue
|
|
144
|
+
raise SystemExit(f"Google Slides API unreachable: {e}")
|
|
145
|
+
raise SystemExit("Google Slides API: still rate-limited after retries; try again in a minute")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def fetch(pid: str) -> dict:
|
|
149
|
+
return api_get(pid)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def thumbnails(pid: str, pres: dict, out: Path) -> list:
|
|
153
|
+
"""Google's own render of each slide (1600 px wide PNG) into out/slide-NN.png: the references for --report."""
|
|
154
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
paths = []
|
|
156
|
+
for n, s in enumerate(pres.get("slides", []), 1):
|
|
157
|
+
p = out / f"slide-{n:02d}.png"
|
|
158
|
+
if not p.exists(): # a re-run after a quota stop continues where it stopped
|
|
159
|
+
t = api_get(f"{pid}/pages/{s['objectId']}/thumbnail?thumbnailProperties.thumbnailSize=LARGE&thumbnailProperties.mimeType=PNG")
|
|
160
|
+
with urllib.request.urlopen(t["contentUrl"], timeout=60) as r: p.write_bytes(r.read())
|
|
161
|
+
paths.append(p)
|
|
162
|
+
return paths
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------- the API's JSON → boxes
|
|
166
|
+
|
|
167
|
+
def _rgb(c: dict | None, scheme: dict[str, dict]) -> str | None:
|
|
168
|
+
"""An OpaqueColor → #rrggbb (theme colours through the master's scheme)."""
|
|
169
|
+
if not c: return None
|
|
170
|
+
c = dict(c.get("opaqueColor", c))
|
|
171
|
+
if "themeColor" in c: c = scheme.get(c["themeColor"], {"rgbColor": {}})
|
|
172
|
+
if any(k in c for k in ("red", "green", "blue")): c = {"rgbColor": c} # the theme palette holds bare RgbColor values
|
|
173
|
+
if "rgbColor" in c or c == {}: # the API drops zero components, and the whole rgbColor when it is black
|
|
174
|
+
v = c.get("rgbColor", {}); return "#%02x%02x%02x" % tuple(round(255 * float(v.get(k, 0))) for k in ("red", "green", "blue"))
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _color(fill: dict | None, scheme: dict) -> str | None:
|
|
179
|
+
"""A SolidFill (colour + alpha) → CSS colour; None when there's none."""
|
|
180
|
+
if not fill or "color" not in fill: return None
|
|
181
|
+
rgb = _rgb(fill["color"], scheme)
|
|
182
|
+
if rgb is None: return None
|
|
183
|
+
a = float(fill.get("alpha", 1))
|
|
184
|
+
return rgb if a >= 0.999 else rgb + "%02x" % round(a * 255)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _emu(v: dict | None) -> float:
|
|
188
|
+
if not v: return 0.0
|
|
189
|
+
m = float(v.get("magnitude", 0))
|
|
190
|
+
return m * EMU_PT if v.get("unit") == "PT" else m
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
DASH = {"DOT": "dot", "DASH": "dash", "DASH_DOT": "dashdot", "LONG_DASH": "longdash", "LONG_DASH_DOT": "longdashdot"}
|
|
194
|
+
ARROW = {"STEALTH_ARROW": "arrow", "FILL_ARROW": "fill-arrow", "FILL_CIRCLE": "circle", "FILL_SQUARE": "square", "FILL_DIAMOND": "diamond",
|
|
195
|
+
"OPEN_ARROW": "open-arrow", "OPEN_CIRCLE": "open-circle", "OPEN_SQUARE": "open-square", "OPEN_DIAMOND": "open-diamond"}
|
|
196
|
+
ALIGN = {"CENTER": "center", "END": "right", "JUSTIFIED": "justify"}
|
|
197
|
+
VALIGN = {"TOP": "top", "BOTTOM": "bottom"}
|
|
198
|
+
# Google puts a line's baseline 0.351 em below its middle in every font; CSS puts it at half the font's
|
|
199
|
+
# hhea ascent − descent (as Chrome reads the font file), so a run in a font far from 0.351 is shifted by the difference.
|
|
200
|
+
# Each font's middle: editor/data/font-metrics.json (tools/font_metrics.py); a font not in it isn't shifted.
|
|
201
|
+
BASELINE = 0.351
|
|
202
|
+
# Google's default adjust values where they aren't the ECMA-376 preset's (the API doesn't give them; Google's own .pptx
|
|
203
|
+
# export does), scaled 0–1 as data-adj, from the box (w, h); the preset pins each to its range (a
|
|
204
|
+
# brace's 0.5 becomes a quarter of its height)
|
|
205
|
+
GOOGLE_ADJ = {"HEXAGON": lambda w, h: (0.28852,), "LEFT_BRACE": lambda w, h: (0.5,), "RIGHT_BRACE": lambda w, h: (0.5,)}
|
|
206
|
+
# (the export also says a wedgeRoundRectCallout's corner, adj3, is 0, but Google draws it rounded, at ECMA's 16667)
|
|
207
|
+
try: HHEA_MID = json.loads((EDITOR / "data" / "font-metrics.json").read_text())
|
|
208
|
+
except (OSError, ValueError): HHEA_MID = {}
|
|
209
|
+
WEBSAFE = {"Arial", "Helvetica", "Times New Roman", "Georgia", "Courier New", "Verdana", "Trebuchet MS", "Impact", "Comic Sans MS", "Calibri", "Cambria", "Tahoma"}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def preset_map() -> dict:
|
|
213
|
+
"""The API's shapeType names (RIGHT_ARROW) → our data-shape names (arrow), from shapes.js' PRESET_OF (rightArrow)."""
|
|
214
|
+
js = (EDITOR / "deck" / "shapes.js").read_text()
|
|
215
|
+
po = js[js.index("PRESET_OF = {"):js.index("};", js.index("PRESET_OF = {"))]
|
|
216
|
+
ours = {}
|
|
217
|
+
for name, prst in re.findall(r"['\"]?([\w-]+)['\"]?:\s*'(\w+)'", po):
|
|
218
|
+
ours[re.sub(r"(?<=[a-z])(?=[A-Z0-9])", "_", prst).upper()] = name
|
|
219
|
+
ours.update({"RECTANGLE": "rect", "ROUND_RECTANGLE": "roundrect", "ELLIPSE": "ellipse", "TEXT_BOX": None, "STAR_5": "star", "MATH_PLUS": "plus",
|
|
220
|
+
"WEDGE_RECTANGLE_CALLOUT": "callout", "WEDGE_ROUND_RECTANGLE_CALLOUT": "roundcallout", "WEDGE_ELLIPSE_CALLOUT": "ovalcallout"})
|
|
221
|
+
# the API's names where they aren't the preset's (rtTriangle is RIGHT_TRIANGLE, round1Rect ROUND_1_RECTANGLE …)
|
|
222
|
+
for api, prst in (("RIGHT_TRIANGLE", "RT_TRIANGLE"), ("DIAGONAL_STRIPE", "DIAG_STRIPE"), ("ROUND_1_RECTANGLE", "ROUND_1RECT"),
|
|
223
|
+
("ROUND_2_DIAGONAL_RECTANGLE", "ROUND_2DIAG_RECT"), ("ROUND_2_SAME_RECTANGLE", "ROUND_2SAME_RECT"), ("SNIP_1_RECTANGLE", "SNIP_1RECT"),
|
|
224
|
+
("SNIP_2_DIAGONAL_RECTANGLE", "SNIP_2DIAG_RECT"), ("SNIP_2_SAME_RECTANGLE", "SNIP_2SAME_RECT"), ("SNIP_ROUND_RECTANGLE", "SNIP_ROUND_RECT")):
|
|
225
|
+
if prst in ours: ours[api] = ours[prst]
|
|
226
|
+
return ours
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _mat(t: dict | None) -> tuple:
|
|
230
|
+
if not t: return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
|
231
|
+
u = EMU_PT if t.get("unit") == "PT" else 1
|
|
232
|
+
# the API leaves out fields that are 0, scaleX / scaleY included (a 90° rotation has both at 0)
|
|
233
|
+
return (float(t.get("scaleX", 0)), float(t.get("shearY", 0)), float(t.get("shearX", 0)), float(t.get("scaleY", 0)),
|
|
234
|
+
float(t.get("translateX", 0)) * u, float(t.get("translateY", 0)) * u)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _mul(p: tuple, c: tuple) -> tuple:
|
|
238
|
+
"""parent ∘ child (the child's transform first, then the parent's), both as (a, b, c, d, tx, ty)."""
|
|
239
|
+
a, b, cc, d, tx, ty = p; a2, b2, c2, d2, tx2, ty2 = c
|
|
240
|
+
return (a * a2 + cc * b2, b * a2 + d * b2, a * c2 + cc * d2, b * c2 + d * d2, a * tx2 + cc * ty2 + tx, b * tx2 + d * ty2 + ty)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Geometry:
|
|
244
|
+
"""An element's box on our canvas: left/top/width/height px, rotation deg, flip, from its absolute transform and size."""
|
|
245
|
+
def __init__(self, m: tuple, size: dict | None, k: float):
|
|
246
|
+
a, b, c, d, tx, ty = m
|
|
247
|
+
w0, h0 = _emu((size or {}).get("width")), _emu((size or {}).get("height"))
|
|
248
|
+
sx, sy = math.hypot(a, b), math.hypot(c, d)
|
|
249
|
+
self.w, self.h = w0 * sx * k, h0 * sy * k
|
|
250
|
+
self.flip = (a * d - b * c) < 0
|
|
251
|
+
self.rot = (math.degrees(math.atan2(-b, -a)) if self.flip else math.degrees(math.atan2(b, a))) if sx else 0.0 # flipped: M = R(θ)·flipH·S
|
|
252
|
+
cx, cy = tx + a * w0 / 2 + c * h0 / 2, ty + b * w0 / 2 + d * h0 / 2 # the centre is where rotation doesn't move it
|
|
253
|
+
self.x, self.y = cx * k - self.w / 2, cy * k - self.h / 2
|
|
254
|
+
self.a, self.b, self.c, self.d, self.tx, self.ty = a, b, c, d, tx, ty
|
|
255
|
+
self.k = k
|
|
256
|
+
|
|
257
|
+
def point(self, u: float, v: float) -> tuple:
|
|
258
|
+
return ((self.tx + self.a * u + self.c * v) * self.k, (self.ty + self.b * u + self.d * v) * self.k)
|
|
259
|
+
|
|
260
|
+
def style(self) -> str:
|
|
261
|
+
s = f"left: {_px(self.x)}px; top: {_px(self.y)}px; width: {_px(self.w)}px; height: {_px(self.h)}px;"
|
|
262
|
+
return s + (f" transform: rotate({self.rot:.2f}deg);" if abs(self.rot) > 0.05 else "")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _px(v: float) -> str:
|
|
266
|
+
return f"{v:.1f}".rstrip("0").rstrip(".") if abs(v) >= 0.05 else "0"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def list_style(glyph: str, depth: int) -> tuple:
|
|
270
|
+
"""A bullet glyph → (ul|ol, list-style-type or None when it is the level's default), in the editor's own vocabulary."""
|
|
271
|
+
g = glyph.strip()
|
|
272
|
+
if re.match(r"^(\d+)[.)]$", g): return ("ol", None if depth == 0 else "decimal")
|
|
273
|
+
if re.match(r"^[ivx]+[.)]$", g): return ("ol", "lower-roman")
|
|
274
|
+
if re.match(r"^[IVX]+[.)]$", g): return ("ol", "upper-roman")
|
|
275
|
+
if re.match(r"^[a-z][.)]$", g): return ("ol", "lower-alpha")
|
|
276
|
+
if re.match(r"^[A-Z][.)]$", g): return ("ol", "upper-alpha")
|
|
277
|
+
default = ["disc", "circle", "square"][min(depth, 2)]
|
|
278
|
+
named = {"●": "disc", "•": "disc", "○": "circle", "◦": "circle", "■": "square", "▪": "square"}
|
|
279
|
+
if g in named: return ("ul", None if named[g] == default else named[g])
|
|
280
|
+
if g in ("-", "–", "—"): return ("ul", f"'{g} '")
|
|
281
|
+
return ("ul", f"'{g} '" if g and "'" not in g else None)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class Converter:
|
|
285
|
+
def __init__(self, pres: dict, deck: Path, get=None, log=None):
|
|
286
|
+
self.pres, self.deck = pres, deck
|
|
287
|
+
self.get = get or self._download # url → bytes (tests pass their own)
|
|
288
|
+
self.log = log if log is not None else []
|
|
289
|
+
W = _emu(pres["pageSize"]["width"]); H = _emu(pres["pageSize"]["height"])
|
|
290
|
+
self.k = 1920 / W
|
|
291
|
+
self.height = round(H * self.k)
|
|
292
|
+
self.presets = preset_map()
|
|
293
|
+
self.pages = {p["objectId"]: p for p in pres.get("masters", []) + pres.get("layouts", []) + pres.get("slides", [])}
|
|
294
|
+
self.elements = {}
|
|
295
|
+
for p in self.pages.values():
|
|
296
|
+
for e in self._walk(p.get("pageElements", [])): self.elements[e["objectId"]] = e
|
|
297
|
+
self.scheme = {}
|
|
298
|
+
for m in pres.get("masters", []):
|
|
299
|
+
for c in m.get("pageProperties", {}).get("colorScheme", {}).get("colors", []):
|
|
300
|
+
self.scheme[c["type"]] = c["color"]
|
|
301
|
+
self.fonts = set()
|
|
302
|
+
self.pptx_adj = {} # adjust values from the deck's .pptx (pptx_adjusts), when one is given
|
|
303
|
+
self.glyphs = set() # characters past Latin-1 in Google Fonts runs: Google Fonts' css2 subsets may leave them out
|
|
304
|
+
self.n = 0
|
|
305
|
+
self.google = self._google_fonts()
|
|
306
|
+
# pictures by element id, so a re-convert from the saved JSON (its download URLs expire within the hour) keeps them
|
|
307
|
+
self.images_file = deck / "source" / "images.json"
|
|
308
|
+
try: self.images = json.loads(self.images_file.read_text())
|
|
309
|
+
except (OSError, ValueError): self.images = {}
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _google_fonts() -> set:
|
|
313
|
+
try: return {r[0] for r in json.loads((EDITOR / "data" / "fonts.json").read_text())}
|
|
314
|
+
except (OSError, ValueError, TypeError): return set()
|
|
315
|
+
|
|
316
|
+
def _walk(self, els):
|
|
317
|
+
for e in els:
|
|
318
|
+
yield e
|
|
319
|
+
if "elementGroup" in e: yield from self._walk(e["elementGroup"].get("children", []))
|
|
320
|
+
|
|
321
|
+
def _download(self, url: str) -> bytes:
|
|
322
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}), timeout=60) as r: return r.read()
|
|
323
|
+
|
|
324
|
+
def nid(self) -> str:
|
|
325
|
+
self.n += 1; return f"g{self.slide}-{self.n}"
|
|
326
|
+
|
|
327
|
+
# ---- inheritance: a placeholder's parent on the layout, then the master
|
|
328
|
+
def parents(self, el: dict) -> list:
|
|
329
|
+
out, seen = [], set()
|
|
330
|
+
while el and "shape" in el:
|
|
331
|
+
pid = el["shape"].get("placeholder", {}).get("parentObjectId")
|
|
332
|
+
if not pid or pid in seen or pid not in self.elements: break
|
|
333
|
+
seen.add(pid); el = self.elements[pid]; out.append(el)
|
|
334
|
+
return out
|
|
335
|
+
|
|
336
|
+
def prop(self, el: dict, *path, default=None):
|
|
337
|
+
"""A shapeProperties value, inherited from the placeholder chain when unset / INHERIT."""
|
|
338
|
+
for e in [el] + self.parents(el):
|
|
339
|
+
v = e.get("shape", {}).get("shapeProperties", {})
|
|
340
|
+
for p in path: v = v.get(p, {}) if isinstance(v, dict) else {}
|
|
341
|
+
if v not in ({}, None): return v
|
|
342
|
+
return default
|
|
343
|
+
|
|
344
|
+
def fill(self, el: dict) -> str | None:
|
|
345
|
+
for e in [el] + self.parents(el):
|
|
346
|
+
f = e.get("shape", {}).get("shapeProperties", {}).get("shapeBackgroundFill")
|
|
347
|
+
if not f: continue
|
|
348
|
+
st = f.get("propertyState", "RENDERED")
|
|
349
|
+
if st == "NOT_RENDERED": return None
|
|
350
|
+
if st == "RENDERED" and "solidFill" in f: return _color(f["solidFill"], self.scheme)
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def outline(self, el: dict) -> tuple:
|
|
354
|
+
"""(colour, width px, dash) or (None, 0, None)."""
|
|
355
|
+
for e in [el] + self.parents(el):
|
|
356
|
+
o = e.get("shape", {}).get("shapeProperties", {}).get("outline")
|
|
357
|
+
if not o: continue
|
|
358
|
+
st = o.get("propertyState", "RENDERED")
|
|
359
|
+
if st == "NOT_RENDERED": return (None, 0, None)
|
|
360
|
+
if st == "RENDERED" and ("outlineFill" in o or "weight" in o):
|
|
361
|
+
col = _color(o.get("outlineFill", {}).get("solidFill"), self.scheme)
|
|
362
|
+
w = _emu(o.get("weight")) * self.k if o.get("weight") else 1 * EMU_PT * self.k
|
|
363
|
+
return (col, w, DASH.get(o.get("dashStyle", "SOLID")))
|
|
364
|
+
return (None, 0, None)
|
|
365
|
+
|
|
366
|
+
# ---- text
|
|
367
|
+
def text_defaults(self, el: dict) -> list:
|
|
368
|
+
"""Run and paragraph styles inherited by nesting level: [(text style, paragraph style) per level], deepest parent first."""
|
|
369
|
+
chain = []
|
|
370
|
+
for p in reversed(self.parents(el)):
|
|
371
|
+
levels = {}
|
|
372
|
+
for para in self.paragraphs(p.get("shape", {}).get("text", {})):
|
|
373
|
+
lvl = para["level"]
|
|
374
|
+
if lvl in levels: continue
|
|
375
|
+
first = next((r for r in para["runs"] if "textRun" in r), None)
|
|
376
|
+
levels[lvl] = ((first or {}).get("textRun", {}).get("style", {}), para["style"])
|
|
377
|
+
chain.append(levels)
|
|
378
|
+
return chain
|
|
379
|
+
|
|
380
|
+
@staticmethod
|
|
381
|
+
def paragraphs(text: dict) -> list:
|
|
382
|
+
"""textElements → [{style, level, bullet, runs}]: a paragraphMarker then its runs up to the next marker."""
|
|
383
|
+
out, cur = [], None
|
|
384
|
+
for te in text.get("textElements", []):
|
|
385
|
+
if "paragraphMarker" in te:
|
|
386
|
+
pm = te["paragraphMarker"]
|
|
387
|
+
cur = {"style": pm.get("style", {}), "bullet": pm.get("bullet"), "level": (pm.get("bullet") or {}).get("nestingLevel", 0), "runs": []}
|
|
388
|
+
out.append(cur)
|
|
389
|
+
elif cur is not None:
|
|
390
|
+
cur["runs"].append(te)
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
def family_css(self, st: dict) -> str:
|
|
394
|
+
fam = (st.get("weightedFontFamily") or {}).get("fontFamily") or st.get("fontFamily")
|
|
395
|
+
if not fam: return ""
|
|
396
|
+
if fam not in WEBSAFE: self.fonts.add(fam)
|
|
397
|
+
return f"font-family: '{fam}'" + (", serif" if fam in ("Georgia", "Times New Roman", "Merriweather", "Lora", "Playfair Display", "EB Garamond", "Roboto Slab") else ", sans-serif")
|
|
398
|
+
|
|
399
|
+
def run_html(self, r: dict, scale: float, para: tuple = ("", "")) -> str:
|
|
400
|
+
"""One resolved run (resolve_text) as a <span>. `para`: its paragraph's own font-size and font-family CSS, which
|
|
401
|
+
the run inherits, so a run states them only where it differs (one place to change a paragraph's size)."""
|
|
402
|
+
st, t = r["style"], r["text"]
|
|
403
|
+
if t.endswith("\n"): t = t[:-1]
|
|
404
|
+
if not t: return ""
|
|
405
|
+
css = []
|
|
406
|
+
fam = self.family_css(st)
|
|
407
|
+
if fam and fam != para[1]: css.append(fam)
|
|
408
|
+
if ((st.get("weightedFontFamily") or {}).get("fontFamily") or st.get("fontFamily")) in self.google:
|
|
409
|
+
self.glyphs.update(ch for ch in t if ord(ch) > 0xFF and not ch.isspace())
|
|
410
|
+
fpx = float((st.get("fontSize") or {}).get("magnitude", 14)) * scale * EMU_PT * self.k # the run's size, in canvas px
|
|
411
|
+
shift = BASELINE - HHEA_MID.get((st.get("weightedFontFamily") or {}).get("fontFamily") or st.get("fontFamily") or "Arial", BASELINE)
|
|
412
|
+
if abs(shift) >= 0.01: css.append(f"position: relative; top: {_px(shift * fpx)}px")
|
|
413
|
+
off = st.get("baselineOffset")
|
|
414
|
+
if off in ("SUPERSCRIPT", "SUBSCRIPT"): # Google: 2/3 the size, the baseline ±0.375 em, and the line keeps its height
|
|
415
|
+
css.append(f"font-size: {_px(fpx * 2 / 3)}px; vertical-align: {_px(0.375 * fpx * (1 if off == 'SUPERSCRIPT' else -1))}px; line-height: 0")
|
|
416
|
+
elif st.get("fontSize") and f"font-size: {_px(fpx)}px" != para[0]: css.append(f"font-size: {_px(fpx)}px")
|
|
417
|
+
wt = (st.get("weightedFontFamily") or {}).get("weight")
|
|
418
|
+
if st.get("bold") or (wt and wt >= 600): css.append("font-weight: bold")
|
|
419
|
+
if st.get("italic"): css.append("font-style: italic")
|
|
420
|
+
deco = [d for d, on in (("underline", st.get("underline")), ("line-through", st.get("strikethrough"))) if on]
|
|
421
|
+
if deco: css.append("text-decoration: " + " ".join(deco))
|
|
422
|
+
col = _rgb(st.get("foregroundColor"), self.scheme) # an OptionalColor: {opaqueColor}, no alpha
|
|
423
|
+
if col: css.append(f"color: {col}")
|
|
424
|
+
bg = _rgb(st.get("backgroundColor"), self.scheme)
|
|
425
|
+
if bg: css.append(f"background: {bg}")
|
|
426
|
+
parts = [html.escape(p) for p in t.split("\v")]
|
|
427
|
+
inner = "<br>".join(parts)
|
|
428
|
+
if not inner.strip(): inner = " " * max(1, len(inner)) # a blank paragraph still takes a line (whitespace alone collapses)
|
|
429
|
+
if st.get("link", {}).get("url"): inner = f'<a href="{html.escape(st["link"]["url"], quote=True)}">{inner}</a>'
|
|
430
|
+
return f'<span style="{"; ".join(css)}">{inner}</span>' if css else inner
|
|
431
|
+
|
|
432
|
+
def resolve_text(self, el: dict, text: dict) -> list:
|
|
433
|
+
"""The IR's paragraphs: every style with its inheritance followed (the placeholder chain per
|
|
434
|
+
nesting level, then Slides' own defaults). [{level, bullet, style, base, runs: [{text, style} | {auto}]}]"""
|
|
435
|
+
chain = self.text_defaults(el)
|
|
436
|
+
# Slides' own defaults for text with nothing set anywhere: Arial 14 pt in the theme's text colour
|
|
437
|
+
defaults = {"fontFamily": "Arial", "fontSize": {"magnitude": 14, "unit": "PT"}, "foregroundColor": {"opaqueColor": {"themeColor": "DARK1"}} if "DARK1" in self.scheme else {"opaqueColor": {"rgbColor": {}}}}
|
|
438
|
+
out = []
|
|
439
|
+
for para in self.paragraphs(text):
|
|
440
|
+
base_t, base_p = dict(defaults), {}
|
|
441
|
+
for levels in chain:
|
|
442
|
+
t, p = levels.get(para["level"]) or levels.get(0) or ({}, {})
|
|
443
|
+
base_t.update(t); base_p.update(p)
|
|
444
|
+
runs = [{"text": r["textRun"].get("content", ""), "style": {**base_t, **r["textRun"].get("style", {})}} if "textRun" in r
|
|
445
|
+
else {"auto": r.get("autoText", {}).get("content", "")} for r in para["runs"]]
|
|
446
|
+
out.append({"level": para["level"], "bullet": para["bullet"], "style": {**base_p, **para["style"]}, "base": base_t, "runs": runs})
|
|
447
|
+
return out
|
|
448
|
+
|
|
449
|
+
def text_html(self, paras: list, scale: float = 1.0, lsr: float = 1.0, in_text_box: bool = False) -> str:
|
|
450
|
+
"""Resolved paragraphs (resolve_text) as .content HTML. Paragraph alignment / spacing / indents inline per paragraph; bullets as lists."""
|
|
451
|
+
out, stack = [], [] # stack: open list levels
|
|
452
|
+
for pi, para in enumerate(paras):
|
|
453
|
+
ps, base_t = para["style"], para["base"]
|
|
454
|
+
texts = [r for r in para["runs"] if "text" in r]
|
|
455
|
+
# the paragraph's own font size (its biggest run's, or the inherited one): line-height is computed on it, so line pitch is exact
|
|
456
|
+
sizes = [float((r["style"].get("fontSize") or {}).get("magnitude", 14)) for r in texts] or [float((base_t.get("fontSize") or {}).get("magnitude", 14))]
|
|
457
|
+
css = [f"font-size: {_px(min(sizes) * scale * EMU_PT * self.k)}px"] # the smallest run: the paragraph's own strut must never exceed a line's text
|
|
458
|
+
# and the first run's font: a strut in another font (other ascent/descent) would stretch every line past its line-height
|
|
459
|
+
first_run = texts[0]["style"] if texts else base_t
|
|
460
|
+
fam = self.family_css(first_run)
|
|
461
|
+
if fam: css.append(fam)
|
|
462
|
+
runs = "".join(self.run_html(r, scale, (css[0], fam)) if "text" in r else html.escape(r["auto"]) for r in para["runs"]) # runs say only what differs
|
|
463
|
+
al = ALIGN.get(ps.get("alignment", ""))
|
|
464
|
+
if al: css.append(f"text-align: {al}")
|
|
465
|
+
# a text box wraps 2 px wider than Google's inset (a word overflowing by 1 px at 96 dpi stays): text aligned
|
|
466
|
+
# right or centred is drawn back to Google's edge, justified text splits the 2 px between its two ends
|
|
467
|
+
if al and in_text_box:
|
|
468
|
+
css.append(f"position: relative; left: {-2 if al == 'right' else -1}px")
|
|
469
|
+
# measured on Google's renders: single spacing is 1.2 × the font size, and autofit's lineSpacingReduction comes off the fraction
|
|
470
|
+
ls = float(ps.get("lineSpacing", 100) or 100) / 100 - (1 - lsr)
|
|
471
|
+
if len(set(sizes)) == 1: # Google rounds a line's pitch to whole 96-dpi px (0.75 pt): 12 pt → 19 px, 24 pt → 38 px
|
|
472
|
+
lh, lh1 = (round(1.2 * f * sizes[0] * scale * 4 / 3) * 0.75 * EMU_PT * self.k for f in (ls, 1))
|
|
473
|
+
if lsr < 1: lh = 1.2 * ls * sizes[0] * scale * EMU_PT * self.k # shrink on overflow's reduced spacing isn't rounded
|
|
474
|
+
css.append(f"line-height: {_px(lh)}px")
|
|
475
|
+
# Google keeps the first baseline where single spacing puts it (b1, from the line's top) and adds the
|
|
476
|
+
# extra below; under single spacing it puts it at spacing × b1. CSS splits the leading above and
|
|
477
|
+
# below the text instead: move the paragraph by the difference
|
|
478
|
+
b1 = lh1 / 2 + BASELINE * sizes[0] * scale * EMU_PT * self.k
|
|
479
|
+
shift = (min(ls, 1) - 1) * b1 - (lh - lh1) / 2
|
|
480
|
+
if abs(shift) > 0.25: css.append(f"position: relative; top: {_px(shift)}px")
|
|
481
|
+
else: # mixed sizes: unitless, so a bigger run's line grows as Google's does
|
|
482
|
+
css.append(f"line-height: {1.2 * ls:.3f}")
|
|
483
|
+
# COLLAPSE_LISTS (Slides' default): no paragraph spacing between items of one list, only around the list
|
|
484
|
+
same_list = lambda q: q is not None and bool(q["bullet"]) and bool(para["bullet"]) # Slides: "spacing is skipped between list elements"
|
|
485
|
+
collapse = ps.get("spacingMode", "COLLAPSE_LISTS") == "COLLAPSE_LISTS" and para["bullet"]
|
|
486
|
+
prev_, next_ = (paras[pi - 1] if pi else None), (paras[pi + 1] if pi + 1 < len(paras) else None)
|
|
487
|
+
for key, prop, skip in (("spaceAbove", "margin-top", collapse and same_list(prev_)), ("spaceBelow", "margin-bottom", collapse and same_list(next_))):
|
|
488
|
+
if not skip and ps.get(key) and float(ps[key].get("magnitude", 0)) > 0: css.append(f"{prop}: {_px(float(ps[key]['magnitude']) * EMU_PT * self.k)}px")
|
|
489
|
+
ind = float(ps.get("indentStart", {}).get("magnitude", 0)) if ps.get("indentStart") else 0
|
|
490
|
+
lvl = para["level"] if para["bullet"] else -1
|
|
491
|
+
while len(stack) > (lvl + 1): out.append(f"</{stack.pop()[0]}>")
|
|
492
|
+
if lvl >= 0:
|
|
493
|
+
while len(stack) < lvl + 1:
|
|
494
|
+
glyph = (para["bullet"] or {}).get("glyph", "")
|
|
495
|
+
tag, ls = list_style(glyph, len(stack))
|
|
496
|
+
# the text starts at indentStart (Slides: 36 pt per level); the list's padding is the step from the level outside it
|
|
497
|
+
outer = stack[-1][1] if stack else 0.0
|
|
498
|
+
step = (ind if ind > 0.5 else 36.0 * (len(stack) + 1)) - outer
|
|
499
|
+
st = "; ".join(s for s in (f"list-style-type: {ls}" if ls else "", f"padding-left: {_px(step * EMU_PT * self.k)}px") if s)
|
|
500
|
+
out.append(f'<{tag} data-mk style="{st}">'); stack.append((tag, outer + step))
|
|
501
|
+
# the marker: Google's glyph (or number style) in the bullet's colour, at indentFirstLine (base.css data-mk)
|
|
502
|
+
first = float((ps.get("indentFirstLine") or {}).get("magnitude", 0) or 0)
|
|
503
|
+
css.append(f"--mk: {_px(max(0.0, (ind if ind > 0.5 else 36.0 * (lvl + 1)) - first) * EMU_PT * self.k)}px")
|
|
504
|
+
b = para["bullet"] or {}; g = b.get("glyph", "").strip()
|
|
505
|
+
bcol = _rgb((b.get("bulletStyle") or {}).get("foregroundColor") or first_run.get("foregroundColor"), self.scheme)
|
|
506
|
+
if bcol: css.append(f"color: {bcol}")
|
|
507
|
+
fr = first_run; bl = BASELINE - HHEA_MID.get((fr.get("weightedFontFamily") or {}).get("fontFamily") or fr.get("fontFamily") or "Arial", BASELINE)
|
|
508
|
+
if abs(bl) >= 0.01: css.append(f"--bl: {_px(bl * sizes[0] * scale * EMU_PT * self.k)}px")
|
|
509
|
+
if stack[-1][0] == "ol":
|
|
510
|
+
num = "lower-roman" if re.match(r"^[ivx]+", g) else "upper-roman" if re.match(r"^[IVX]+", g) else "lower-alpha" if re.match(r"^[a-z]", g) else "upper-alpha" if re.match(r"^[A-Z]", g) else "decimal"
|
|
511
|
+
css.append(f"--num: {num}; --sfx: '{g[-1] if g[-1:] in '.)' else ''}'")
|
|
512
|
+
elif g and "'" not in g and "\\" not in g: css.append(f"--glyph: '{g}'")
|
|
513
|
+
st = ' style="' + '; '.join(css) + '"' if css else '' # (no nested same quotes in an f-string before Python 3.12)
|
|
514
|
+
out.append(f'<li{st}>{runs or "<br>"}</li>')
|
|
515
|
+
else:
|
|
516
|
+
if ind > 0.5: css.append(f"padding-left: {_px(ind * EMU_PT * self.k)}px")
|
|
517
|
+
st = ' style="' + '; '.join(css) + '"' if css else ''
|
|
518
|
+
out.append(f'<div{st}>{runs or "<br>"}</div>')
|
|
519
|
+
while stack: out.append(f"</{stack.pop()[0]}>")
|
|
520
|
+
return "".join(out)
|
|
521
|
+
|
|
522
|
+
def plain_text(self, text: dict) -> str:
|
|
523
|
+
return "\n".join("".join(r.get("textRun", {}).get("content", "") for r in p["runs"]).rstrip("\n") for p in self.paragraphs(text)).strip()
|
|
524
|
+
|
|
525
|
+
# ---- elements
|
|
526
|
+
def pptx_adjust(self, g: Geometry) -> list | None:
|
|
527
|
+
"""The shape's adjust values from the deck's own .pptx (Google's export: the API doesn't give them), matched by
|
|
528
|
+
slide, position and size; None when there's no .pptx or no shape there."""
|
|
529
|
+
for x, y, w, h, vals in self.pptx_adj.get(self.slide, []):
|
|
530
|
+
if abs(x - g.x) < 1 and abs(y - g.y) < 1 and abs(w - g.w) < 1 and abs(h - g.h) < 1: return vals
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
def resolve_shape(self, el: dict, g: Geometry) -> dict | None:
|
|
534
|
+
"""The IR's shape: what Google draws, inheritance followed. None for an empty placeholder."""
|
|
535
|
+
sh = el["shape"]
|
|
536
|
+
kind = sh.get("shapeType", "TEXT_BOX")
|
|
537
|
+
ph = sh.get("placeholder", {}).get("type")
|
|
538
|
+
text = sh.get("text", {})
|
|
539
|
+
has_text = bool(self.plain_text(text))
|
|
540
|
+
if ph and not has_text: return None # an empty placeholder: Slides doesn't show it
|
|
541
|
+
name = self.presets.get(kind, "__missing__") if kind != "TEXT_BOX" else None
|
|
542
|
+
if name not in (None, "rect", "ellipse", "__missing__") and not getattr(self, "_adj_noted", False):
|
|
543
|
+
self._adj_noted = True
|
|
544
|
+
self.log.append("shape adjust handles (arrow head size, corner radius, X thickness …) aren't in the Slides API: presets use their defaults; fix by eye where it shows")
|
|
545
|
+
if name == "__missing__":
|
|
546
|
+
self.log.append(f"slide {self.slide}: shape type {kind} isn't a preset shapes.js has; drawn as a rectangle")
|
|
547
|
+
name = "rect"
|
|
548
|
+
sc, sw, dash = self.outline(el)
|
|
549
|
+
af = self.prop(el, "autofit", default={}) or {}
|
|
550
|
+
adj = self.pptx_adjust(g) if name else None
|
|
551
|
+
if adj is not None and kind == "WEDGE_ROUND_RECTANGLE_CALLOUT": adj = adj[:2] # the export says its corner is 0; Google draws ECMA's
|
|
552
|
+
return {"kind": kind, "preset": name, "placeholder": ph, "fill": self.fill(el), "outline": {"color": sc, "width": sw, "dash": dash},
|
|
553
|
+
"adjust": adj if adj is not None else list(GOOGLE_ADJ[kind](g.w, g.h)) if name and kind in GOOGLE_ADJ and g.w > 0 and g.h > 0 else [],
|
|
554
|
+
"valign": self.prop(el, "contentAlignment", default="TOP" if not name else "MIDDLE"),
|
|
555
|
+
"autofit": {"type": af.get("autofitType", "NONE"), "scale": float(af.get("fontScale", 1) or 1),
|
|
556
|
+
"lineReduction": float(af.get("lineSpacingReduction", 0) or 0)},
|
|
557
|
+
"label": el.get("description") or el.get("title") or "", "paragraphs": self.resolve_text(el, text) if has_text else []}
|
|
558
|
+
|
|
559
|
+
def shape(self, el: dict, g: Geometry) -> str | None:
|
|
560
|
+
s = self.resolve_shape(el, g)
|
|
561
|
+
if s is None: return None
|
|
562
|
+
name, fill, (sc, sw, dash), ph = s["preset"], s["fill"], s["outline"].values(), s["placeholder"]
|
|
563
|
+
style = g.style()
|
|
564
|
+
attrs = ""
|
|
565
|
+
if name:
|
|
566
|
+
attrs += f' data-shape="{name}"'
|
|
567
|
+
for n, v in enumerate(s["adjust"], 1): attrs += f' data-adj{"" if n == 1 else n}="{v}"'
|
|
568
|
+
style += f" --fill: {fill or 'transparent'}; --stroke: {sc or 'transparent'}; --sw: {_px(sw)}px;"
|
|
569
|
+
else:
|
|
570
|
+
if fill: style += f" --fill: {fill};"
|
|
571
|
+
if sc: style += f" --stroke: {sc}; --sw: {_px(sw)}px;"
|
|
572
|
+
if dash and sc: attrs += f' data-dash="{dash}"'
|
|
573
|
+
if g.flip: attrs += ' data-flip="h"'
|
|
574
|
+
if ph in ("TITLE", "CENTERED_TITLE"): attrs += ' data-role="title"'
|
|
575
|
+
elif ph == "SUBTITLE": attrs += ' data-role="sub"'
|
|
576
|
+
elif ph == "BODY": attrs += ' data-role="body"'
|
|
577
|
+
va = s["valign"]
|
|
578
|
+
if va in VALIGN and not (name and va == "MIDDLE"): attrs += f' data-valign="{VALIGN[va]}"'
|
|
579
|
+
kind_af = s["autofit"]["type"]
|
|
580
|
+
if kind_af == "TEXT_AUTOFIT": attrs += ' data-autofit="shrink"'
|
|
581
|
+
elif kind_af == "SHAPE_AUTOFIT": attrs += ' data-autofit="resize"'
|
|
582
|
+
elif s["paragraphs"]: attrs += ' data-autofit="none"'
|
|
583
|
+
if s["label"]: attrs += f' aria-label="{html.escape(s["label"], quote=True)}"'
|
|
584
|
+
content = self.text_html(s["paragraphs"], s["autofit"]["scale"], 1 - s["autofit"]["lineReduction"], in_text_box=s["kind"] == "TEXT_BOX") if s["paragraphs"] else ""
|
|
585
|
+
cstyle = "padding: 19.2px 17.2px 19.2px 19.2px;" if not name else "" # Google's text box inset: 0.1 in on every side; Google keeps a word that overflows the right one by up to 1 px at 96 dpi
|
|
586
|
+
return (f' <div class="box" id="{self.nid()}"{attrs} style="{style}">'
|
|
587
|
+
f'<div class="content"{f" style={chr(34)}{cstyle}{chr(34)}" if cstyle else ""}>{content}</div></div>')
|
|
588
|
+
|
|
589
|
+
def nearest_side(self, g: Geometry, px: float, py: float) -> str:
|
|
590
|
+
sides = {"t": (g.x + g.w / 2, g.y), "b": (g.x + g.w / 2, g.y + g.h), "l": (g.x, g.y + g.h / 2), "r": (g.x + g.w, g.y + g.h / 2)}
|
|
591
|
+
return min(sides, key=lambda s: math.hypot(sides[s][0] - px, sides[s][1] - py))
|
|
592
|
+
|
|
593
|
+
def line(self, el: dict, g: Geometry) -> str:
|
|
594
|
+
ln = el["line"]; lp = ln.get("lineProperties", {})
|
|
595
|
+
lt = ln.get("lineType", "STRAIGHT_LINE")
|
|
596
|
+
shape = "elbow" if lt.startswith("BENT") else "curve" if lt.startswith("CURVED") else "line"
|
|
597
|
+
(x0, y0), (x1, y1) = g.point(0, 0), g.point(_emu(el.get("size", {}).get("width")), _emu(el.get("size", {}).get("height")))
|
|
598
|
+
left, top, w, h = min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0)
|
|
599
|
+
col = _color(lp.get("lineFill", {}).get("solidFill"), self.scheme) or "#000000"
|
|
600
|
+
sw = _emu(lp.get("weight")) * self.k if lp.get("weight") else EMU_PT * self.k
|
|
601
|
+
attrs = f' data-shape="{shape}"'
|
|
602
|
+
if (x1 - x0) * (y1 - y0) < 0 and w > 0.5 and h > 0.5: attrs += ' data-dir="up"'
|
|
603
|
+
if (x1 < x0) and (shape != "line" or h <= 0.5 or w <= 0.5): attrs += ' data-flip="h"' # drawn right to left: the editor's flip keeps the ends in order
|
|
604
|
+
d = DASH.get(lp.get("dashStyle", "SOLID"))
|
|
605
|
+
if d: attrs += f' data-dash="{d}"'
|
|
606
|
+
mirrored = x1 < x0 - 0.5 or (abs(x1 - x0) <= 0.5 and y1 < y0) # the API's start is at the right (or bottom): swap, our from is the left end
|
|
607
|
+
ends = (("endArrow", "data-start"), ("startArrow", "data-end")) if mirrored else (("startArrow", "data-start"), ("endArrow", "data-end"))
|
|
608
|
+
for key, attr in ends:
|
|
609
|
+
a = ARROW.get(lp.get(key, "NONE"))
|
|
610
|
+
if a: attrs += f' {attr}="{a}"'
|
|
611
|
+
conns = (("endConnection", "data-from", (x1, y1)), ("startConnection", "data-to", (x0, y0))) if mirrored else (("startConnection", "data-from", (x0, y0)), ("endConnection", "data-to", (x1, y1)))
|
|
612
|
+
for key, attr, (px_, py_) in conns:
|
|
613
|
+
c = lp.get(key, {}).get("connectedObjectId")
|
|
614
|
+
tgt = self.ids.get(c)
|
|
615
|
+
if tgt: attrs += f' {attr}="{tgt[0]}:{self.nearest_side(tgt[1], px_, py_)}"'
|
|
616
|
+
return (f' <div class="box" id="{self.nid()}"{attrs} style="left: {_px(left)}px; top: {_px(top)}px; width: {_px(w)}px; height: {_px(h)}px; '
|
|
617
|
+
f'--stroke: {col}; --sw: {_px(sw)}px;"><div class="content"></div></div>')
|
|
618
|
+
|
|
619
|
+
def save(self, url: str | None, mime_hint: str = "", key: str = "") -> str | None:
|
|
620
|
+
if not url: return None
|
|
621
|
+
if key and self.images.get(key) and (self.deck / self.images[key]).exists(): return self.images[key]
|
|
622
|
+
try:
|
|
623
|
+
if url.startswith("data:"):
|
|
624
|
+
head, b64 = url.split(",", 1); data = base64.b64decode(b64); mime = head[5:].split(";")[0]
|
|
625
|
+
else:
|
|
626
|
+
data = self.get(url)
|
|
627
|
+
mime = {b"\x89PNG": "image/png", b"\xff\xd8": "image/jpeg", b"GIF8": "image/gif", b"RIFF": "image/webp", b"<svg": "image/svg+xml", b"<?xm": "image/svg+xml"}.get(
|
|
628
|
+
next((s for s in (b"\x89PNG", b"\xff\xd8", b"GIF8", b"RIFF", b"<svg", b"<?xm") if data.startswith(s)), b""), mime_hint or "image/png")
|
|
629
|
+
except Exception as e:
|
|
630
|
+
self.log.append(f"slide {self.slide}: a picture couldn't be downloaded ({e})")
|
|
631
|
+
return None
|
|
632
|
+
ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp", "image/svg+xml": ".svg"}.get(mime, ".png")
|
|
633
|
+
name = f"s{self.slide:02d}-{hashlib.sha1(data).hexdigest()[:10]}{ext}"
|
|
634
|
+
(self.deck / "assets").mkdir(parents=True, exist_ok=True)
|
|
635
|
+
(self.deck / "assets" / name).write_bytes(data)
|
|
636
|
+
if key: self.images[key] = f"assets/{name}"
|
|
637
|
+
return f"assets/{name}"
|
|
638
|
+
|
|
639
|
+
def image(self, el: dict, g: Geometry, url: str | None, props: dict) -> str:
|
|
640
|
+
src = self.save(url, key=el.get("objectId", ""))
|
|
641
|
+
attrs = ""
|
|
642
|
+
cp = props.get("cropProperties", {})
|
|
643
|
+
crop = [float(cp.get(k, 0)) for k in ("topOffset", "rightOffset", "bottomOffset", "leftOffset")]
|
|
644
|
+
if any(c < -0.001 for c in crop): # a negative offset shows empty space inside the box: move the box edge to the picture's
|
|
645
|
+
tt, rr, bb, ll = crop
|
|
646
|
+
fx, fy = 1 - ll - rr, 1 - tt - bb
|
|
647
|
+
t2, r2, b2, l2 = (max(c, 0.0) for c in crop)
|
|
648
|
+
if fx > 0 and fy > 0 and abs(g.rot) < 0.05:
|
|
649
|
+
g.x += (l2 - ll) / fx * g.w; g.y += (t2 - tt) / fy * g.h
|
|
650
|
+
g.w *= (1 - l2 - r2) / fx; g.h *= (1 - t2 - b2) / fy
|
|
651
|
+
crop = [t2, r2, b2, l2]
|
|
652
|
+
if any(c > 0.001 for c in crop): attrs += f' data-crop="{" ".join(f"{c:.4f}" for c in crop)}"'
|
|
653
|
+
if g.flip: attrs += ' data-flip="h"'
|
|
654
|
+
if props.get("shadow") and props["shadow"].get("propertyState", "RENDERED") == "RENDERED": attrs += " data-shadow"
|
|
655
|
+
alt = html.escape(el.get("description") or el.get("title") or "", quote=True)
|
|
656
|
+
# Slides stretches a picture to its box (the box carries the aspect ratio); the viewer's default is to letterbox
|
|
657
|
+
op = " style=\"object-fit: fill" + (f'; opacity: {1 - float(props["transparency"]):.2f}' if props.get("transparency") else "") + '"'
|
|
658
|
+
o = props.get("outline", {})
|
|
659
|
+
style = g.style()
|
|
660
|
+
if o.get("propertyState", "RENDERED") == "RENDERED" and o.get("outlineFill"): # an outline that is present is drawn unless it says otherwise
|
|
661
|
+
style += f" --stroke: {_color(o['outlineFill'].get('solidFill'), self.scheme) or '#000'}; --sw: {_px(_emu(o.get('weight')) * self.k or EMU_PT * self.k)}px;"
|
|
662
|
+
img = f'<img src="{src}" alt="{alt}"{op}>' if src else ""
|
|
663
|
+
return (f' <div class="box image" id="{self.nid()}"{attrs} style="{style}"><div class="content">{img}</div></div>'
|
|
664
|
+
+ ("" if src else " <!-- picture not downloadable -->"))
|
|
665
|
+
|
|
666
|
+
def video(self, el: dict, g: Geometry) -> str:
|
|
667
|
+
v = el["video"]
|
|
668
|
+
if v.get("source") == "YOUTUBE" and v.get("id"):
|
|
669
|
+
url = f"https://www.youtube.com/embed/{v['id']}"
|
|
670
|
+
return f' <div class="box embed" id="{self.nid()}" style="{g.style()}"><div class="content"><iframe src="{url}" allowfullscreen></iframe></div></div>'
|
|
671
|
+
self.log.append(f"slide {self.slide}: a Drive video can't be downloaded through the API; left as a link box")
|
|
672
|
+
return (f' <div class="box" id="{self.nid()}" data-shape="rect" style="{g.style()} --fill: #202124; --stroke: transparent; --sw: 0px;">'
|
|
673
|
+
f'<div class="content" style="color: #fff"><a href="{html.escape(v.get("url", ""), quote=True)}">Video</a></div></div>')
|
|
674
|
+
|
|
675
|
+
def table(self, el: dict, g: Geometry) -> str:
|
|
676
|
+
t = el["table"]
|
|
677
|
+
# the element's size is a placeholder (3000000 EMU): the real box is the sum of the column widths and row heights
|
|
678
|
+
cols = [_emu(c.get("columnWidth")) * self.k for c in t.get("tableColumns", [])]
|
|
679
|
+
hts = [_emu(r.get("rowHeight")) * self.k for r in t.get("tableRows", [])]
|
|
680
|
+
if cols and sum(cols) > 1: g.w = sum(cols)
|
|
681
|
+
if hts and sum(hts) > 1: g.h = sum(hts)
|
|
682
|
+
colgroup = "".join(f'<col style="width: {100 * c / sum(cols):.2f}%">' for c in cols) if cols and sum(cols) > 1 else ""
|
|
683
|
+
# borders: Slides gives one per cell edge; the first horizontal one stands for the table (1pt black is our default)
|
|
684
|
+
border = ""
|
|
685
|
+
for br in t.get("horizontalBorderRows", []) + t.get("verticalBorderRows", []):
|
|
686
|
+
for bc in br.get("tableBorderCells", []):
|
|
687
|
+
bp = bc.get("tableBorderProperties", {})
|
|
688
|
+
col = _color(bp.get("tableBorderFill", {}).get("solidFill"), self.scheme)
|
|
689
|
+
if col or bp.get("weight"):
|
|
690
|
+
w = _emu(bp.get("weight")) * self.k if bp.get("weight") else EMU_PT * self.k
|
|
691
|
+
border = f"border: {_px(w)}px {'dashed' if bp.get('dashStyle', 'SOLID') != 'SOLID' else 'solid'} {col or 'transparent'}; "
|
|
692
|
+
break
|
|
693
|
+
if border: break
|
|
694
|
+
rows = []
|
|
695
|
+
for ri, r in enumerate(t.get("tableRows", [])):
|
|
696
|
+
cells = []
|
|
697
|
+
for c in r.get("tableCells", []):
|
|
698
|
+
span = "".join(f' {k}="{c[j]}"' for j, k in (("rowSpan", "rowspan"), ("columnSpan", "colspan")) if c.get(j, 1) > 1)
|
|
699
|
+
cf = c.get("tableCellProperties", {}).get("tableCellBackgroundFill", {})
|
|
700
|
+
bg = _color(cf.get("solidFill"), self.scheme) if cf.get("propertyState", "RENDERED") == "RENDERED" else None
|
|
701
|
+
st = f' style="padding: 19.2px; {border}{f"background: {bg}" if bg else ""}"' # 0.1 in cell inset: closest to Google's renders overall (see Bugs)
|
|
702
|
+
cells.append(f"<td{span}{st}>{self.text_html(self.resolve_text(el, c.get('text', {}))) or '<br>'}</td>")
|
|
703
|
+
rh = f' style="height: {_px(hts[ri])}px"' if ri < len(hts) and hts[ri] > 1 else ""
|
|
704
|
+
rows.append(f"<tr{rh}>" + "".join(cells) + "</tr>")
|
|
705
|
+
return (f' <div class="box table" id="{self.nid()}" data-valign="top" style="{g.style()}"><div class="content" style="padding: 0px;">'
|
|
706
|
+
f'<table>{colgroup}{"".join(rows)}</table></div></div>')
|
|
707
|
+
|
|
708
|
+
def convert(self, only=None) -> list:
|
|
709
|
+
"""Every slide (or the 1-based numbers in `only`) as a <section> string."""
|
|
710
|
+
sections = []
|
|
711
|
+
for n, page in enumerate(self.pres.get("slides", []), 1):
|
|
712
|
+
if only and n not in only: continue
|
|
713
|
+
self.slide, self.n, self.ids = n, 0, {}
|
|
714
|
+
out = []
|
|
715
|
+
for e in page.get("pageElements", []): self.convert_element(e, (1, 0, 0, 1, 0, 0), out)
|
|
716
|
+
out = [(g, self.line(h[1], h[2]) if isinstance(h, tuple) else h) for g, h in out] # the deferred lines, shapes now have ids
|
|
717
|
+
pg = self.resolve_page(page)
|
|
718
|
+
attrs = f' data-background-color="{pg["background"]["color"]}"'
|
|
719
|
+
if pg["background"]["image"]:
|
|
720
|
+
src = self.save(pg["background"]["image"], key="bg:" + page.get("objectId", str(n)))
|
|
721
|
+
if src: attrs += f' data-background-image="{src}"'
|
|
722
|
+
if pg["hidden"]: attrs += ' data-visibility="hidden"'
|
|
723
|
+
lines = [h for _, h in out]
|
|
724
|
+
if pg["notes"]: lines.append(f' <aside class="notes">{html.escape(pg["notes"])}</aside>')
|
|
725
|
+
sections.append(f"<section{attrs}>\n" + "\n".join(lines) + "\n</section>")
|
|
726
|
+
return sections
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def ir(self) -> dict:
|
|
730
|
+
"""The whole IR as plain data: pages and shapes resolved; images, lines, tables, videos and
|
|
731
|
+
charts so far as their geometry and the API's own object ("raw")."""
|
|
732
|
+
log, self.log = self.log, [] # resolving again would log twice
|
|
733
|
+
slides = []
|
|
734
|
+
for n, page in enumerate(self.pres.get("slides", []), 1):
|
|
735
|
+
self.slide = n
|
|
736
|
+
slides.append({"id": page.get("objectId"), **self.resolve_page(page),
|
|
737
|
+
"elements": [e for el in page.get("pageElements", []) for e in [self.element_ir(el, (1, 0, 0, 1, 0, 0))] if e]})
|
|
738
|
+
self.log = log
|
|
739
|
+
return {"id": self.pres.get("presentationId", ""), "title": self.pres.get("title", ""), "size": {"w": 1920, "h": self.height}, "slides": slides}
|
|
740
|
+
|
|
741
|
+
def element_ir(self, el: dict, parent: tuple) -> dict | None:
|
|
742
|
+
m = _mul(parent, _mat(el.get("transform")))
|
|
743
|
+
g = Geometry(m, el.get("size"), self.k)
|
|
744
|
+
box = {"id": el.get("objectId"), "x": round(g.x, 2), "y": round(g.y, 2), "w": round(g.w, 2), "h": round(g.h, 2), "rotation": round(g.rot, 2),
|
|
745
|
+
"flipH": g.flip, "label": el.get("description") or el.get("title") or ""}
|
|
746
|
+
if "elementGroup" in el:
|
|
747
|
+
return {"type": "group", **box, "children": [e for ch in el["elementGroup"].get("children", []) for e in [self.element_ir(ch, m)] if e]}
|
|
748
|
+
if "shape" in el:
|
|
749
|
+
s = self.resolve_shape(el, g)
|
|
750
|
+
if not s: return None
|
|
751
|
+
return {"type": "shape", **box, **{k: v for k, v in s.items() if k not in ("paragraphs", "label")}, "paragraphs": [self.para_ir(q) for q in s["paragraphs"]]}
|
|
752
|
+
if "line" in el:
|
|
753
|
+
lp = el["line"].get("lineProperties", {})
|
|
754
|
+
(x0, y0), (x1, y1) = g.point(0, 0), g.point(_emu(el.get("size", {}).get("width")), _emu(el.get("size", {}).get("height")))
|
|
755
|
+
return {"type": "line", **box, "kind": el["line"].get("lineType", "STRAIGHT_LINE"), "from": [round(x0, 2), round(y0, 2)], "to": [round(x1, 2), round(y1, 2)],
|
|
756
|
+
"color": _color(lp.get("lineFill", {}).get("solidFill"), self.scheme) or "#000000", "weight": _emu(lp.get("weight")) * self.k if lp.get("weight") else EMU_PT * self.k,
|
|
757
|
+
"dash": lp.get("dashStyle", "SOLID"), "startArrow": lp.get("startArrow", "NONE"), "endArrow": lp.get("endArrow", "NONE"),
|
|
758
|
+
"connections": {k: (lp.get(k + "Connection") or {}).get("connectedObjectId") for k in ("start", "end")}}
|
|
759
|
+
if "image" in el or "sheetsChart" in el:
|
|
760
|
+
src = el.get("image") or el["sheetsChart"]
|
|
761
|
+
pr = src.get("imageProperties") or src.get("sheetsChartProperties", {}).get("chartImageProperties", {}) or {}
|
|
762
|
+
cp = pr.get("cropProperties", {}); o = pr.get("outline", {})
|
|
763
|
+
return {"type": "image" if "image" in el else "chart", **box, "url": src.get("contentUrl"),
|
|
764
|
+
"crop": {k: float(cp.get(k + "Offset", 0)) for k in ("left", "top", "right", "bottom")}, "transparency": float(pr.get("transparency", 0) or 0),
|
|
765
|
+
"outline": {"color": _color(o.get("outlineFill", {}).get("solidFill"), self.scheme), "weight": _emu(o.get("weight")) * self.k}
|
|
766
|
+
if o.get("propertyState", "RENDERED") == "RENDERED" and o.get("outlineFill") else None,
|
|
767
|
+
"shadow": bool(pr.get("shadow") and pr["shadow"].get("propertyState", "RENDERED") == "RENDERED")}
|
|
768
|
+
if "video" in el:
|
|
769
|
+
v = el["video"]; vp = v.get("videoProperties", {})
|
|
770
|
+
return {"type": "video", **box, "source": v.get("source"), "videoId": v.get("id"), "url": v.get("url"), "start": vp.get("start"), "end": vp.get("end")}
|
|
771
|
+
if "table" in el:
|
|
772
|
+
t = el["table"]
|
|
773
|
+
cols, rows = [_emu(c.get("columnWidth")) * self.k for c in t.get("tableColumns", [])], [_emu(r.get("rowHeight")) * self.k for r in t.get("tableRows", [])]
|
|
774
|
+
if sum(cols) > 1: box["w"] = round(sum(cols), 2) # the element's own size is a placeholder: the table is its columns and rows
|
|
775
|
+
if sum(rows) > 1: box["h"] = round(sum(rows), 2)
|
|
776
|
+
return {"type": "table", **box, "columns": cols, "rows": rows,
|
|
777
|
+
"cells": [{"row": ri, "col": ci, "rowSpan": c.get("rowSpan", 1), "colSpan": c.get("columnSpan", 1),
|
|
778
|
+
"fill": _color(c.get("tableCellProperties", {}).get("tableCellBackgroundFill", {}).get("solidFill"), self.scheme),
|
|
779
|
+
"paragraphs": [self.para_ir(q) for q in self.resolve_text(el, c.get("text", {}))]}
|
|
780
|
+
for ri, r in enumerate(t.get("tableRows", [])) for ci, c in enumerate(r.get("tableCells", []))]}
|
|
781
|
+
if "wordArt" in el: return {"type": "wordArt", **box, "text": el["wordArt"].get("renderedText", "")}
|
|
782
|
+
return {"type": "unknown", **box, "keys": [k for k in el if k not in ("objectId", "size", "transform", "title", "description")]}
|
|
783
|
+
|
|
784
|
+
def run_ir(self, st: dict) -> dict:
|
|
785
|
+
"""A resolved run style in the IR's units: family, weight, size pt, colours #rrggbb (theme colours resolved)."""
|
|
786
|
+
wf = st.get("weightedFontFamily") or {}
|
|
787
|
+
return {"font": wf.get("fontFamily") or st.get("fontFamily"), "weight": wf.get("weight", 700 if st.get("bold") else 400),
|
|
788
|
+
"size": float((st.get("fontSize") or {}).get("magnitude", 14)), "bold": bool(st.get("bold")), "italic": bool(st.get("italic")),
|
|
789
|
+
"underline": bool(st.get("underline")), "strikethrough": bool(st.get("strikethrough")), "smallCaps": bool(st.get("smallCaps")),
|
|
790
|
+
"color": _rgb(st.get("foregroundColor"), self.scheme), "highlight": _rgb(st.get("backgroundColor"), self.scheme),
|
|
791
|
+
"offset": (st.get("baselineOffset") or "NONE").lower(), "link": (st.get("link") or {}).get("url") or (st.get("link") or {}).get("slideIndex")}
|
|
792
|
+
|
|
793
|
+
def para_ir(self, q: dict) -> dict:
|
|
794
|
+
"""A resolved paragraph (resolve_text) in the IR's units: pt, percent, Google's list glyph."""
|
|
795
|
+
ps = q["style"]; pt = lambda k: float((ps.get(k) or {}).get("magnitude", 0) or 0)
|
|
796
|
+
b = q["bullet"]
|
|
797
|
+
return {"align": ps.get("alignment", "START"), "spacing": float(ps.get("lineSpacing", 100) or 100), "spaceAbove": pt("spaceAbove"), "spaceBelow": pt("spaceBelow"),
|
|
798
|
+
"indentStart": pt("indentStart"), "indentFirstLine": pt("indentFirstLine"), "spacingMode": ps.get("spacingMode", "COLLAPSE_LISTS"),
|
|
799
|
+
"list": {"level": q["level"], "glyph": (b.get("glyph") or "").strip(),
|
|
800
|
+
"color": _rgb((b.get("bulletStyle") or {}).get("foregroundColor"), self.scheme)} if b else None,
|
|
801
|
+
"runs": [{"text": r["text"].rstrip("\n"), **self.run_ir(r["style"])} if "text" in r else {"auto": r["auto"]} for r in q["runs"]]}
|
|
802
|
+
|
|
803
|
+
# ---- pages
|
|
804
|
+
def resolve_page(self, page: dict) -> dict:
|
|
805
|
+
"""The IR's page: background (colour, image url) through slide → layout → master, hidden, speaker notes."""
|
|
806
|
+
bg, img = self.background(page)
|
|
807
|
+
return {"background": {"color": bg, "image": img}, "hidden": bool(page.get("slideProperties", {}).get("isSkipped")), "notes": self.notes(page)}
|
|
808
|
+
|
|
809
|
+
def background(self, page: dict) -> tuple:
|
|
810
|
+
"""(colour, image url) with inheritance slide → layout → master."""
|
|
811
|
+
chain = [page]
|
|
812
|
+
sp = page.get("slideProperties", {})
|
|
813
|
+
for key in ("layoutObjectId", "masterObjectId"):
|
|
814
|
+
if sp.get(key) in self.pages: chain.append(self.pages[sp[key]])
|
|
815
|
+
lp = page.get("layoutProperties", {})
|
|
816
|
+
if lp.get("masterObjectId") in self.pages: chain.append(self.pages[lp["masterObjectId"]])
|
|
817
|
+
for p in chain:
|
|
818
|
+
f = p.get("pageProperties", {}).get("pageBackgroundFill")
|
|
819
|
+
if not f or f.get("propertyState") == "INHERIT": continue
|
|
820
|
+
if f.get("propertyState") == "NOT_RENDERED": return ("#ffffff", None)
|
|
821
|
+
if "solidFill" in f: return (_color(f["solidFill"], self.scheme) or "#ffffff", None)
|
|
822
|
+
if "stretchedPictureFill" in f: return ("#ffffff", f["stretchedPictureFill"].get("contentUrl"))
|
|
823
|
+
return ("#ffffff", None)
|
|
824
|
+
|
|
825
|
+
def notes(self, page: dict) -> str:
|
|
826
|
+
np_ = page.get("slideProperties", {}).get("notesPage", {})
|
|
827
|
+
for e in np_.get("pageElements", []):
|
|
828
|
+
if e.get("shape", {}).get("placeholder", {}).get("type") == "BODY":
|
|
829
|
+
return self.plain_text(e["shape"].get("text", {}))
|
|
830
|
+
return ""
|
|
831
|
+
|
|
832
|
+
def convert_element(self, el: dict, parent: tuple, out: list):
|
|
833
|
+
"""One element (groups recurse) → (geometry, html) into out; lines are deferred until every shape has an id."""
|
|
834
|
+
m = _mul(parent, _mat(el.get("transform")))
|
|
835
|
+
g = Geometry(m, el.get("size"), self.k)
|
|
836
|
+
if "elementGroup" in el:
|
|
837
|
+
inner = []
|
|
838
|
+
for ch in el["elementGroup"].get("children", []): self.convert_element(ch, m, inner)
|
|
839
|
+
inner = [(g_, self.line(h[1], h[2]) if isinstance(h, tuple) else h) for g_, h in inner]
|
|
840
|
+
boxes = [b for b in inner if b[0] is not None]
|
|
841
|
+
if not boxes: return
|
|
842
|
+
x0 = min(b.x for b, _ in boxes); y0 = min(b.y for b, _ in boxes)
|
|
843
|
+
x1 = max(b.x + b.w for b, _ in boxes); y1 = max(b.y + b.h for b, _ in boxes)
|
|
844
|
+
rel = [" " + h.replace(f"left: {_px(b.x)}px; top: {_px(b.y)}px;", f"left: {_px(b.x - x0)}px; top: {_px(b.y - y0)}px;", 1) for b, h in boxes]
|
|
845
|
+
out.append((None, f' <div class="box group" id="{self.nid()}" style="left: {_px(x0)}px; top: {_px(y0)}px; width: {_px(x1 - x0)}px; height: {_px(y1 - y0)}px;">\n'
|
|
846
|
+
+ "\n".join(rel) + "\n </div>"))
|
|
847
|
+
return
|
|
848
|
+
if "line" in el: # lines are deferred so every shape they can attach to has an id
|
|
849
|
+
out.append((g, ("__line__", el, g))); return
|
|
850
|
+
if "shape" in el: h = self.shape(el, g)
|
|
851
|
+
elif "image" in el: h = self.image(el, g, el["image"].get("contentUrl"), el["image"].get("imageProperties", {}))
|
|
852
|
+
elif "sheetsChart" in el: h = self.image(el, g, el["sheetsChart"].get("contentUrl"), el["sheetsChart"].get("sheetsChartProperties", {}).get("chartImageProperties", {}))
|
|
853
|
+
elif "video" in el: h = self.video(el, g)
|
|
854
|
+
elif "table" in el: h = self.table(el, g)
|
|
855
|
+
elif "wordArt" in el: h = (f' <div class="box" id="{self.nid()}" data-valign="top" style="{g.style()}"><div class="content" style="font-size: {_px(g.h * 0.6)}px; font-weight: bold">'
|
|
856
|
+
f'{html.escape(el["wordArt"].get("renderedText", ""))}</div></div>')
|
|
857
|
+
else:
|
|
858
|
+
self.log.append(f"slide {self.slide}: skipped an element the importer doesn't know ({', '.join(k for k in el if k not in ('objectId', 'size', 'transform', 'title', 'description'))})")
|
|
859
|
+
return
|
|
860
|
+
if h:
|
|
861
|
+
m_ = re.search(r'id="([^"]+)"', h)
|
|
862
|
+
if m_: self.ids[el["objectId"]] = (m_.group(1), g)
|
|
863
|
+
out.append((g, h))
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
# ---------------------------------------------------------------- the deck file
|
|
867
|
+
|
|
868
|
+
def write_deck(deck: Path, sections: list, title: str, height: int, fonts: set, keep=False, glyphs: str = "") -> Path:
|
|
869
|
+
path = deck / "deck.html"
|
|
870
|
+
deck.mkdir(parents=True, exist_ok=True)
|
|
871
|
+
if not path.exists(): new_deck(path, title)
|
|
872
|
+
text = path.read_text()
|
|
873
|
+
joined = "\n".join(sections)
|
|
874
|
+
if keep: text = text.replace("\n</div></div>\n<script", "\n" + joined + "\n</div></div>\n<script", 1)
|
|
875
|
+
else: text = re.sub(r'(<div class="slides"[^>]*>\n).*?(\n</div></div>)', lambda m: m.group(1) + joined + m.group(2), text, count=1, flags=re.S)
|
|
876
|
+
if height != 1080: text = re.sub(r'<div class="slides"[^>]*>', f'<div class="slides" data-size="1920x{height}">', text, count=1)
|
|
877
|
+
google = sorted(f for f in fonts if f in Converter._google_fonts())
|
|
878
|
+
if google:
|
|
879
|
+
link = ('<link id="deck-fonts" rel="stylesheet" href="https://fonts.googleapis.com/css2?' +
|
|
880
|
+
"&".join("family=" + f.replace(" ", "+") + ":ital,wght@0,400;0,700;1,400;1,700" for f in google) + '&display=swap">')
|
|
881
|
+
if 'id="deck-fonts"' in text: text = re.sub(r'<link id="deck-fonts"[^>]*>', link, text, count=1)
|
|
882
|
+
else: text = text.replace("</head>", link + "\n</head>", 1)
|
|
883
|
+
# the characters Google Fonts' subsets (latin, latin-ext …) may leave out, from the full fonts as Slides has them (→ in Montserrat)
|
|
884
|
+
text = re.sub(r'<link id="deck-fonts-text"[^>]*>\n?', "", text, count=1)
|
|
885
|
+
if glyphs:
|
|
886
|
+
extra = ('<link id="deck-fonts-text" rel="stylesheet" href="https://fonts.googleapis.com/css2?' + "&".join("family=" + f.replace(" ", "+") + ":ital,wght@0,400;0,700;1,400;1,700" for f in google) +
|
|
887
|
+
"&text=" + urllib.parse.quote("".join(sorted(set(glyphs)))) + '&display=swap">')
|
|
888
|
+
text = text.replace("</head>", extra + "\n</head>", 1)
|
|
889
|
+
if title: text = re.sub(r"<title>.*?</title>", f"<title>{html.escape(title)}</title>", text, count=1, flags=re.S)
|
|
890
|
+
path.write_text(text)
|
|
891
|
+
return path
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def pptx_adjusts(pptx: Path, k: float) -> dict:
|
|
895
|
+
"""{slide number: [(x, y, w, h in canvas px, [adjust values 0–1])]} for every top-level preset shape with an avLst
|
|
896
|
+
in a .pptx (standard library only: the slides' XML, in the presentation's order)."""
|
|
897
|
+
import zipfile
|
|
898
|
+
out = {}
|
|
899
|
+
with zipfile.ZipFile(pptx) as z:
|
|
900
|
+
rels = dict(re.findall(r'Id="([^"]+)"[^>]*Target="([^"]+)"', z.read("ppt/_rels/presentation.xml.rels").decode()))
|
|
901
|
+
order = re.findall(r'<p:sldId [^>]*r:id="([^"]+)"', z.read("ppt/presentation.xml").decode())
|
|
902
|
+
for n, rid in enumerate(order, 1):
|
|
903
|
+
x = z.read("ppt/" + rels[rid].lstrip("/").replace("ppt/", "")).decode()
|
|
904
|
+
tree = x[x.find("<p:spTree>"):]
|
|
905
|
+
for sp in re.findall(r"<p:sp>.*?</p:sp>", tree, re.S):
|
|
906
|
+
m = re.search(r'<a:off x="(-?\d+)" y="(-?\d+)"/><a:ext cx="(\d+)" cy="(\d+)"/>', sp)
|
|
907
|
+
av = re.search(r'<a:prstGeom prst="\w+"><a:avLst>(.*?)</a:avLst>', sp, re.S)
|
|
908
|
+
if not m or not av: continue
|
|
909
|
+
gd = dict((nm, int(v)) for v, nm in re.findall(r'<a:gd fmla="val (-?\d+)" name="(\w+)"/>', av.group(1)))
|
|
910
|
+
vals = [gd[a] / 100000 for a in sorted((a for a in gd if re.fullmatch(r"adj\d*", a)), key=lambda a: int(a[3:] or 1))]
|
|
911
|
+
if vals: out.setdefault(n, []).append((*(int(v) * k for v in m.groups()), vals))
|
|
912
|
+
return out
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def import_deck(pres: dict, deck: Path, pptx: Path | None = None, get=None) -> dict:
|
|
916
|
+
"""The whole thing for a fetched presentation: sections into deck/deck.html (+ assets/), notes/builds from a .pptx. Returns a summary."""
|
|
917
|
+
conv = Converter(pres, deck, get=get)
|
|
918
|
+
if pptx: conv.pptx_adj = pptx_adjusts(pptx, conv.k)
|
|
919
|
+
sections = conv.convert()
|
|
920
|
+
if pptx:
|
|
921
|
+
sections = apply_pptx(sections, pptx, pres)
|
|
922
|
+
write_deck(deck, sections, pres.get("title", ""), conv.height, conv.fonts, glyphs="".join(conv.glyphs))
|
|
923
|
+
if conv.images:
|
|
924
|
+
conv.images_file.parent.mkdir(parents=True, exist_ok=True); conv.images_file.write_text(json.dumps(conv.images, indent=1))
|
|
925
|
+
log = deck / "source" / "import-log.txt"
|
|
926
|
+
if conv.log:
|
|
927
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
928
|
+
log.write_text("glideshow import-google: things to check by hand\n\n" + "".join(l + "\n" for l in conv.log))
|
|
929
|
+
elif log.exists(): log.unlink()
|
|
930
|
+
return {"slides": len(sections), "fonts": sorted(conv.fonts), "log": conv.log, "height": conv.height}
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def apply_pptx(sections: list, pptx: Path, pres: dict) -> list:
|
|
934
|
+
"""Speaker notes (when the API gave none) and click builds as fragments, from the deck's own .pptx download."""
|
|
935
|
+
sys.path.insert(0, str(EDITOR.parent / "tools"))
|
|
936
|
+
try:
|
|
937
|
+
from gslides2deck import pptx_extras, apply_builds
|
|
938
|
+
except ImportError:
|
|
939
|
+
print("--pptx needs tools/gslides2deck.py (a checkout); skipped"); return sections
|
|
940
|
+
W = _emu(pres["pageSize"]["width"])
|
|
941
|
+
try: ex = pptx_extras(pptx, W) # geometry in EMU, same units as the API
|
|
942
|
+
except ImportError: # python-pptx isn't installed: notes and builds are skipped (adjust values don't need it)
|
|
943
|
+
print("--pptx: speaker notes and click builds need python-pptx (uv run --with python-pptx …); skipped"); return sections
|
|
944
|
+
k = 1920 / W
|
|
945
|
+
out = []
|
|
946
|
+
for n, sec in enumerate(sections, 1):
|
|
947
|
+
e = ex.get(n, {})
|
|
948
|
+
lines = sec.split("\n")
|
|
949
|
+
if e.get("builds"): lines = apply_builds(lines, e["builds"], k)
|
|
950
|
+
if e.get("notes") and '<aside class="notes">' not in sec:
|
|
951
|
+
lines.insert(-1, f' <aside class="notes">{html.escape(e["notes"])}</aside>')
|
|
952
|
+
out.append("\n".join(lines))
|
|
953
|
+
return out
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# ---------------------------------------------------------------- report (our render vs Google's thumbnails)
|
|
957
|
+
|
|
958
|
+
def report(deck: Path, refs: list) -> list:
|
|
959
|
+
"""Mean |difference| per slide (0–255, at 480 × 270, lightly blurred) between our render (test/snap.py) and Google's thumbnail. Needs playwright."""
|
|
960
|
+
try: from playwright.sync_api import sync_playwright
|
|
961
|
+
except ImportError:
|
|
962
|
+
print("scores need playwright (`uv run --with playwright`); skipped"); return []
|
|
963
|
+
import subprocess, tempfile
|
|
964
|
+
snap = EDITOR.parent / "test" / "snap.py"
|
|
965
|
+
out = Path(tempfile.mkdtemp(prefix="slides-snap-"))
|
|
966
|
+
subprocess.run([sys.executable, str(snap), str(deck), str(out)], check=False, capture_output=True)
|
|
967
|
+
scores = []
|
|
968
|
+
with sync_playwright() as p:
|
|
969
|
+
b = p.chromium.launch(channel="chrome"); pg = b.new_page()
|
|
970
|
+
for n, ref in enumerate(refs, 1):
|
|
971
|
+
ours = out / f"slide-{n:02d}.png"
|
|
972
|
+
if not ours.exists(): scores.append(None); continue
|
|
973
|
+
a = "data:image/png;base64," + base64.b64encode(ref.read_bytes()).decode()
|
|
974
|
+
c = "data:image/png;base64," + base64.b64encode(ours.read_bytes()).decode()
|
|
975
|
+
scores.append(pg.evaluate("""async ([a, b]) => { const load = s => new Promise(r => { const i = new Image(); i.onload = () => r(i); i.src = s; });
|
|
976
|
+
const [ia, ib] = await Promise.all([load(a), load(b)]); const W = 480, H = 270;
|
|
977
|
+
// a slight blur first, so a text line a few pixels off reads as a small difference, not a missing line
|
|
978
|
+
const px = i => { const c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d'); x.filter = 'blur(1.5px)'; x.drawImage(i, 0, 0, W, H); return x.getImageData(0, 0, W, H).data; };
|
|
979
|
+
const pa = px(ia), pb = px(ib); let s = 0; for (let i = 0; i < pa.length; i += 4) s += Math.abs(pa[i] - pb[i]) + Math.abs(pa[i+1] - pb[i+1]) + Math.abs(pa[i+2] - pb[i+2]);
|
|
980
|
+
return s / (pa.length / 4 * 3); }""", [a, c]))
|
|
981
|
+
b.close()
|
|
982
|
+
return scores
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# ---------------------------------------------------------------- the command
|
|
986
|
+
|
|
987
|
+
def bookmarklet() -> str:
|
|
988
|
+
"""The embed-viewer capture (tools/gslides2deck.py snippet) as a bookmarklet, for accounts that can't add an OAuth client."""
|
|
989
|
+
tool = EDITOR.parent / "tools" / "gslides2deck.py"
|
|
990
|
+
import subprocess
|
|
991
|
+
r = subprocess.run([sys.executable, str(tool), "snippet"], capture_output=True, text=True)
|
|
992
|
+
if r.returncode: raise SystemExit("tools/gslides2deck.py snippet failed:\n" + r.stderr[-400:])
|
|
993
|
+
return "javascript:" + urllib.parse.quote("void(async()=>{" + r.stdout.strip() + "})()", safe="") # the snippet awaits: it needs an async wrapper
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def main(argv: list):
|
|
997
|
+
args = [a for a in argv]
|
|
998
|
+
flags = {a for a in args if a.startswith("--") and "=" not in a}
|
|
999
|
+
pptx = None
|
|
1000
|
+
if "--pptx" in args:
|
|
1001
|
+
i = args.index("--pptx"); pptx = Path(args[i + 1]).expanduser(); del args[i:i + 2]
|
|
1002
|
+
only = None
|
|
1003
|
+
if "--slides" in args:
|
|
1004
|
+
i = args.index("--slides"); only = set();
|
|
1005
|
+
for part in args[i + 1].split(","):
|
|
1006
|
+
a, _, b = part.partition("-"); only.update(range(int(a), int(b or a) + 1))
|
|
1007
|
+
del args[i:i + 2]
|
|
1008
|
+
pos = [a for a in args if not a.startswith("--")]
|
|
1009
|
+
if "--login" in flags:
|
|
1010
|
+
login(); return
|
|
1011
|
+
if "--bookmarklet" in flags:
|
|
1012
|
+
print("Drag this to the bookmarks bar, open the deck in Google Slides, click it; then: glideshow import-google gslides-<id>.json\n")
|
|
1013
|
+
print(bookmarklet()); return
|
|
1014
|
+
if not pos: raise SystemExit(__doc__)
|
|
1015
|
+
src, dest = pos[0], pos[1] if len(pos) > 1 else None
|
|
1016
|
+
if src.endswith(".json") and Path(src).expanduser().exists():
|
|
1017
|
+
data = json.loads(Path(src).expanduser().read_text())
|
|
1018
|
+
if "slides" in data and "pageSize" not in data: # a capture from the bookmarklet / snippet: the SVG converter handles it
|
|
1019
|
+
import subprocess
|
|
1020
|
+
deck = Path(dest).expanduser() if dest else HOME / _slug(data.get("title") or Path(src).stem)
|
|
1021
|
+
cmd = [sys.executable, str(EDITOR.parent / "tools" / "gslides2deck.py"), "convert", str(Path(src).expanduser()), str(deck)] + (["--pptx", str(pptx)] if pptx else [])
|
|
1022
|
+
subprocess.run(cmd, check=True); return _open(deck, flags)
|
|
1023
|
+
pres, pid = data, data.get("presentationId", "")
|
|
1024
|
+
else:
|
|
1025
|
+
pid = presentation_id(src)
|
|
1026
|
+
print(f"Fetching {pid} from the Google Slides API…")
|
|
1027
|
+
pres = fetch(pid)
|
|
1028
|
+
title = pres.get("title") or "Imported deck"
|
|
1029
|
+
deck = Path(dest).expanduser() if dest and ("/" in dest or Path(dest).exists()) else HOME / _slug(dest or title)
|
|
1030
|
+
deck.mkdir(parents=True, exist_ok=True)
|
|
1031
|
+
if pid:
|
|
1032
|
+
(deck / "source").mkdir(exist_ok=True)
|
|
1033
|
+
(deck / "source" / f"gslides-api-{pid}.json").write_text(json.dumps(pres)) # the raw response, so a re-run needs no network
|
|
1034
|
+
info = import_deck(pres, deck, pptx=pptx)
|
|
1035
|
+
if "--ir" in flags: # the IR beside the raw response, for reading what the importer drew from
|
|
1036
|
+
(deck / "source").mkdir(exist_ok=True)
|
|
1037
|
+
(deck / "source" / "ir.json").write_text(json.dumps(Converter(pres, deck).ir(), indent=1, ensure_ascii=False))
|
|
1038
|
+
print(f"wrote {deck / 'deck.html'}: {info['slides']} slides" + (f", Google Fonts: {', '.join(info['fonts'])}" if info["fonts"] else ""))
|
|
1039
|
+
for l in info["log"]: print(" " + l)
|
|
1040
|
+
if "--report" in flags:
|
|
1041
|
+
if not pid: print("--report needs the live deck (thumbnails come from the API)")
|
|
1042
|
+
else:
|
|
1043
|
+
refs = thumbnails(pid, pres, deck / "refs")
|
|
1044
|
+
scores = report(deck, refs)
|
|
1045
|
+
if scores:
|
|
1046
|
+
print("mean pixel difference vs Google's render (under 3 is at parity, over 6 needs a look):")
|
|
1047
|
+
for n, s in enumerate(scores, 1): print(f" slide {n:>3} {'-' if s is None else f'{s:5.1f}'}")
|
|
1048
|
+
print(f"references in {deck / 'refs'}")
|
|
1049
|
+
_open(deck, flags)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _slug(title: str) -> str:
|
|
1053
|
+
import datetime
|
|
1054
|
+
s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "deck"
|
|
1055
|
+
return f"{s}-{datetime.date.today().isoformat()}"
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _open(deck: Path, flags: set):
|
|
1059
|
+
if "--no-open" in flags: return
|
|
1060
|
+
from .cli import serve
|
|
1061
|
+
serve(deck, True)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
if __name__ == "__main__":
|
|
1065
|
+
main(sys.argv[1:])
|