glideshow 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. glideshow/__init__.py +15 -0
  2. glideshow/__main__.py +3 -0
  3. glideshow/agent.py +681 -0
  4. glideshow/build.py +54 -0
  5. glideshow/cli.py +320 -0
  6. glideshow/comments.py +35 -0
  7. glideshow/decks.py +230 -0
  8. glideshow/docs/agents.md +223 -0
  9. glideshow/docs/deck-format.md +125 -0
  10. glideshow/editor/.assetsignore +5 -0
  11. glideshow/editor/apple-touch-icon.png +0 -0
  12. glideshow/editor/data/font-metrics.json +1 -0
  13. glideshow/editor/data/fonts.json +1 -0
  14. glideshow/editor/data/icons.json +1 -0
  15. glideshow/editor/data/symbols.js +42 -0
  16. glideshow/editor/data/themes.js +15 -0
  17. glideshow/editor/deck/base.css +92 -0
  18. glideshow/editor/deck/present.js +222 -0
  19. glideshow/editor/deck/shapes.js +377 -0
  20. glideshow/editor/deck/template.html +37 -0
  21. glideshow/editor/editor.css +657 -0
  22. glideshow/editor/glideshow-1024.png +0 -0
  23. glideshow/editor/glideshow.svg +10 -0
  24. glideshow/editor/globals.d.ts +21 -0
  25. glideshow/editor/home.html +358 -0
  26. glideshow/editor/index.html +386 -0
  27. glideshow/editor/js/arrange.js +122 -0
  28. glideshow/editor/js/boot.js +66 -0
  29. glideshow/editor/js/boxes.js +25 -0
  30. glideshow/editor/js/comments.js +85 -0
  31. glideshow/editor/js/context-menus.js +93 -0
  32. glideshow/editor/js/dialogs.js +168 -0
  33. glideshow/editor/js/dock.js +22 -0
  34. glideshow/editor/js/draw.js +107 -0
  35. glideshow/editor/js/file.js +318 -0
  36. glideshow/editor/js/find.js +72 -0
  37. glideshow/editor/js/format-panel.js +101 -0
  38. glideshow/editor/js/geometry.js +153 -0
  39. glideshow/editor/js/guides.js +37 -0
  40. glideshow/editor/js/insert.js +73 -0
  41. glideshow/editor/js/keyboard.js +256 -0
  42. glideshow/editor/js/live.js +48 -0
  43. glideshow/editor/js/menubar.js +290 -0
  44. glideshow/editor/js/menus.js +93 -0
  45. glideshow/editor/js/motion.js +148 -0
  46. glideshow/editor/js/notes.js +63 -0
  47. glideshow/editor/js/objects.js +202 -0
  48. glideshow/editor/js/people.js +73 -0
  49. glideshow/editor/js/phone-comments.js +43 -0
  50. glideshow/editor/js/phone-edit.js +253 -0
  51. glideshow/editor/js/phone-home.js +51 -0
  52. glideshow/editor/js/phone-mode.js +73 -0
  53. glideshow/editor/js/phone-sheets.js +153 -0
  54. glideshow/editor/js/phone-touch.js +145 -0
  55. glideshow/editor/js/phone-view.js +150 -0
  56. glideshow/editor/js/pointer.js +407 -0
  57. glideshow/editor/js/render.js +353 -0
  58. glideshow/editor/js/shortcuts.js +67 -0
  59. glideshow/editor/js/slides.js +120 -0
  60. glideshow/editor/js/spacing.js +23 -0
  61. glideshow/editor/js/state.js +58 -0
  62. glideshow/editor/js/storage.js +250 -0
  63. glideshow/editor/js/text.js +366 -0
  64. glideshow/editor/js/theme-toggle.js +41 -0
  65. glideshow/editor/js/theme.js +173 -0
  66. glideshow/editor/js/toolbar-groups.js +109 -0
  67. glideshow/editor/js/toolbar.js +372 -0
  68. glideshow/editor/lib/browser-db.js +47 -0
  69. glideshow/editor/lib/library.js +612 -0
  70. glideshow/editor/lib/merge.js +174 -0
  71. glideshow/editor/lib/move-local.js +51 -0
  72. glideshow/editor/lib/onefile.js +108 -0
  73. glideshow/editor/lib/store-browser.js +75 -0
  74. glideshow/editor/lib/store-folder.js +109 -0
  75. glideshow/editor/local/sw.js +66 -0
  76. glideshow/editor/local/tsconfig.json +5 -0
  77. glideshow/editor/looks/glider.svg +9 -0
  78. glideshow/editor/looks/thermal-phone.css +144 -0
  79. glideshow/editor/looks/thermal.css +268 -0
  80. glideshow/editor/manifest.json +23 -0
  81. glideshow/editor/phone.css +345 -0
  82. glideshow/editor/share/share.css +124 -0
  83. glideshow/editor/share/share.js +256 -0
  84. glideshow/editor/share/speaker.html +88 -0
  85. glideshow/editor/tsconfig.json +6 -0
  86. glideshow/figure.py +33 -0
  87. glideshow/fonts.py +63 -0
  88. glideshow/gslides.py +1065 -0
  89. glideshow/history.py +210 -0
  90. glideshow/merge.py +210 -0
  91. glideshow/osutil.py +44 -0
  92. glideshow/pptx_export.py +207 -0
  93. glideshow/pptx_import.py +115 -0
  94. glideshow/publish.py +106 -0
  95. glideshow/render.py +288 -0
  96. glideshow/server.py +549 -0
  97. glideshow/service.py +65 -0
  98. glideshow/skills/glideshow/SKILL.md +85 -0
  99. glideshow-0.1.2.dist-info/METADATA +69 -0
  100. glideshow-0.1.2.dist-info/RECORD +103 -0
  101. glideshow-0.1.2.dist-info/WHEEL +4 -0
  102. glideshow-0.1.2.dist-info/entry_points.txt +2 -0
  103. glideshow-0.1.2.dist-info/licenses/LICENSE +662 -0
glideshow/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 = "&nbsp;" * 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:])