slidesync 0.1.0__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.
slidesync/_sync.py ADDED
@@ -0,0 +1,1941 @@
1
+ """Bidirectional sync between a Slidev markdown deck and Google Slides.
2
+
3
+ `push` builds **native** Slides objects (title/body placeholders, bullets,
4
+ tables, positioned images) via `presentations.batchUpdate` — so text stays
5
+ editable, not a flat image. `pull` reconstructs Slidev markdown from those
6
+ native objects. `roundtrip` pushes a sample into a fresh deck, pulls it back,
7
+ and asserts the two are semantically identical.
8
+
9
+ Auth is borrowed from `gog` (no separate OAuth client): the client id/secret
10
+ live in `~/Library/Application Support/gogcli/credentials.json` and the refresh
11
+ token is exported via `gog auth tokens export`. The stored token already carries
12
+ the `slides`+`drive` scopes.
13
+
14
+ Idempotent sync (upsert), never a blind append:
15
+
16
+ - Each managed slide is created with `objectId = s2g_<keyHash>_<contentHash>`.
17
+ - `keyHash` identifies *which* slide (per-slide `id:` frontmatter, else a title
18
+ slug, else index) and survives edits/reorders; `contentHash` is over a
19
+ canonical render, so push->pull->push is a no-op.
20
+ - Diff: identical hash -> skip; same key, new content -> replace; new key ->
21
+ create. Removed slides are kept by default (`--prune` to delete).
22
+ - Only `s2g_`-prefixed slides are ever touched; hand-authored slides are
23
+ invisible to the sync. A tiny `<!-- s2g {...} -->` marker in speaker notes
24
+ carries the human id + image path so `pull` can recover them.
25
+
26
+ Usage:
27
+ bin/slidesync.py push deck.slidev.md --deck <id> [--anchor <slideId>] [--prune]
28
+ bin/slidesync.py push deck.slidev.md --new "My Talk"
29
+ bin/slidesync.py pull <id> --out deck.slidev.md
30
+ bin/slidesync.py roundtrip [--keep]
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import hashlib
37
+ import html
38
+ import json
39
+ import math
40
+ import os
41
+ import re
42
+ import subprocess
43
+ import sys
44
+ import tempfile
45
+ from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+
48
+ import frontmatter
49
+ from google.auth.transport.requests import Request
50
+ from google.oauth2.credentials import Credentials
51
+ from googleapiclient.discovery import build
52
+ from googleapiclient.http import MediaFileUpload
53
+ from loguru import logger
54
+
55
+ GOGCLI_CRED = Path.home() / "Library/Application Support/gogcli/credentials.json"
56
+ TOKEN_URI = "https://oauth2.googleapis.com/token"
57
+ SCOPES = [
58
+ "https://www.googleapis.com/auth/presentations",
59
+ "https://www.googleapis.com/auth/drive",
60
+ ]
61
+ DEFAULT_ACCOUNT = None # resolved from gog (or $SLIDESYNC_ACCOUNT)
62
+ IMAGE_CACHE = Path(".data/cache/slidesync_images.json")
63
+
64
+ MANAGED_RE = re.compile(r"^s2g_[0-9a-f]{10}_[0-9a-f]{10}$")
65
+ MARKER_RE = re.compile(r"<!--\s*s2g\s*(?P<json>\{.*?\})\s*-->", re.S)
66
+ TEMPLATE_TAG_RE = re.compile(r"<!--\s*s2g:template\s+(?P<name>\S+)\s*-->")
67
+ EMU_PER_PX = 9525 # 96 dpi
68
+ EMU_PER_IN = 914400
69
+ SLIDE_W = 9144000
70
+ SLIDE_H = 5143500 # 16:9 slide height (5.625in)
71
+ BODY_X, BODY_Y = 457200, 1143000
72
+ BODY_W, BODY_H = 8229600, 3771900
73
+
74
+ SECTION_LAYOUTS = {"section", "center", "cover", "intro"}
75
+ RESERVED_KEYS = {"id", "template", "layout"}
76
+
77
+ # Brand palette extracted from the Reliable Monitors deck (IBM Plex Sans).
78
+ BRAND_FONT = "IBM Plex Sans"
79
+ RED = {"red": 0.7529412, "green": 0.22352941, "blue": 0.16862746} # #C0392B
80
+ INK = {"red": 0.011764706, "green": 0.02745098, "blue": 0.07058824} # #03070F
81
+ BODY_INK = {"red": 0.11764706, "green": 0.1254902, "blue": 0.14117648} # #1E2024
82
+ PAPER = {"red": 0.98039216, "green": 0.98039216, "blue": 0.98039216} # #FAFAFA
83
+ WHITE = {"red": 1.0, "green": 1.0, "blue": 1.0} # #FFFFFF
84
+ LIGHT_BG, DARK_BG = PAPER, BODY_INK
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Auth (borrowed from gog)
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _ensure_gog_keyring_password() -> None:
92
+ """Load gog's file-keyring password lazily for our gog subprocesses.
93
+
94
+ Shells no longer export GOG_KEYRING_PASSWORD globally; read it from the
95
+ 600-mode password file only when slidesync actually invokes gog.
96
+ """
97
+ if os.environ.get("GOG_KEYRING_PASSWORD"):
98
+ return
99
+ for p in (
100
+ Path.home() / ".config/gogcli/keyring-password",
101
+ Path.home() / "Library/Application Support/gogcli/keyring-password",
102
+ ):
103
+ if p.exists():
104
+ os.environ["GOG_KEYRING_PASSWORD"] = p.read_text().strip()
105
+ return
106
+
107
+
108
+ def get_services(account: str | None):
109
+ _ensure_gog_keyring_password()
110
+ creds = _credentials(account or _default_account())
111
+ slides = build("slides", "v1", credentials=creds, cache_discovery=False)
112
+ drive = build("drive", "v3", credentials=creds, cache_discovery=False)
113
+ return slides, drive
114
+
115
+
116
+ def _default_account() -> str:
117
+ """Resolve the gog account: $SLIDESYNC_ACCOUNT, else gog's default account."""
118
+ env = os.environ.get("SLIDESYNC_ACCOUNT")
119
+ if env:
120
+ return env
121
+ out = subprocess.run(["gog", "auth", "list", "-p"], capture_output=True,
122
+ text=True).stdout
123
+ rows = [ln.split("\t") for ln in out.splitlines() if ln.strip()]
124
+ for cols in rows:
125
+ if len(cols) >= 2 and cols[1] == "default":
126
+ return cols[0]
127
+ if rows:
128
+ return rows[0][0]
129
+ sys.exit("no gog account found; run `gog login <email>` or set "
130
+ "$SLIDESYNC_ACCOUNT")
131
+
132
+
133
+ def _credentials(account: str) -> Credentials:
134
+ if not GOGCLI_CRED.exists():
135
+ sys.exit(f"gog OAuth client not found at {GOGCLI_CRED}")
136
+ client = json.loads(GOGCLI_CRED.read_text())
137
+ with tempfile.NamedTemporaryFile(suffix=".json") as tmp:
138
+ subprocess.run(
139
+ ["gog", "auth", "tokens", "export", account, "--out", tmp.name,
140
+ "--overwrite"],
141
+ check=True, capture_output=True, text=True,
142
+ )
143
+ token = json.loads(Path(tmp.name).read_text())
144
+ creds = Credentials(
145
+ token=None, refresh_token=token["refresh_token"],
146
+ client_id=client["client_id"], client_secret=client["client_secret"],
147
+ token_uri=TOKEN_URI, scopes=SCOPES,
148
+ )
149
+ creds.refresh(Request())
150
+ return creds
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Model
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ @dataclass
159
+ class Run:
160
+ start: int # codepoint offset into Para.text
161
+ end: int
162
+ style: str # bold | italic | code | link
163
+ link: str | None = None
164
+
165
+
166
+ @dataclass
167
+ class Para:
168
+ text: str # clean text, no leading tabs
169
+ runs: list[Run] = field(default_factory=list)
170
+ depth: int = -1 # >=0 bullet nesting; -1 plain paragraph
171
+ ordered: bool = False # numbered list item (1.) vs bullet (-)
172
+
173
+
174
+ @dataclass
175
+ class Slide:
176
+ key: str
177
+ layout: str # section | content | image | table (generative path)
178
+ title: str = ""
179
+ paras: list[Para] = field(default_factory=list)
180
+ image: str | None = None
181
+ image_alt: str = "" # ![alt](path) -> image description / accessibility alt text
182
+ table: list[list[str]] | None = None
183
+ notes: str = ""
184
+ kicker: str = "" # h2 above an h1 -> {{h2}} kicker
185
+ layout_name: str | None = None # explicit `layout:` — section kw or theme layout
186
+ template_name: str | None = None # explicit `template:` — tagged styled slide
187
+ vars: dict = field(default_factory=dict) # extra frontmatter -> {{token}} values
188
+ custom: str | None = None # ```gslides``` literal Slides API requests (JSON)
189
+ verbatim: str | None = None # ``` ``` fenced body for prompt/code slides
190
+ key_hash: str = ""
191
+ content_hash: str = ""
192
+ object_id: str = ""
193
+
194
+ def semantic(self) -> tuple:
195
+ def runs(p):
196
+ return tuple((r.start, r.end, r.style, r.link) for r in p.runs)
197
+ paras = tuple((p.depth, p.ordered, p.text, runs(p)) for p in self.paras)
198
+ table = tuple(map(tuple, self.table)) if self.table else None
199
+ return (self.key, self.layout_name, self.template_name,
200
+ tuple(sorted(self.vars.items())), self.layout, self.title,
201
+ self.kicker, paras, table, self.image, self.image_alt,
202
+ self.notes)
203
+
204
+
205
+ def _u16(text: str) -> int:
206
+ return len(text.encode("utf-16-le")) // 2
207
+
208
+
209
+ def _slug(text: str) -> str:
210
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
211
+
212
+
213
+ def _sha10(text: str) -> str:
214
+ return hashlib.sha1(text.encode()).hexdigest()[:10]
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Importer: markdown -> Slide
219
+ # ---------------------------------------------------------------------------
220
+
221
+ VCLICK_RE = re.compile(r"</?v-clicks?\b[^>]*>", re.I)
222
+ DIV_RE = re.compile(r"</?(?:div|span)\b[^>]*>", re.I)
223
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
224
+ LIST_RE = re.compile(r"^(?P<indent>\s*)(?P<marker>[-*]|\d+\.)\s+(?P<text>.*)$")
225
+ IMAGE_RE = re.compile(r"^!\[(?P<alt>[^\]]*)\]\((?P<url>[^)]+)\)\s*$")
226
+ COMMENT_RE = re.compile(r"<!--(?P<body>.*?)-->", re.S)
227
+ CUSTOM_RE = re.compile(
228
+ r"""(?msx) # multiline, dotall, verbose
229
+ ^```\ *g?slides\ *\n # fence opening with a gslides/slides lang tag
230
+ (?P<json>.*?) # the literal Slides API requests (JSON)
231
+ \n```\ *$ # fence close
232
+ """
233
+ )
234
+ TABLE_SEP_RE = re.compile(r"^\s*\|?[\s:|-]+\|?\s*$")
235
+ INLINE_RE = re.compile(
236
+ r"""(?x)
237
+ (\*\*.+?\*\* | __.+?__
238
+ | \*[^*]+?\* | _[^_]+?_
239
+ | `[^`]+?`
240
+ | \[[^\]]+?\]\([^)]+?\))
241
+ """
242
+ )
243
+
244
+
245
+ def load_slides(path: Path) -> list[Slide]:
246
+ return build_slides(split_slides(path.read_text()))
247
+
248
+
249
+ def split_slides(text: str) -> list[tuple[dict, str]]:
250
+ post = frontmatter.loads(text)
251
+ chunks = re.split(r"(?m)^---[ \t]*$", post.content)
252
+ out: list[tuple[dict, str]] = []
253
+ i = 0
254
+ while i < len(chunks):
255
+ meta, chunk = {}, chunks[i]
256
+ if i + 1 < len(chunks) and _is_yaml_block(chunk):
257
+ meta = _parse_yaml(chunk)
258
+ i += 1
259
+ chunk = chunks[i] if i < len(chunks) else ""
260
+ if chunk.strip():
261
+ out.append((meta, chunk))
262
+ i += 1
263
+ return out
264
+
265
+
266
+ def _is_yaml_block(chunk: str) -> bool:
267
+ lines = [ln for ln in chunk.splitlines() if ln.strip()]
268
+ return bool(lines) and all(re.match(r"^\s*[\w-]+\s*:", ln) for ln in lines)
269
+
270
+
271
+ def _parse_yaml(text: str) -> dict:
272
+ out = {}
273
+ for line in text.splitlines():
274
+ m = re.match(r"^\s*([\w-]+)\s*:\s*(.*)$", line)
275
+ if m:
276
+ out[m.group(1)] = m.group(2).strip().strip("'\"")
277
+ return out
278
+
279
+
280
+ def build_slides(chunks: list[tuple[dict, str]]) -> list[Slide]:
281
+ return [build_slide(meta, body, i) for i, (meta, body) in enumerate(chunks)]
282
+
283
+
284
+ def build_slide(meta: dict, body: str, index: int) -> Slide:
285
+ custom, body = _extract_custom(body)
286
+ verbatim = None
287
+ if (meta.get("template") or "").lower() in ("prompt", "code"):
288
+ verbatim, body = _extract_verbatim(body)
289
+ headings, paras, image, image_alt, table, notes = parse_body(body)
290
+ h1, h2 = headings.get(1), headings.get(2)
291
+ title = h1 or h2 or "" # h1 is the headline; a lone h2 is the title
292
+ kicker = h2 if (h1 and h2) else "" # an h2 above an h1 is the kicker
293
+ key = meta.get("id") or _slug(title) or f"slide{index}"
294
+ slide = Slide(key, _layout_of(meta, image, table), title, paras, image,
295
+ image_alt, table, notes)
296
+ slide.kicker = kicker
297
+ slide.layout_name = meta.get("layout")
298
+ slide.template_name = "custom" if custom is not None else meta.get("template")
299
+ slide.vars = {k: v for k, v in meta.items() if k not in RESERVED_KEYS}
300
+ slide.custom = custom
301
+ slide.verbatim = verbatim
302
+ return _finalize(slide)
303
+
304
+
305
+ def _finalize(slide: Slide) -> Slide:
306
+ slide.key_hash = _sha10(slide.key)
307
+ slide.content_hash = _sha10(to_slidev(slide, include_id=False))
308
+ if slide.custom is not None:
309
+ # Stable id keyed only on `id:` — native drawing edits (which never touch
310
+ # the markdown) must not orphan the slide, since custom slides are
311
+ # pull-authoritative and only (re)pushed when missing.
312
+ slide.content_hash = slide.key_hash
313
+ slide.object_id = f"s2g_{slide.key_hash}_{slide.content_hash}"
314
+ return slide
315
+
316
+
317
+ def _layout_of(meta: dict, image, table) -> str:
318
+ if meta.get("layout") in SECTION_LAYOUTS:
319
+ return "section"
320
+ if image:
321
+ return "image"
322
+ if table:
323
+ return "table"
324
+ return "content"
325
+
326
+
327
+ def parse_body(body: str):
328
+ """Return (headings{level:text}, paras, image, table, notes)."""
329
+ notes = _extract_notes(body)
330
+ body = COMMENT_RE.sub("", body)
331
+ body = VCLICK_RE.sub("", DIV_RE.sub("", body))
332
+ lines = body.splitlines()
333
+ headings, paras, image, table, image_alt = {}, [], None, None, ""
334
+ i = 0
335
+ while i < len(lines):
336
+ line, stripped = lines[i], lines[i].strip()
337
+ if not stripped:
338
+ if paras and (paras[-1].text or paras[-1].depth >= 0):
339
+ paras.append(Para("", [], -1)) # keep one blank line for spacing
340
+ i += 1
341
+ continue
342
+ hm = HEADING_RE.match(line)
343
+ level = len(hm.group(1)) if hm else 0
344
+ if hm and level in (1, 2) and level not in headings and not paras:
345
+ headings[level] = parse_inline(hm.group(2).strip())[0]
346
+ i += 1
347
+ elif (im := IMAGE_RE.match(stripped)):
348
+ image, image_alt, i = im.group("url"), im.group("alt"), i + 1
349
+ elif "|" in line and i + 1 < len(lines) and TABLE_SEP_RE.match(lines[i + 1]):
350
+ table, i = _parse_table(lines, i)
351
+ else:
352
+ paras.append(_parse_para(line, hm))
353
+ i += 1
354
+ while paras and not paras[-1].text and paras[-1].depth < 0 \
355
+ and not paras[-1].runs:
356
+ paras.pop()
357
+ return headings, paras, image, image_alt, table, notes
358
+
359
+
360
+ def _extract_notes(body: str) -> str:
361
+ parts = [m.group("body").strip() for m in COMMENT_RE.finditer(body)]
362
+ joined = "\n".join(p for p in parts if p)
363
+ return MARKER_RE.sub("", joined).strip()
364
+
365
+
366
+ def _extract_custom(body: str) -> tuple[str | None, str]:
367
+ """Pull a ```gslides``` literal-requests block out of the body, if present."""
368
+ m = CUSTOM_RE.search(body)
369
+ if not m:
370
+ return None, body
371
+ return m.group("json").strip(), body[:m.start()] + body[m.end():]
372
+
373
+
374
+ VERBATIM_RE = re.compile( # any fenced block — captures the literal body, no parsing
375
+ r"""(?msx)
376
+ ^```[^\n]*\n
377
+ (?P<text>.*?)
378
+ \n```[ ]*$
379
+ """
380
+ )
381
+
382
+
383
+ def _extract_verbatim(body: str) -> tuple[str | None, str]:
384
+ """Pull a fenced block out of the body verbatim (for prompt/code slides)."""
385
+ m = VERBATIM_RE.search(body)
386
+ if not m:
387
+ return None, body
388
+ return m.group("text"), body[:m.start()] + body[m.end():]
389
+
390
+
391
+ def _parse_para(line: str, hm) -> Para:
392
+ lm = LIST_RE.match(line)
393
+ if lm:
394
+ depth = len(lm.group("indent").replace("\t", " ")) // 2
395
+ clean, runs = parse_inline(lm.group("text").strip())
396
+ return Para(clean, runs, depth, ordered=lm.group("marker")[0].isdigit())
397
+ if hm:
398
+ clean, _ = parse_inline(hm.group(2).strip())
399
+ return Para(clean, [Run(0, len(clean), "bold")], -1)
400
+ clean, runs = parse_inline(line.strip())
401
+ return Para(clean, runs, -1)
402
+
403
+
404
+ def _parse_table(lines, i):
405
+ rows = []
406
+ while i < len(lines) and "|" in lines[i]:
407
+ if TABLE_SEP_RE.match(lines[i]):
408
+ i += 1
409
+ continue
410
+ cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
411
+ rows.append([parse_inline(c)[0] for c in cells])
412
+ i += 1
413
+ return rows, i
414
+
415
+
416
+ WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
417
+ BR_RE = re.compile(r"<br\s*/?>", re.I)
418
+
419
+
420
+ def _clean_text(text: str) -> str:
421
+ text = BR_RE.sub(" ", text)
422
+ text = WIKILINK_RE.sub(lambda m: m.group(2) or m.group(1), text)
423
+ return html.unescape(text)
424
+
425
+
426
+ def parse_inline(text: str) -> tuple[str, list[Run]]:
427
+ text = _clean_text(text)
428
+ clean, runs, pos = "", [], 0
429
+ for m in INLINE_RE.finditer(text):
430
+ clean += text[pos:m.start()]
431
+ inner, style, link = _inline_inner(m.group(0))
432
+ runs.append(Run(len(clean), len(clean) + len(inner), style, link))
433
+ clean += inner
434
+ pos = m.end()
435
+ clean += text[pos:]
436
+ return clean, runs
437
+
438
+
439
+ def _inline_inner(tok: str):
440
+ if tok.startswith(("**", "__")):
441
+ return tok[2:-2], "bold", None
442
+ if tok.startswith("`"):
443
+ return tok[1:-1], "code", None
444
+ if tok.startswith("["):
445
+ m = re.match(r"\[([^\]]+)\]\(([^)]+)\)", tok)
446
+ return m.group(1), "link", m.group(2)
447
+ return tok[1:-1], "italic", None
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # Canonical render: Slide -> markdown
452
+ # ---------------------------------------------------------------------------
453
+
454
+ MARKS = {"bold": ("**", "**"), "italic": ("*", "*"), "code": ("`", "`")}
455
+
456
+
457
+ def to_slidev(slide: Slide, include_id: bool = True) -> str:
458
+ fm = {}
459
+ if slide.template_name:
460
+ fm["template"] = slide.template_name
461
+ for k in sorted(slide.vars):
462
+ fm[k] = slide.vars[k]
463
+ elif slide.layout_name:
464
+ fm["layout"] = slide.layout_name
465
+ if include_id:
466
+ fm["id"] = slide.key
467
+ out = []
468
+ if fm:
469
+ out += ["---"] + [f"{k}: {v}" for k, v in fm.items()] + ["---"]
470
+ if slide.custom is not None:
471
+ out += ["```gslides", slide.custom, "```"]
472
+ if slide.notes:
473
+ out.append(f"<!-- {slide.notes} -->")
474
+ return "\n".join(out).strip() + "\n"
475
+ if slide.verbatim is not None:
476
+ if slide.title:
477
+ out.append(("# " if slide.kicker else "## ") + slide.title)
478
+ if slide.kicker:
479
+ out.append("## " + slide.kicker)
480
+ out += ["```text", slide.verbatim, "```"]
481
+ if slide.notes:
482
+ out.append(f"<!-- {slide.notes} -->")
483
+ return "\n".join(out).strip() + "\n"
484
+ if slide.kicker: # h1 headline + h2 kicker
485
+ out.append("# " + slide.title)
486
+ out.append("## " + slide.kicker)
487
+ elif slide.title:
488
+ out.append(("# " if slide.layout == "section" else "## ") + slide.title)
489
+ out += [_render_para(p) for p in slide.paras]
490
+ if slide.image:
491
+ out.append(f"![{slide.image_alt}]({slide.image})")
492
+ if slide.table:
493
+ out += _render_table(slide.table)
494
+ if slide.notes:
495
+ out.append(f"<!-- {slide.notes} -->")
496
+ return "\n".join(out).strip() + "\n"
497
+
498
+
499
+ def _render_para(p: Para) -> str:
500
+ body = render_inline(p.text, p.runs)
501
+ if p.depth >= 0:
502
+ return " " * p.depth + ("1. " if p.ordered else "- ") + body
503
+ return body
504
+
505
+
506
+ def render_inline(text: str, runs: list[Run]) -> str:
507
+ edits = []
508
+ for r in runs:
509
+ start, end = r.start, r.end # don't wrap surrounding whitespace in marks
510
+ while start < end and text[start].isspace():
511
+ start += 1
512
+ while end > start and text[end - 1].isspace():
513
+ end -= 1
514
+ if start >= end:
515
+ continue
516
+ if r.style == "link":
517
+ edits.append((start, "["))
518
+ edits.append((end, f"]({r.link})"))
519
+ else:
520
+ o, c = MARKS[r.style]
521
+ edits.append((start, o))
522
+ edits.append((end, c))
523
+ out, last = [], 0
524
+ for pos, mark in sorted(edits, key=lambda e: (e[0], e[1] in (")",))):
525
+ out.append(text[last:pos])
526
+ out.append(mark)
527
+ last = pos
528
+ out.append(text[last:])
529
+ return "".join(out)
530
+
531
+
532
+ def _render_table(table) -> list[str]:
533
+ head = "| " + " | ".join(table[0]) + " |"
534
+ sep = "| " + " | ".join("---" for _ in table[0]) + " |"
535
+ rows = ["| " + " | ".join(r) + " |" for r in table[1:]]
536
+ return [head, sep, *rows]
537
+
538
+
539
+ def _marker(slide: Slide) -> str:
540
+ data = {"id": slide.key}
541
+ if slide.image:
542
+ data["img"] = slide.image
543
+ if slide.image_alt:
544
+ data["alt"] = slide.image_alt
545
+ if slide.template_name:
546
+ data["template"] = slide.template_name
547
+ if slide.title:
548
+ data["h1"] = slide.title
549
+ if slide.kicker:
550
+ data["h2"] = slide.kicker
551
+ body_md = "\n".join(_render_para(p) for p in slide.paras)
552
+ if body_md:
553
+ data["body"] = body_md
554
+ if slide.vars:
555
+ data["vars"] = slide.vars
556
+ elif slide.layout_name and slide.layout_name not in SECTION_LAYOUTS:
557
+ data["tpl"] = slide.layout_name
558
+ return f"<!-- s2g {json.dumps(data, separators=(',', ':'))} -->"
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # Push: Slide -> batchUpdate
563
+ # ---------------------------------------------------------------------------
564
+
565
+ STYLE = {
566
+ "bold": ({"bold": True}, "bold"),
567
+ "italic": ({"italic": True}, "italic"),
568
+ "code": ({"fontFamily": "Roboto Mono"}, "fontFamily"),
569
+ }
570
+
571
+
572
+ FILLABLE = {"TITLE", "CENTERED_TITLE", "SUBTITLE", "BODY"}
573
+
574
+
575
+ def _font_all(obj_id, rgb, bold=None, cell=None) -> dict:
576
+ """Set the brand font + colour over all text in a shape or table cell."""
577
+ style = {"fontFamily": BRAND_FONT,
578
+ "foregroundColor": {"opaqueColor": {"rgbColor": rgb}}}
579
+ fields = "fontFamily,foregroundColor"
580
+ if bold is not None:
581
+ style["bold"] = bold
582
+ fields += ",bold"
583
+ req = {"objectId": obj_id, "textRange": {"type": "ALL"},
584
+ "style": style, "fields": fields}
585
+ if cell is not None:
586
+ req["cellLocation"] = {"rowIndex": cell[0], "columnIndex": cell[1]}
587
+ return {"updateTextStyle": req}
588
+
589
+
590
+ def _kicker(tid) -> list[dict]:
591
+ """Style a slide title as the deck's red, centered kicker label."""
592
+ return [
593
+ {"updateTextStyle": {"objectId": tid, "textRange": {"type": "ALL"},
594
+ "style": {"fontFamily": BRAND_FONT, "bold": False,
595
+ "fontSize": {"magnitude": 18, "unit": "PT"},
596
+ "foregroundColor": {"opaqueColor": {"rgbColor": RED}}},
597
+ "fields": "fontFamily,bold,fontSize,foregroundColor"}},
598
+ {"updateParagraphStyle": {"objectId": tid, "textRange": {"type": "ALL"},
599
+ "style": {"alignment": "CENTER"}, "fields": "alignment"}},
600
+ ]
601
+
602
+
603
+ @dataclass
604
+ class Style:
605
+ bg: dict # slide background
606
+ headline_pt: int | None # None -> no separate headline (kicker is the title)
607
+ headline_rgb: dict
608
+ body_align: str | None # CENTER | START | None (no body region)
609
+ top: float # starting y (inches) of the kicker/headline block
610
+ head_lines: int = 2 # max headline lines before auto-fit shrinks the font
611
+
612
+
613
+ # Built-in brand kit matching the Reliable Monitors deck (no in-deck templates).
614
+ STYLES = {
615
+ "dark": Style(DARK_BG, 72, PAPER, None, 2.0), # dark title card
616
+ "title": Style(DARK_BG, 72, PAPER, None, 2.0),
617
+ "appendix": Style(LIGHT_BG, 72, INK, None, 2.0), # light title card
618
+ "label": Style(LIGHT_BG, 50, INK, "CENTER", 1.5), # question + centered body
619
+ "question": Style(LIGHT_BG, 50, INK, "CENTER", 1.5),
620
+ "topic": Style(LIGHT_BG, 40, INK, "START", 0.6, head_lines=1), # 1-line headline
621
+ "content": Style(LIGHT_BG, None, INK, "START", 0.4), # kicker-as-title + left body
622
+ }
623
+
624
+
625
+ def _est_lines(text: str, pt: int, width_in: float = 9.32) -> int:
626
+ """Estimate wrapped line count for a bold headline (font-metric free).
627
+
628
+ The 0.64 average-glyph-width factor is measured from IBM Plex Bold and is
629
+ deliberately conservative: it slightly over-counts lines so the reserved
630
+ headline height never falls short of what renders (which would overlap the
631
+ body below it).
632
+ """
633
+ chars_per_line = max(1, int(width_in * 72 / (pt * 0.64)))
634
+ return max(1, math.ceil(len(text) / chars_per_line))
635
+
636
+
637
+ def _fit_headline_pt(text: str, base_pt: int, max_lines: int = 2,
638
+ width_in: float = 9.32) -> int:
639
+ """Shrink a headline's font so it wraps to at most `max_lines` lines.
640
+
641
+ Steps down from `base_pt` (never below ~55% of it) until the estimated
642
+ wrapped line count fits — so a long title shrinks to stay on the slide
643
+ instead of overflowing or pushing the body off the page.
644
+ """
645
+ floor = 20 if max_lines == 1 else max(20, int(base_pt * 0.55))
646
+ pt = base_pt
647
+ while pt > floor and _est_lines(text, pt, width_in) > max_lines:
648
+ pt -= 2
649
+ return pt
650
+
651
+
652
+ def _styled_requests(slide: Slide, style: Style, image_url, image_px) -> list[dict]:
653
+ sid = slide.object_id
654
+ reqs = [{"createSlide": {"objectId": sid,
655
+ "slideLayoutReference": {"predefinedLayout": "BLANK"}}},
656
+ _bg(sid, style.bg)]
657
+ if style.headline_pt is None: # content: the kicker IS the title
658
+ kicker_text, headline_text = slide.title, None
659
+ else:
660
+ kicker_text, headline_text = slide.kicker, slide.title
661
+ head_h = 0.0
662
+ head_pt = style.headline_pt
663
+ if headline_text:
664
+ head_pt = _fit_headline_pt(headline_text, style.headline_pt,
665
+ max_lines=style.head_lines)
666
+ head_h = _est_lines(headline_text, head_pt) * head_pt * 1.25 / 72 + 0.1
667
+ # Title cards (no body, no image) vertically centre the kicker+headline.
668
+ if (style.body_align is None or not slide.paras) and not slide.image:
669
+ block = (0.36 if kicker_text else 0) + head_h
670
+ y = max(0.4, (5.63 - block) / 2)
671
+ else:
672
+ y = style.top
673
+ # Headings align with the body: left for START-body templates (topic/content),
674
+ # centred for centred-body templates (question/label) and title cards.
675
+ head_align = "START" if style.body_align == "START" else "CENTER"
676
+ if kicker_text:
677
+ reqs += _text_box(sid, sid + "_k", (0.34, y, 9.32, 0.5),
678
+ kicker_text, 18, RED, False, halign=head_align)
679
+ y += 0.36 # tight kicker -> headline gap (kicker text is ~0.31in tall)
680
+ if headline_text:
681
+ reqs += _text_box(sid, sid + "_h", (0.34, y, 9.32, head_h),
682
+ headline_text, head_pt, style.headline_rgb, True,
683
+ halign=head_align)
684
+ y += head_h + 0.15
685
+ if style.body_align and slide.paras:
686
+ bid = sid + "_b"
687
+ reqs.append({"createShape": {"objectId": bid, "shapeType": "TEXT_BOX",
688
+ "elementProperties": {"pageObjectId": sid,
689
+ "size": {"width": {"magnitude": _emu(9.32), "unit": "EMU"},
690
+ "height": {"magnitude": _emu(max(5.2 - y, 1.0)),
691
+ "unit": "EMU"}},
692
+ "transform": {"scaleX": 1, "scaleY": 1, "translateX": _emu(0.34),
693
+ "translateY": _emu(y), "unit": "EMU"}}}})
694
+ reqs += _body(bid, slide.paras, align=style.body_align)
695
+ if slide.image and image_url:
696
+ avail = _emu(max(5.4 - y, 1.5))
697
+ w, h = _fit2(image_px, _emu(9.32), avail)
698
+ reqs.append({"createImage": {
699
+ "objectId": sid + "_img", "url": image_url,
700
+ "elementProperties": {"pageObjectId": sid,
701
+ "size": {"width": {"magnitude": w, "unit": "EMU"},
702
+ "height": {"magnitude": h, "unit": "EMU"}},
703
+ "transform": {"scaleX": 1, "scaleY": 1, "unit": "EMU",
704
+ "translateX": (SLIDE_W - w) // 2,
705
+ "translateY": _emu(y)}}}})
706
+ if slide.image_alt:
707
+ reqs.append(_alt_req(sid + "_img", slide.image_alt))
708
+ return reqs
709
+
710
+
711
+ def _fit2(px, max_w, max_h):
712
+ if not px:
713
+ return max_w, max_h
714
+ w, h = px[0] * EMU_PER_PX, px[1] * EMU_PER_PX
715
+ scale = min(max_w / w, max_h / h, 1.0)
716
+ return int(w * scale), int(h * scale)
717
+
718
+
719
+ def _graph_requests(slide: Slide, image_url, image_px) -> list[dict]:
720
+ """Full-bleed graph: a single image maximised to fill the page, no text.
721
+
722
+ For `template: graph` / `full` slides — the figure is self-titled, so the
723
+ slide carries no kicker, headline, or body. The image is scaled to fit the
724
+ slide (aspect preserved) with a thin margin and centred both ways.
725
+ """
726
+ sid = slide.object_id
727
+ reqs = [{"createSlide": {"objectId": sid,
728
+ "slideLayoutReference": {"predefinedLayout": "BLANK"}}},
729
+ _bg(sid, WHITE)]
730
+ if slide.image and image_url:
731
+ margin = _emu(0.1)
732
+ w, h = _fit2(image_px, SLIDE_W - 2 * margin, SLIDE_H - 2 * margin)
733
+ reqs.append({"createImage": {
734
+ "objectId": sid + "_img", "url": image_url,
735
+ "elementProperties": {"pageObjectId": sid,
736
+ "size": {"width": {"magnitude": w, "unit": "EMU"},
737
+ "height": {"magnitude": h, "unit": "EMU"}},
738
+ "transform": {"scaleX": 1, "scaleY": 1, "unit": "EMU",
739
+ "translateX": (SLIDE_W - w) // 2,
740
+ "translateY": (SLIDE_H - h) // 2}}}})
741
+ if slide.image_alt:
742
+ reqs.append(_alt_req(sid + "_img", slide.image_alt))
743
+ return reqs
744
+
745
+
746
+ def _custom_requests(slide: Slide) -> list[dict]:
747
+ """Build a custom slide by replaying its literal Slides API requests.
748
+
749
+ The ```gslides``` block holds either a list of requests or `{"requests":
750
+ [...]}`. The page is created blank; `__PAGE__` in the JSON is substituted
751
+ with this slide's objectId (so element ids embedding it stay unique).
752
+ """
753
+ sid = slide.object_id
754
+ reqs = [{"createSlide": {"objectId": sid,
755
+ "slideLayoutReference": {"predefinedLayout": "BLANK"}}}]
756
+ try:
757
+ payload = json.loads(slide.custom)
758
+ except json.JSONDecodeError as exc:
759
+ logger.error(f"custom slide '{slide.key}': invalid JSON ({exc}); left blank")
760
+ return reqs
761
+ body = payload.get("requests", payload) if isinstance(payload, dict) else payload
762
+ if not isinstance(body, list):
763
+ logger.error(f"custom slide '{slide.key}': expected a list of requests "
764
+ "or {{'requests': [...]}}; left blank")
765
+ return reqs
766
+ reqs += json.loads(json.dumps(body).replace("__PAGE__", sid))
767
+ return reqs
768
+
769
+
770
+ def _fit_body_pt(text: str, base: int = 11, floor: int = 6,
771
+ width_in: float = 9.32, height_in: float = 4.65) -> int:
772
+ """Largest mono size (base..floor) at which the verbatim text fits the box."""
773
+ src = text.split("\n")
774
+ for pt in range(base, floor - 1, -1):
775
+ cpl = max(1, int(width_in * 72 / (pt * 0.62))) # mono glyph ~0.62*pt wide
776
+ rows = sum(max(1, math.ceil(len(ln) / cpl)) for ln in src)
777
+ if rows * pt * 1.18 / 72 <= height_in: # ~90% line spacing (see _prompt_requests)
778
+ return pt
779
+ return floor
780
+
781
+
782
+ def _prompt_requests(slide: Slide) -> list[dict]:
783
+ """Verbatim prompt/code slide: red kicker title + the fenced body in mono.
784
+
785
+ For `template: prompt` / `code`. The ``` ``` block is rendered byte-for-byte
786
+ (no markdown parsing, so numbered lists / bullets survive) at the largest
787
+ Roboto Mono size that fits the slide, so even a long system prompt stays on
788
+ one slide.
789
+ """
790
+ sid = slide.object_id
791
+ reqs = [{"createSlide": {"objectId": sid,
792
+ "slideLayoutReference": {"predefinedLayout": "BLANK"}}},
793
+ _bg(sid, LIGHT_BG)]
794
+ if slide.title:
795
+ reqs += _text_box(sid, sid + "_k", (0.34, 0.28, 9.32, 0.5),
796
+ slide.title, 16, RED, True)
797
+ body = slide.verbatim if slide.verbatim is not None else \
798
+ "\n\n".join(p.text for p in slide.paras if p.text)
799
+ if body:
800
+ bid = sid + "_b"
801
+ reqs.append({"createShape": {"objectId": bid, "shapeType": "TEXT_BOX",
802
+ "elementProperties": {"pageObjectId": sid,
803
+ "size": {"width": {"magnitude": _emu(9.32), "unit": "EMU"},
804
+ "height": {"magnitude": _emu(4.65), "unit": "EMU"}},
805
+ "transform": {"scaleX": 1, "scaleY": 1, "translateX": _emu(0.34),
806
+ "translateY": _emu(0.85), "unit": "EMU"}}}})
807
+ reqs.append({"insertText": {"objectId": bid, "text": body}})
808
+ reqs.append({"updateTextStyle": {"objectId": bid, "textRange": {"type": "ALL"},
809
+ "style": {"fontFamily": "Roboto Mono",
810
+ "fontSize": {"magnitude": _fit_body_pt(body), "unit": "PT"},
811
+ "foregroundColor": {"opaqueColor": {"rgbColor": BODY_INK}}},
812
+ "fields": "fontFamily,fontSize,foregroundColor"}})
813
+ reqs.append({"updateParagraphStyle": {"objectId": bid, "textRange": {"type": "ALL"},
814
+ "style": {"lineSpacing": 90, "spaceAbove": {"magnitude": 0, "unit": "PT"},
815
+ "spaceBelow": {"magnitude": 0, "unit": "PT"}},
816
+ "fields": "lineSpacing,spaceAbove,spaceBelow"}})
817
+ return reqs
818
+
819
+
820
+ def slide_requests(slide: Slide, image_url, image_px,
821
+ layouts=None, templates=None) -> list[dict]:
822
+ if slide.custom is not None:
823
+ return _custom_requests(slide)
824
+ tpl = (slide.template_name or "").lower()
825
+ if tpl in ("graph", "full"):
826
+ return _graph_requests(slide, image_url, image_px)
827
+ if tpl in ("prompt", "code"):
828
+ return _prompt_requests(slide)
829
+ if tpl in STYLES:
830
+ return _styled_requests(slide, STYLES[tpl], image_url, image_px)
831
+ if templates and tpl in templates:
832
+ return _tagged_requests(slide, templates[tpl])
833
+ name = (slide.layout_name or "").lower()
834
+ if layouts and name in layouts and slide.layout_name not in SECTION_LAYOUTS:
835
+ return _layout_requests(slide, layouts[name])
836
+ tid = slide.object_id + "_t"
837
+ if slide.layout == "section":
838
+ reqs = _create(slide, "TITLE", [("CENTERED_TITLE", tid)])
839
+ reqs += _insert(tid, slide.title)
840
+ reqs += [_bg(slide.object_id, DARK_BG), _font_all(tid, PAPER, bold=True)]
841
+ elif slide.layout == "content":
842
+ bid = slide.object_id + "_b"
843
+ reqs = _create(slide, "TITLE_AND_BODY", [("TITLE", tid), ("BODY", bid)])
844
+ reqs += _insert(tid, slide.title) + _body(bid, slide.paras)
845
+ reqs += [_bg(slide.object_id, LIGHT_BG)] + _kicker(tid)
846
+ else:
847
+ reqs = _create(slide, "TITLE_ONLY", [("TITLE", tid)])
848
+ reqs += _insert(tid, slide.title)
849
+ reqs += [_bg(slide.object_id, LIGHT_BG)] + _kicker(tid)
850
+ if slide.layout == "image" and image_url:
851
+ reqs.append(_image(slide, image_url, image_px))
852
+ if slide.image_alt:
853
+ reqs.append(_alt_req(slide.object_id + "_img", slide.image_alt))
854
+ elif slide.layout == "table" and slide.table:
855
+ reqs += _table(slide)
856
+ return reqs
857
+
858
+
859
+ def _plain_body(paras: list[Para]) -> str:
860
+ return "\n".join(p.text for p in paras)
861
+
862
+
863
+ def _tagged_requests(slide: Slide, template_id: str) -> list[dict]:
864
+ """Duplicate a styled template slide and interpolate {{token}} text."""
865
+ reqs = [{"duplicateObject": {"objectId": template_id,
866
+ "objectIds": {template_id: slide.object_id}}}]
867
+ tokens = {"h1": slide.title, "title": slide.title, "h2": slide.kicker,
868
+ "body": _plain_body(slide.paras), **slide.vars}
869
+ for tok, val in tokens.items():
870
+ reqs.append({"replaceAllText": {
871
+ "containsText": {"text": "{{" + tok + "}}", "matchCase": True},
872
+ "replaceText": str(val), "pageObjectIds": [slide.object_id]}})
873
+ return reqs
874
+
875
+
876
+ def _layout_requests(slide: Slide, lay: dict) -> list[dict]:
877
+ """Fill a themed master layout's placeholders from the slide's structure."""
878
+ ids, mappings = {}, []
879
+ for t, idx in lay["ph"]:
880
+ if t not in FILLABLE:
881
+ continue
882
+ oid = f"{slide.object_id}_{t}{idx}"
883
+ ids[(t, idx)] = oid
884
+ mappings.append({"layoutPlaceholder": {"type": t, "index": idx},
885
+ "objectId": oid})
886
+ reqs = [{"createSlide": {
887
+ "objectId": slide.object_id,
888
+ "slideLayoutReference": {"layoutId": lay["id"]},
889
+ "placeholderIdMappings": mappings,
890
+ }}]
891
+ title_key = ("TITLE", 0) if ("TITLE", 0) in ids else ("CENTERED_TITLE", 0)
892
+ if title_key in ids:
893
+ reqs += _insert(ids[title_key], slide.title)
894
+ if ("BODY", 0) in ids:
895
+ reqs += _body(ids[("BODY", 0)], slide.paras)
896
+ return reqs
897
+
898
+
899
+ def _create(slide, layout, placeholders) -> list[dict]:
900
+ mappings = [{"layoutPlaceholder": {"type": t, "index": 0}, "objectId": oid}
901
+ for t, oid in placeholders]
902
+ return [{"createSlide": {
903
+ "objectId": slide.object_id,
904
+ "slideLayoutReference": {"predefinedLayout": layout},
905
+ "placeholderIdMappings": mappings,
906
+ }}]
907
+
908
+
909
+ def _insert(obj_id, text) -> list[dict]:
910
+ return [{"insertText": {"objectId": obj_id, "text": text}}] if text else []
911
+
912
+
913
+ def _body(bid: str, paras: list[Para], align: str = "START") -> list[dict]:
914
+ lines = ["\t" * max(p.depth, 0) + p.text for p in paras]
915
+ full = "\n".join(lines)
916
+ if not full:
917
+ return []
918
+ reqs = [{"insertText": {"objectId": bid, "text": full}},
919
+ _font_all(bid, BODY_INK)] # base brand font; inline runs override below
920
+ bullets, off = [], 0
921
+ for line, p in zip(lines, paras):
922
+ cbase = off + _u16("\t" * max(p.depth, 0))
923
+ for r in p.runs:
924
+ if r.style == "link" and r.link.startswith("#"):
925
+ continue # internal slide links: resolved in _apply_internal_links
926
+ s = cbase + _u16(p.text[:r.start])
927
+ e = cbase + _u16(p.text[:r.end])
928
+ reqs.append(_style(bid, s, e, r))
929
+ if p.depth >= 0:
930
+ bullets.append((off, off + _u16(line), p.ordered))
931
+ off += _u16(line) + 1
932
+ total = _u16(full)
933
+ bullets = [(s, min(e, total), o) for s, e, o in bullets if s < min(e, total)]
934
+ reqs += _bullets(bid, bullets)
935
+ reqs.append({"updateParagraphStyle": {"objectId": bid,
936
+ "textRange": {"type": "ALL"}, "style": {"alignment": align},
937
+ "fields": "alignment"}})
938
+ return reqs
939
+
940
+
941
+ _LINK_FIELDS = "link,foregroundColor,underline"
942
+
943
+
944
+ def _link_style(link: dict) -> dict:
945
+ """Brand-red, underlined link styling — overrides the theme hyperlink colour."""
946
+ return {"link": link,
947
+ "foregroundColor": {"opaqueColor": {"rgbColor": RED}},
948
+ "underline": True}
949
+
950
+
951
+ def _style(obj_id, start, end, run: Run) -> dict:
952
+ rng = {"type": "FIXED_RANGE", "startIndex": start, "endIndex": end}
953
+ if run.style == "link":
954
+ return {"updateTextStyle": {"objectId": obj_id, "textRange": rng,
955
+ "style": _link_style({"url": run.link}),
956
+ "fields": _LINK_FIELDS}}
957
+ style, fields = STYLE[run.style]
958
+ return {"updateTextStyle": {"objectId": obj_id, "textRange": rng,
959
+ "style": style, "fields": fields}}
960
+
961
+
962
+ def _bullets(bid, spans) -> list[dict]:
963
+ out = []
964
+ # Apply right-to-left: createParagraphBullets removes the leading tabs used
965
+ # for nesting, shrinking the text, which would shift later groups' indices.
966
+ for s, e, ordered in reversed(_merge(spans)):
967
+ preset = "NUMBERED_DIGIT_ALPHA_ROMAN" if ordered \
968
+ else "BULLET_DISC_CIRCLE_SQUARE"
969
+ out.append({"createParagraphBullets": {
970
+ "objectId": bid,
971
+ "textRange": {"type": "FIXED_RANGE", "startIndex": s, "endIndex": e},
972
+ "bulletPreset": preset,
973
+ }})
974
+ return out
975
+
976
+
977
+ def _merge(spans):
978
+ merged = []
979
+ for s, e, ordered in sorted(spans):
980
+ if merged and ordered == merged[-1][2] and s <= merged[-1][1] + 1:
981
+ merged[-1] = (merged[-1][0], e, ordered)
982
+ else:
983
+ merged.append((s, e, ordered))
984
+ return merged
985
+
986
+
987
+ def _alt_req(object_id: str, alt: str) -> dict:
988
+ """Set an image element's accessibility alt text (its description)."""
989
+ return {"updatePageElementAltText": {"objectId": object_id, "description": alt}}
990
+
991
+
992
+ def _image(slide, url, px) -> dict:
993
+ w, h = _fit(px)
994
+ return {"createImage": {
995
+ "objectId": slide.object_id + "_img", "url": url,
996
+ "elementProperties": {
997
+ "pageObjectId": slide.object_id,
998
+ "size": {"width": {"magnitude": w, "unit": "EMU"},
999
+ "height": {"magnitude": h, "unit": "EMU"}},
1000
+ "transform": {"scaleX": 1, "scaleY": 1, "unit": "EMU",
1001
+ "translateX": (SLIDE_W - w) // 2, "translateY": BODY_Y},
1002
+ },
1003
+ }}
1004
+
1005
+
1006
+ def _fit(px):
1007
+ if not px:
1008
+ return BODY_W, BODY_H
1009
+ w, h = px[0] * EMU_PER_PX, px[1] * EMU_PER_PX
1010
+ scale = min(BODY_W / w, BODY_H / h, 1.0)
1011
+ return int(w * scale), int(h * scale)
1012
+
1013
+
1014
+ def _table(slide) -> list[dict]:
1015
+ rows, cols = len(slide.table), len(slide.table[0])
1016
+ tid = slide.object_id + "_tbl"
1017
+ reqs = [{"createTable": {
1018
+ "objectId": tid,
1019
+ "elementProperties": {
1020
+ "pageObjectId": slide.object_id,
1021
+ "transform": {"scaleX": 1, "scaleY": 1, "unit": "EMU",
1022
+ "translateX": BODY_X, "translateY": BODY_Y},
1023
+ },
1024
+ "rows": rows, "columns": cols,
1025
+ }}]
1026
+ for r, row in enumerate(slide.table):
1027
+ for c, cell in enumerate(row):
1028
+ if cell:
1029
+ reqs.append({"insertText": {
1030
+ "objectId": tid, "text": cell,
1031
+ "cellLocation": {"rowIndex": r, "columnIndex": c}}})
1032
+ reqs.append(_font_all(tid, INK if r == 0 else BODY_INK,
1033
+ bold=(r == 0), cell=(r, c)))
1034
+ return reqs
1035
+
1036
+
1037
+ # ---------------------------------------------------------------------------
1038
+ # Push: diff + execute
1039
+ # ---------------------------------------------------------------------------
1040
+
1041
+
1042
+ def managed_slides(slides_api, deck, pres=None) -> dict[str, tuple[str, str]]:
1043
+ pres = pres or slides_api.presentations().get(presentationId=deck).execute()
1044
+ out = {}
1045
+ for s in pres.get("slides", []):
1046
+ if MANAGED_RE.match(s["objectId"]):
1047
+ _, kh, ch = s["objectId"].split("_")
1048
+ out[kh] = (s["objectId"], ch)
1049
+ return out
1050
+
1051
+
1052
+ def _template_index(slides_api, deck, pres=None) -> dict[str, str]:
1053
+ """name(lower) -> objectId for slides tagged `<!-- s2g:template NAME -->`."""
1054
+ pres = pres or slides_api.presentations().get(presentationId=deck).execute()
1055
+ out = {}
1056
+ for s in pres.get("slides", []):
1057
+ m = TEMPLATE_TAG_RE.search(_read_notes(s))
1058
+ if m:
1059
+ out[m.group("name").lower()] = s["objectId"]
1060
+ return out
1061
+
1062
+
1063
+ def _layout_map(slides_api, deck, pres=None) -> dict[str, dict]:
1064
+ """displayName(lower) -> {id, ph:[(type,index)]} for the deck's master layouts."""
1065
+ pres = pres or slides_api.presentations().get(presentationId=deck).execute()
1066
+ out = {}
1067
+ for lay in pres.get("layouts", []):
1068
+ name = lay.get("layoutProperties", {}).get("displayName")
1069
+ if not name:
1070
+ continue
1071
+ ph = [(el["shape"]["placeholder"].get("type"),
1072
+ el["shape"]["placeholder"].get("index", 0))
1073
+ for el in lay.get("pageElements", [])
1074
+ if el.get("shape", {}).get("placeholder")]
1075
+ out[name.lower()] = {"id": lay["objectId"], "ph": ph}
1076
+ return out
1077
+
1078
+
1079
+ def plan_sync(source: list[Slide], managed: dict, prune: bool, force: bool = False):
1080
+ creates, deletes, skips = [], [], []
1081
+ for s in source:
1082
+ if s.custom is not None:
1083
+ # Pull-authoritative: keep the live (hand-drawn) slide if it exists;
1084
+ # only (re)push when it's missing. Never clobbered, even with --force.
1085
+ (skips if s.key_hash in managed else creates).append(s)
1086
+ continue
1087
+ if s.key_hash in managed:
1088
+ old_id, old_ch = managed[s.key_hash]
1089
+ if old_ch == s.content_hash and not force:
1090
+ skips.append(s)
1091
+ else: # changed, or force-re-render
1092
+ creates.append(s)
1093
+ deletes.append(old_id)
1094
+ else:
1095
+ creates.append(s)
1096
+ keys = {s.key_hash for s in source}
1097
+ pruned = [oid for kh, (oid, _) in managed.items() if kh not in keys and prune]
1098
+ return creates, deletes, skips, pruned
1099
+
1100
+
1101
+ def push(slides_api, drive, deck, source, anchor, prune, base_dir=Path("."),
1102
+ force=False) -> dict:
1103
+ pres = slides_api.presentations().get(presentationId=deck).execute()
1104
+ managed = managed_slides(slides_api, deck, pres)
1105
+ creates, deletes, skips, pruned = plan_sync(source, managed, prune, force)
1106
+ if not (creates or deletes or pruned): # nothing changed — skip reorder/links/gets
1107
+ return {"create": 0, "skip": len(skips), "replace": 0, "prune": 0}
1108
+ layouts = _layout_map(slides_api, deck, pres)
1109
+ templates = _template_index(slides_api, deck, pres)
1110
+ reqs = [{"deleteObject": {"objectId": oid}} for oid in deletes + pruned]
1111
+ create_set = set(id(s) for s in creates)
1112
+ for s in source:
1113
+ if id(s) not in create_set:
1114
+ continue
1115
+ url, px = _resolve_image(drive, s, base_dir)
1116
+ reqs += slide_requests(s, url, px, layouts, templates)
1117
+ if reqs:
1118
+ _batch(slides_api, deck, reqs)
1119
+ _apply_notes(slides_api, deck, creates)
1120
+ _reorder(slides_api, deck, source, anchor)
1121
+ _apply_internal_links(slides_api, deck, source)
1122
+ return {"create": len(creates), "skip": len(skips),
1123
+ "replace": len(deletes), "prune": len(pruned)}
1124
+
1125
+
1126
+ # Templates with no body region, so they cannot host an internal-link run.
1127
+ NOBODY_TEMPLATES = {"dark", "title", "appendix", "graph", "full"}
1128
+
1129
+
1130
+ def _apply_internal_links(slides_api, deck, source) -> None:
1131
+ """Resolve `[text](#key)` body links to native Slides slide links.
1132
+
1133
+ Runs over ALL source slides on every push (not only created ones) so a link
1134
+ stays valid even when its *target* slide's content — hence objectId —
1135
+ changes while the linking slide is unchanged. Title links are dropped at
1136
+ parse time, so only body (`_b`) runs carry internal links.
1137
+ """
1138
+ key_to_oid = {s.key: s.object_id for s in source}
1139
+ reqs = []
1140
+ for s in source:
1141
+ if (s.template_name or "").lower() in NOBODY_TEMPLATES:
1142
+ _warn_orphan_links(s)
1143
+ continue
1144
+ bid = s.object_id + "_b"
1145
+ off = 0
1146
+ for p in s.paras:
1147
+ for r in p.runs:
1148
+ if r.style == "link" and r.link.startswith("#"):
1149
+ oid = key_to_oid.get(r.link[1:])
1150
+ if not oid:
1151
+ logger.warning(f"internal link {r.link} on '{s.key}' "
1152
+ "has no matching slide id")
1153
+ continue
1154
+ reqs.append({"updateTextStyle": {"objectId": bid,
1155
+ "textRange": {"type": "FIXED_RANGE",
1156
+ "startIndex": off + _u16(p.text[:r.start]),
1157
+ "endIndex": off + _u16(p.text[:r.end])},
1158
+ "style": _link_style({"pageObjectId": oid}),
1159
+ "fields": _LINK_FIELDS}})
1160
+ off += _u16(p.text) + 1
1161
+ if reqs:
1162
+ _batch(slides_api, deck, reqs)
1163
+
1164
+
1165
+ def _warn_orphan_links(slide: Slide) -> None:
1166
+ if any(r.style == "link" and r.link.startswith("#")
1167
+ for p in slide.paras for r in p.runs):
1168
+ logger.warning(f"internal link on '{slide.key}' ignored: template "
1169
+ f"'{slide.template_name}' has no body region")
1170
+
1171
+
1172
+ def _resolve_image(drive, slide: Slide, base_dir=Path(".")):
1173
+ if slide.layout != "image" or not slide.image:
1174
+ return None, None
1175
+ p = Path(slide.image)
1176
+ if not p.is_absolute():
1177
+ p = base_dir / p
1178
+ if not p.exists():
1179
+ logger.warning(f"image not found, graphic skipped: {p}")
1180
+ return None, None
1181
+ return upload_image(drive, p), png_size(p)
1182
+
1183
+
1184
+ def _batch(slides_api, deck, requests):
1185
+ slides_api.presentations().batchUpdate(
1186
+ presentationId=deck, body={"requests": requests}).execute()
1187
+
1188
+
1189
+ def _apply_notes(slides_api, deck, creates):
1190
+ want = {s.object_id: (s.notes + "\n\n\n" + _marker(s)).strip()
1191
+ for s in creates}
1192
+ if not want:
1193
+ return
1194
+ pres = slides_api.presentations().get(presentationId=deck).execute()
1195
+ reqs = []
1196
+ for s in pres.get("slides", []):
1197
+ if s["objectId"] not in want:
1198
+ continue
1199
+ nid = _notes_shape_id(s)
1200
+ if not nid:
1201
+ continue
1202
+ if _read_notes(s): # clear any notes inherited from a duplicated template
1203
+ reqs.append({"deleteText": {"objectId": nid,
1204
+ "textRange": {"type": "ALL"}}})
1205
+ reqs.append({"insertText": {"objectId": nid, "text": want[s["objectId"]]}})
1206
+ if reqs:
1207
+ _batch(slides_api, deck, reqs)
1208
+
1209
+
1210
+ def _notes_shape_id(slide):
1211
+ notes_page = slide.get("slideProperties", {}).get("notesPage", {})
1212
+ nid = notes_page.get("notesProperties", {}).get("speakerNotesObjectId")
1213
+ if nid:
1214
+ return nid
1215
+ for el in notes_page.get("pageElements", []):
1216
+ if el.get("shape", {}).get("placeholder", {}).get("type") == "BODY":
1217
+ return el["objectId"]
1218
+ return None
1219
+
1220
+
1221
+ def _reorder(slides_api, deck, source, anchor):
1222
+ pres = slides_api.presentations().get(presentationId=deck).execute()
1223
+ order = [s["objectId"] for s in pres.get("slides", [])]
1224
+ want = [s.object_id for s in source if s.object_id in order]
1225
+ if not want:
1226
+ return
1227
+ # Managed slides sit after any hand-built slides but BEFORE trailing
1228
+ # template (s2gtpl_) slides, which stay parked at the end.
1229
+ tpl_at = next((i for i, o in enumerate(order) if o.startswith("s2gtpl_")),
1230
+ len(order))
1231
+ if anchor and anchor in order:
1232
+ base = order.index(anchor) + 1
1233
+ else:
1234
+ base = sum(1 for o in order[:tpl_at] if not MANAGED_RE.match(o))
1235
+ # updateSlidesPosition can't reorder slides relative to each other, so move
1236
+ # one at a time into position (sequential requests act like insertion sort).
1237
+ reqs = [{"updateSlidesPosition": {"slideObjectIds": [oid],
1238
+ "insertionIndex": base + i}}
1239
+ for i, oid in enumerate(want)]
1240
+ _batch(slides_api, deck, reqs)
1241
+
1242
+
1243
+ # ---------------------------------------------------------------------------
1244
+ # Image hosting
1245
+ # ---------------------------------------------------------------------------
1246
+
1247
+
1248
+ def png_size(path: Path):
1249
+ head = path.read_bytes()[:24]
1250
+ if head[:8] == b"\x89PNG\r\n\x1a\n":
1251
+ return int.from_bytes(head[16:20], "big"), int.from_bytes(head[20:24], "big")
1252
+ return None
1253
+
1254
+
1255
+ def upload_image(drive, path: Path) -> str:
1256
+ cache = json.loads(IMAGE_CACHE.read_text()) if IMAGE_CACHE.exists() else {}
1257
+ digest = hashlib.sha1(path.read_bytes()).hexdigest()
1258
+ if digest in cache:
1259
+ return cache[digest]
1260
+ media = MediaFileUpload(str(path), mimetype="image/png")
1261
+ f = drive.files().create(body={"name": path.name}, media_body=media,
1262
+ fields="id").execute()
1263
+ drive.permissions().create(
1264
+ fileId=f["id"], body={"type": "anyone", "role": "reader"}).execute()
1265
+ url = f"https://drive.google.com/uc?export=download&id={f['id']}"
1266
+ cache[digest] = url
1267
+ IMAGE_CACHE.parent.mkdir(parents=True, exist_ok=True)
1268
+ IMAGE_CACHE.write_text(json.dumps(cache, indent=2))
1269
+ return url
1270
+
1271
+
1272
+ # ---------------------------------------------------------------------------
1273
+ # Pull: native objects -> Slide
1274
+ # ---------------------------------------------------------------------------
1275
+
1276
+
1277
+ def pull_slides(slides_api, deck, managed_only=True) -> list[Slide]:
1278
+ pres = slides_api.presentations().get(presentationId=deck).execute()
1279
+ out = []
1280
+ for s in pres.get("slides", []):
1281
+ if managed_only and not MANAGED_RE.match(s["objectId"]):
1282
+ continue
1283
+ out.append(_finalize(_slide_from_native(s)))
1284
+ return out
1285
+
1286
+
1287
+ def _el_y(el) -> float:
1288
+ return el.get("transform", {}).get("translateY", 0.0)
1289
+
1290
+
1291
+ def _el_x(el) -> float:
1292
+ return el.get("transform", {}).get("translateX", 0.0)
1293
+
1294
+
1295
+ def _first_font_pt(shape) -> float:
1296
+ for el in shape.get("text", {}).get("textElements", []):
1297
+ sz = (el.get("textRun") or {}).get("style", {}).get("fontSize", {})
1298
+ if sz.get("magnitude"):
1299
+ return sz["magnitude"]
1300
+ return 0.0
1301
+
1302
+
1303
+ def _slide_from_native(s) -> Slide:
1304
+ notes_raw = _read_notes(s)
1305
+ marker = _read_marker(notes_raw)
1306
+ notes = MARKER_RE.sub("", notes_raw).strip()
1307
+ if marker.get("template") == "custom":
1308
+ return _custom_slide_from_native(s, marker, notes)
1309
+ if marker.get("template"):
1310
+ return _slide_from_marker(marker, notes)
1311
+
1312
+ # Decks slidesync didn't author have no TITLE placeholder and often many
1313
+ # independent text boxes, so we can't assume one title + one body: collect
1314
+ # every non-empty text box, the first image, and any table.
1315
+ title_el, text_shapes, image_el, table = None, [], None, None
1316
+ for el in s.get("pageElements", []):
1317
+ if "table" in el:
1318
+ table = _table_from_native(el["table"])
1319
+ elif "image" in el:
1320
+ image_el = image_el or el
1321
+ elif el.get("shape", {}).get("text"):
1322
+ paras = _paras_from_shape(el["shape"])
1323
+ if not paras:
1324
+ continue
1325
+ ph = el["shape"].get("placeholder", {}).get("type")
1326
+ if ph in ("TITLE", "CENTERED_TITLE") and title_el is None:
1327
+ title_el = (el, paras)
1328
+ else:
1329
+ text_shapes.append((el, paras))
1330
+
1331
+ if title_el is None and text_shapes: # no placeholder: the biggest-font box is the title
1332
+ title_el = max(text_shapes,
1333
+ key=lambda t: (_first_font_pt(t[0]["shape"]), -_el_y(t[0])))
1334
+ text_shapes.remove(title_el)
1335
+ title = _flatten(title_el[1]).strip() if title_el else ""
1336
+
1337
+ text_shapes.sort(key=lambda t: (_el_y(t[0]), _el_x(t[0]))) # reading order
1338
+ body: list[Para] = []
1339
+ for _, paras in text_shapes:
1340
+ if body: # blank line between merged boxes
1341
+ body.append(Para("", [], -1))
1342
+ body.extend(paras)
1343
+
1344
+ image, image_alt = marker.get("img"), ""
1345
+ if image is None and image_el is not None: # foreign image: keep its live URL + alt
1346
+ image = image_el["image"].get("contentUrl") or image_el["image"].get("sourceUrl")
1347
+ image_alt = image_el.get("description") or image_el.get("title") or ""
1348
+
1349
+ layout = _infer_layout(body, image, table)
1350
+ key = marker.get("id") or _slug(title) or s["objectId"]
1351
+ slide = Slide(key, layout, title=title, paras=body, image=image,
1352
+ image_alt=image_alt, table=table, notes=notes)
1353
+ slide.layout_name = marker.get("tpl") or ("section" if layout == "section"
1354
+ else None)
1355
+ return slide
1356
+
1357
+
1358
+ def _custom_slide_from_native(s, marker: dict, notes: str) -> Slide:
1359
+ """Capture a hand-drawn slide's live elements into a ```gslides``` block.
1360
+
1361
+ The Google Slides copy is authoritative; this snapshot lets the slide be
1362
+ recreated if it is ever deleted. Geometry, text (with first-run style),
1363
+ shape fill/outline, images and lines are captured; richer styling is
1364
+ approximate (and irrelevant while the live slide exists, which is the norm).
1365
+ """
1366
+ reqs = _elements_to_requests(s.get("pageElements", []))
1367
+ slide = Slide(marker["id"], "custom", notes=notes)
1368
+ slide.template_name = "custom"
1369
+ slide.custom = json.dumps({"requests": reqs}, indent=2)
1370
+ return slide
1371
+
1372
+
1373
+ # Writable subfields copied verbatim from the get-response back into update
1374
+ # requests (the two schemas share these). Connections are intentionally dropped
1375
+ # (they reference sibling element ids we renumber) — see _line_prop_requests.
1376
+ # `shadow` and `autofit` carry read-only/computed subfields (fontScale,
1377
+ # lineSpacingReduction) the API refuses in an update mask, so they are not
1378
+ # captured — recreated slides inherit the defaults. The rest is writable and
1379
+ # copied verbatim from the get-response.
1380
+ _SHAPE_PROP_FIELDS = ("shapeBackgroundFill", "outline",
1381
+ "contentAlignment", "link")
1382
+ _LINE_PROP_FIELDS = ("lineFill", "weight", "dashStyle", "startArrow",
1383
+ "endArrow", "link")
1384
+ _IMAGE_PROP_FIELDS = ("cropProperties", "outline", "brightness",
1385
+ "contrast", "transparency", "recolor", "link")
1386
+ _TEXT_STYLE_FIELDS = ("bold", "italic", "underline", "strikethrough", "smallCaps",
1387
+ "backgroundColor", "foregroundColor", "weightedFontFamily",
1388
+ "fontFamily", "fontSize", "baselineOffset", "link")
1389
+ _PARA_STYLE_FIELDS = ("alignment", "lineSpacing", "direction", "spacingMode",
1390
+ "spaceAbove", "spaceBelow", "indentStart", "indentEnd",
1391
+ "indentFirstLine")
1392
+
1393
+
1394
+ def _present(obj: dict, fields: tuple[str, ...]) -> dict:
1395
+ return {k: obj[k] for k in fields if k in obj}
1396
+
1397
+
1398
+ def _update(req: str, eid: str, prop_key: str, props: dict) -> list[dict]:
1399
+ if not props:
1400
+ return []
1401
+ return [{req: {"objectId": eid, prop_key: props, "fields": ",".join(props)}}]
1402
+
1403
+
1404
+ def _elements_to_requests(elements: list[dict]) -> list[dict]:
1405
+ """Convert a slide's live page elements into faithful create+update requests.
1406
+
1407
+ Captures geometry, the full writable property set (fills, outline, shadow,
1408
+ crop, line weight/arrows/dash), and per-run + per-paragraph text styling, so
1409
+ `pull -> push -> pull` is a fixed point. Element connections and unsupported
1410
+ element kinds (groups, video, etc.) are dropped with a warning.
1411
+ """
1412
+ reqs: list[dict] = []
1413
+ for i, el in enumerate(elements):
1414
+ eid = f"__PAGE___el{i}"
1415
+ props = {"pageObjectId": "__PAGE__"}
1416
+ if el.get("size"):
1417
+ props["size"] = el["size"]
1418
+ if el.get("transform"):
1419
+ # get can omit a scale component; create needs both.
1420
+ props["transform"] = {"scaleX": 1, "scaleY": 1, **el["transform"]}
1421
+ if "shape" in el:
1422
+ sh = el["shape"]
1423
+ reqs.append({"createShape": {"objectId": eid,
1424
+ "shapeType": sh.get("shapeType", "TEXT_BOX"),
1425
+ "elementProperties": props}})
1426
+ reqs += _text_requests(eid, sh.get("text", {}))
1427
+ reqs += _update("updateShapeProperties", eid, "shapeProperties",
1428
+ _present(sh.get("shapeProperties", {}), _SHAPE_PROP_FIELDS))
1429
+ elif "image" in el:
1430
+ url = el["image"].get("contentUrl") or el["image"].get("sourceUrl")
1431
+ if not url:
1432
+ logger.warning(f"custom pull: image {eid} has no URL; skipped")
1433
+ continue
1434
+ reqs.append({"createImage": {"objectId": eid, "url": url,
1435
+ "elementProperties": props}})
1436
+ reqs += _update("updateImageProperties", eid, "imageProperties",
1437
+ _present(el["image"].get("imageProperties", {}),
1438
+ _IMAGE_PROP_FIELDS))
1439
+ elif "line" in el:
1440
+ reqs.append({"createLine": {"objectId": eid,
1441
+ "category": el["line"].get("lineCategory", "STRAIGHT"),
1442
+ "elementProperties": props}})
1443
+ reqs += _line_prop_requests(eid, el["line"])
1444
+ else:
1445
+ logger.warning(f"custom pull: unsupported element {list(el)}; skipped")
1446
+ return reqs
1447
+
1448
+
1449
+ def _line_prop_requests(eid: str, line: dict) -> list[dict]:
1450
+ return _update("updateLineProperties", eid, "lineProperties",
1451
+ _present(line.get("lineProperties", {}), _LINE_PROP_FIELDS))
1452
+
1453
+
1454
+ def _text_requests(eid: str, text: dict) -> list[dict]:
1455
+ """Reconstruct shape text exactly: content, per-paragraph and per-run styles."""
1456
+ els = text.get("textElements", [])
1457
+ content = "".join((te.get("textRun") or {}).get("content", "") for te in els)
1458
+ body = content.rstrip("\n") # shapes carry an implicit final paragraph
1459
+ if not body:
1460
+ return []
1461
+ total = _u16(body)
1462
+ reqs = [{"insertText": {"objectId": eid, "text": body}}]
1463
+
1464
+ def clamp(te): # range intersected with the inserted text
1465
+ s, e = te.get("startIndex", 0), te.get("endIndex", te.get("startIndex", 0) + 1)
1466
+ return s, min(e, total)
1467
+
1468
+ for te in els: # paragraph styles + bullets
1469
+ pm = te.get("paragraphMarker")
1470
+ if not pm:
1471
+ continue
1472
+ s, e = clamp(te)
1473
+ if s >= e:
1474
+ continue
1475
+ rng = {"type": "FIXED_RANGE", "startIndex": s, "endIndex": e}
1476
+ ps = _present(pm.get("style", {}), _PARA_STYLE_FIELDS)
1477
+ if ps:
1478
+ reqs.append({"updateParagraphStyle": {"objectId": eid, "textRange": rng,
1479
+ "style": ps, "fields": ",".join(ps)}})
1480
+ if pm.get("bullet"):
1481
+ reqs.append({"createParagraphBullets": {"objectId": eid, "textRange": rng,
1482
+ "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE"}})
1483
+ for te in els: # run styles
1484
+ tr = te.get("textRun")
1485
+ if not tr:
1486
+ continue
1487
+ s, e = clamp(te)
1488
+ st = _present(tr.get("style", {}), _TEXT_STYLE_FIELDS)
1489
+ if s < e and st:
1490
+ reqs.append({"updateTextStyle": {"objectId": eid,
1491
+ "textRange": {"type": "FIXED_RANGE", "startIndex": s, "endIndex": e},
1492
+ "style": st, "fields": ",".join(st)}})
1493
+ return reqs
1494
+
1495
+
1496
+ def _slide_from_marker(marker: dict, notes: str) -> Slide:
1497
+ """Reconstruct a tagged-template slide from its notes marker (source of truth)."""
1498
+ _headings, paras, _, _, _, _ = parse_body(marker.get("body", ""))
1499
+ img = marker.get("img")
1500
+ slide = Slide(marker["id"], "image" if img else "content",
1501
+ marker.get("h1", ""), paras, image=img, notes=notes)
1502
+ slide.image_alt = marker.get("alt", "")
1503
+ slide.kicker = marker.get("h2", "")
1504
+ slide.template_name = marker["template"]
1505
+ slide.vars = marker.get("vars", {})
1506
+ return slide
1507
+
1508
+
1509
+ def _infer_layout(paras, image, table):
1510
+ if image:
1511
+ return "image"
1512
+ if table:
1513
+ return "table"
1514
+ if not paras:
1515
+ return "section"
1516
+ return "content"
1517
+
1518
+
1519
+ def _flatten(paras: list[Para]) -> str:
1520
+ return " ".join(p.text for p in paras)
1521
+
1522
+
1523
+ def _paras_from_shape(shape) -> list[Para]:
1524
+ elements = shape.get("text", {}).get("textElements", [])
1525
+ paras, cur, depth, base = [], None, -1, 0
1526
+ for el in elements:
1527
+ if "paragraphMarker" in el:
1528
+ if cur is not None:
1529
+ paras.append(_finish_para(cur, depth))
1530
+ bullet = el["paragraphMarker"].get("bullet")
1531
+ depth = bullet.get("nestingLevel", 0) if bullet is not None else -1
1532
+ cur, base = {"text": "", "runs": []}, 0
1533
+ elif "textRun" in el and cur is not None:
1534
+ base = _consume_run(el["textRun"], cur, base)
1535
+ if cur is not None:
1536
+ paras.append(_finish_para(cur, depth))
1537
+ while paras and not paras[0].text and not paras[0].runs:
1538
+ paras.pop(0)
1539
+ while paras and not paras[-1].text and not paras[-1].runs:
1540
+ paras.pop()
1541
+ return paras # keep internal blank paragraphs for spacing
1542
+
1543
+
1544
+ def _consume_run(tr, cur, base) -> int:
1545
+ content = tr.get("content", "").replace("\n", "")
1546
+ style = tr.get("style", {})
1547
+ name = _style_name(style)
1548
+ start = base
1549
+ cur["text"] += content
1550
+ if name:
1551
+ link = style.get("link", {}).get("url") if name == "link" else None
1552
+ cur["runs"].append(Run(start, start + len(content), name, link))
1553
+ return start + len(content)
1554
+
1555
+
1556
+ def _style_name(style) -> str | None:
1557
+ if style.get("link", {}).get("url"):
1558
+ return "link"
1559
+ if "Mono" in style.get("fontFamily", ""):
1560
+ return "code"
1561
+ if style.get("bold"):
1562
+ return "bold"
1563
+ if style.get("italic"):
1564
+ return "italic"
1565
+ return None
1566
+
1567
+
1568
+ def _coalesce_runs(runs: list[Run]) -> list[Run]:
1569
+ """Merge adjacent same-style runs — Google often splits one styled span into
1570
+ several textRuns, which would otherwise render as `**a****b**`."""
1571
+ merged: list[Run] = []
1572
+ for r in runs:
1573
+ prev = merged[-1] if merged else None
1574
+ if prev and prev.style == r.style and prev.link == r.link and prev.end == r.start:
1575
+ merged[-1] = Run(prev.start, r.end, r.style, r.link)
1576
+ else:
1577
+ merged.append(r)
1578
+ return merged
1579
+
1580
+
1581
+ def _finish_para(cur, depth) -> Para:
1582
+ text = cur["text"].lstrip("\t")
1583
+ shift = len(cur["text"]) - len(text)
1584
+ runs = [Run(r.start - shift, r.end - shift, r.style, r.link) for r in cur["runs"]]
1585
+ return Para(text, _coalesce_runs([r for r in runs if r.end > r.start]), depth)
1586
+
1587
+
1588
+ def _table_from_native(table) -> list[list[str]]:
1589
+ rows = []
1590
+ for row in table.get("tableRows", []):
1591
+ cells = []
1592
+ for cell in row.get("tableCells", []):
1593
+ cells.append(_flatten(_paras_from_shape(cell)))
1594
+ rows.append(cells)
1595
+ return rows
1596
+
1597
+
1598
+ def _read_notes(s) -> str:
1599
+ notes_page = s.get("slideProperties", {}).get("notesPage", {})
1600
+ nid = notes_page.get("notesProperties", {}).get("speakerNotesObjectId")
1601
+ for el in notes_page.get("pageElements", []):
1602
+ if el.get("objectId") == nid or \
1603
+ el.get("shape", {}).get("placeholder", {}).get("type") == "BODY":
1604
+ return _flatten(_paras_from_shape(el.get("shape", {})))
1605
+ return ""
1606
+
1607
+
1608
+ def _read_marker(notes: str) -> dict:
1609
+ m = MARKER_RE.search(notes)
1610
+ return json.loads(m.group("json")) if m else {}
1611
+
1612
+
1613
+ def write_slidev(slides: list[Slide], path: Path):
1614
+ fm = ["theme: seriph"]
1615
+ if path.exists():
1616
+ deck = frontmatter.loads(path.read_text()).metadata.get("deck")
1617
+ if deck:
1618
+ fm.append(f"deck: {deck}")
1619
+ body = "\n---\n".join(to_slidev(s) for s in slides)
1620
+ path.write_text("---\n" + "\n".join(fm) + "\n---\n\n" + body)
1621
+
1622
+
1623
+ # ---------------------------------------------------------------------------
1624
+ # Branded templates (match the Reliable Monitors deck)
1625
+ # ---------------------------------------------------------------------------
1626
+
1627
+
1628
+ def _emu(inches: float) -> int:
1629
+ return int(inches * EMU_PER_IN)
1630
+
1631
+
1632
+ def _text_box(slide_id, box_id, box, text, size, rgb, bold, valign=None,
1633
+ halign="CENTER") -> list[dict]:
1634
+ x, y, w, h = box
1635
+ reqs = [
1636
+ {"createShape": {"objectId": box_id, "shapeType": "TEXT_BOX",
1637
+ "elementProperties": {"pageObjectId": slide_id,
1638
+ "size": {"width": {"magnitude": _emu(w), "unit": "EMU"},
1639
+ "height": {"magnitude": _emu(h), "unit": "EMU"}},
1640
+ "transform": {"scaleX": 1, "scaleY": 1, "translateX": _emu(x),
1641
+ "translateY": _emu(y), "unit": "EMU"}}}},
1642
+ {"insertText": {"objectId": box_id, "text": text}},
1643
+ {"updateTextStyle": {"objectId": box_id, "textRange": {"type": "ALL"},
1644
+ "style": {"fontFamily": BRAND_FONT, "bold": bold,
1645
+ "fontSize": {"magnitude": size, "unit": "PT"},
1646
+ "foregroundColor": {"opaqueColor": {"rgbColor": rgb}}},
1647
+ "fields": "fontFamily,bold,fontSize,foregroundColor"}},
1648
+ {"updateParagraphStyle": {"objectId": box_id, "textRange": {"type": "ALL"},
1649
+ "style": {"alignment": halign}, "fields": "alignment"}},
1650
+ ]
1651
+ if valign:
1652
+ reqs.append({"updateShapeProperties": {"objectId": box_id,
1653
+ "shapeProperties": {"contentAlignment": valign},
1654
+ "fields": "contentAlignment"}})
1655
+ return reqs
1656
+
1657
+
1658
+ def _bg(slide_id, rgb) -> dict:
1659
+ return {"updatePageProperties": {"objectId": slide_id,
1660
+ "pageProperties": {"pageBackgroundFill": {"solidFill": {
1661
+ "color": {"rgbColor": rgb}}}},
1662
+ "fields": "pageBackgroundFill.solidFill.color"}}
1663
+
1664
+
1665
+ def _branded_template(name, bg, headline_rgb, body_rgb, hsize, with_body) -> list[dict]:
1666
+ """Centered kicker + headline (+ body) template, matching the deck's style."""
1667
+ sid = f"s2gtpl_{name}"
1668
+ reqs = [{"createSlide": {"objectId": sid,
1669
+ "slideLayoutReference": {"predefinedLayout": "BLANK"}}},
1670
+ _bg(sid, bg)]
1671
+ reqs += _text_box(sid, sid + "_k", (0.34, 1.6, 9.32, 0.5),
1672
+ "{{h2}}", 17, RED, False)
1673
+ reqs += _text_box(sid, sid + "_h", (0.34, 2.05, 9.32, 1.7),
1674
+ "{{h1}}", hsize, headline_rgb, True, valign="MIDDLE")
1675
+ if with_body:
1676
+ reqs += _text_box(sid, sid + "_b", (0.34, 3.85, 9.32, 1.4),
1677
+ "{{body}}", 24, body_rgb, False)
1678
+ return reqs
1679
+
1680
+
1681
+ # (name, background, headline colour, body colour, headline pt, has-body)
1682
+ TEMPLATE_SPECS = [
1683
+ ("label", LIGHT_BG, INK, BODY_INK, 50, True),
1684
+ ("dark", DARK_BG, PAPER, PAPER, 72, False),
1685
+ ]
1686
+
1687
+
1688
+ def make_templates(slides_api, deck):
1689
+ reqs = []
1690
+ for spec in TEMPLATE_SPECS:
1691
+ reqs += _branded_template(*spec)
1692
+ _batch(slides_api, deck, reqs)
1693
+ names = [s[0] for s in TEMPLATE_SPECS]
1694
+ pres = slides_api.presentations().get(presentationId=deck).execute()
1695
+ tag = []
1696
+ for s in pres.get("slides", []):
1697
+ if s["objectId"] in {f"s2gtpl_{n}" for n in names}:
1698
+ nid = _notes_shape_id(s)
1699
+ if nid:
1700
+ tag.append({"insertText": {"objectId": nid,
1701
+ "text": f"<!-- s2g:template {s['objectId'][7:]} -->"}})
1702
+ if tag:
1703
+ _batch(slides_api, deck, tag)
1704
+ _hide_templates(slides_api, deck, [f"s2gtpl_{n}" for n in names])
1705
+ return names
1706
+
1707
+
1708
+ def _hide_templates(slides_api, deck, ids):
1709
+ """Skip template slides in the slideshow (best effort)."""
1710
+ pres = slides_api.presentations().get(
1711
+ presentationId=deck, fields="slides.objectId").execute()
1712
+ present = {s["objectId"] for s in pres.get("slides", [])} & set(ids)
1713
+ if not present:
1714
+ return
1715
+ try:
1716
+ _batch(slides_api, deck, [{"updateSlideProperties": {
1717
+ "objectId": i, "slideProperties": {"isSkipped": True},
1718
+ "fields": "isSkipped"}} for i in present])
1719
+ except Exception as e: # noqa: BLE001 - API may not expose isSkipped
1720
+ logger.warning(f"could not mark templates skipped: {e}")
1721
+
1722
+
1723
+ # ---------------------------------------------------------------------------
1724
+ # Commands
1725
+ # ---------------------------------------------------------------------------
1726
+
1727
+ SAMPLE = """---
1728
+ theme: seriph
1729
+ ---
1730
+
1731
+ ---
1732
+ layout: section
1733
+ id: intro
1734
+ ---
1735
+
1736
+ # Round-trip check
1737
+
1738
+ <!-- opening remarks for the section -->
1739
+
1740
+ ---
1741
+ id: findings
1742
+ ---
1743
+
1744
+ ## Key findings
1745
+
1746
+ - First **bold** point
1747
+ - nested with `code`
1748
+ - nested with *italic*
1749
+ - Second point with a [link](https://example.com)
1750
+
1751
+ <!-- talk through each finding -->
1752
+
1753
+ ---
1754
+ id: data
1755
+ ---
1756
+
1757
+ ## The numbers
1758
+
1759
+ | Metric | Value |
1760
+ | --- | --- |
1761
+ | AUROC | 0.93 |
1762
+ | Gap | small |
1763
+
1764
+ ---
1765
+ template: label
1766
+ id: ask
1767
+ ---
1768
+
1769
+ ## QUESTION
1770
+
1771
+ # What should we prioritise?
1772
+
1773
+ - summarisation first
1774
+ - collusion is characterise-only
1775
+
1776
+ <!-- the ask -->
1777
+
1778
+ ---
1779
+ template: dark
1780
+ id: titlecard
1781
+ ---
1782
+
1783
+ ## MEETING
1784
+
1785
+ # 2026/06/01
1786
+ """
1787
+
1788
+
1789
+ def new_deck(slides_api, title: str) -> str:
1790
+ """Create a deck and remove the default blank slide Google inserts."""
1791
+ deck = slides_api.presentations().create(
1792
+ body={"title": title}).execute()["presentationId"]
1793
+ pres = slides_api.presentations().get(presentationId=deck).execute()
1794
+ reqs = [{"deleteObject": {"objectId": s["objectId"]}}
1795
+ for s in pres.get("slides", [])]
1796
+ if reqs:
1797
+ _batch(slides_api, deck, reqs)
1798
+ return deck
1799
+
1800
+
1801
+ DECK_ID_RE = re.compile(r"/presentation/d/(?P<id>[A-Za-z0-9_-]+)")
1802
+
1803
+
1804
+ def deck_from_source(path: Path) -> str | None:
1805
+ """Read the target deck id from the file's top-level `deck:` frontmatter."""
1806
+ val = frontmatter.loads(path.read_text()).metadata.get("deck")
1807
+ if not val:
1808
+ return None
1809
+ m = DECK_ID_RE.search(str(val))
1810
+ return m.group("id") if m else str(val)
1811
+
1812
+
1813
+ def cmd_push(args):
1814
+ source = load_slides(args.source)
1815
+ logger.info(f"parsed {len(source)} slides from {args.source}")
1816
+ slides_api, drive = get_services(args.account)
1817
+ deck = args.deck or deck_from_source(args.source)
1818
+ if args.new:
1819
+ deck = new_deck(slides_api, args.new)
1820
+ logger.info(f"created https://docs.google.com/presentation/d/{deck}/edit")
1821
+ if not deck:
1822
+ sys.exit("no target deck: pass --deck/--new or add `deck:` frontmatter")
1823
+ stats = push(slides_api, drive, deck, source, args.anchor, args.prune,
1824
+ base_dir=args.source.parent, force=args.force)
1825
+ logger.success(f"{stats} -> https://docs.google.com/presentation/d/{deck}/edit")
1826
+
1827
+
1828
+ def cmd_pull(args):
1829
+ slides_api, _ = get_services(args.account)
1830
+ slides = pull_slides(slides_api, args.deck, managed_only=not args.all)
1831
+ write_slidev(slides, args.out)
1832
+ logger.success(f"pulled {len(slides)} slides -> {args.out}")
1833
+
1834
+
1835
+ def cmd_make_templates(args):
1836
+ slides_api, _ = get_services(args.account)
1837
+ names = make_templates(slides_api, args.deck)
1838
+ logger.success(f"created templates {names} in "
1839
+ f"https://docs.google.com/presentation/d/{args.deck}/edit")
1840
+
1841
+
1842
+ def cmd_layouts(args):
1843
+ slides_api, _ = get_services(args.account)
1844
+ pres = slides_api.presentations().get(presentationId=args.deck).execute()
1845
+ for lay in pres.get("layouts", []):
1846
+ name = lay.get("layoutProperties", {}).get("displayName", "?")
1847
+ phs = []
1848
+ for el in lay.get("pageElements", []):
1849
+ ph = el.get("shape", {}).get("placeholder")
1850
+ if ph:
1851
+ phs.append(f"{ph.get('type')}[{ph.get('index', 0)}]")
1852
+ logger.info(f"{name:<24} {', '.join(phs) or '(no placeholders)'}")
1853
+
1854
+
1855
+ def _loop_hop(slides_api, drive, title, slides):
1856
+ """One md->slides hop: build a deck, push, pull back."""
1857
+ deck = new_deck(slides_api, title)
1858
+ push(slides_api, drive, deck, slides, anchor=None, prune=False)
1859
+ return deck, pull_slides(slides_api, deck)
1860
+
1861
+
1862
+ def cmd_roundtrip(args):
1863
+ slides_api, drive = get_services(args.account)
1864
+ src = build_slides(split_slides(SAMPLE))
1865
+ # md -> slides -> md -> slides
1866
+ deck_a, got_a = _loop_hop(slides_api, drive, "slidesync roundtrip A", src)
1867
+ deck_b, got_b = _loop_hop(slides_api, drive, "slidesync roundtrip B", got_a)
1868
+ logger.info(f"hop A https://docs.google.com/presentation/d/{deck_a}/edit")
1869
+ logger.info(f"hop B https://docs.google.com/presentation/d/{deck_b}/edit")
1870
+ ok = _compare(src, got_a) and _compare(got_a, got_b)
1871
+ if not args.keep:
1872
+ drive.files().delete(fileId=deck_a).execute()
1873
+ drive.files().delete(fileId=deck_b).execute()
1874
+ logger.info("scratch decks deleted")
1875
+ logger.log("SUCCESS" if ok else "ERROR",
1876
+ "loop stable" if ok else "loop DIVERGED")
1877
+ sys.exit(0 if ok else 1)
1878
+
1879
+
1880
+ def _compare(src: list[Slide], got: list[Slide]) -> bool:
1881
+ if len(src) != len(got):
1882
+ logger.error(f"slide count {len(src)} != {len(got)}")
1883
+ return False
1884
+ ok = True
1885
+ for a, b in zip(src, got):
1886
+ if a.semantic() == b.semantic():
1887
+ logger.success(f" [match] {a.key}")
1888
+ continue
1889
+ ok = False
1890
+ logger.error(f" [DIFF] {a.key}")
1891
+ for fa, fb, name in zip(a.semantic(), b.semantic(),
1892
+ ["key", "layout_name", "template_name", "vars",
1893
+ "layout", "title", "kicker", "paras", "table",
1894
+ "image", "notes"]):
1895
+ if fa != fb:
1896
+ logger.error(f" {name}: {fa!r} != {fb!r}")
1897
+ logger.log("SUCCESS" if ok else "ERROR",
1898
+ "round-trip PASS" if ok else "round-trip FAIL")
1899
+ return ok
1900
+
1901
+
1902
+ def main():
1903
+ ap = argparse.ArgumentParser(description=__doc__)
1904
+ ap.add_argument("--account", default=DEFAULT_ACCOUNT)
1905
+ sub = ap.add_subparsers(dest="cmd", required=True)
1906
+
1907
+ p = sub.add_parser("push")
1908
+ p.add_argument("source", type=Path)
1909
+ p.add_argument("--deck")
1910
+ p.add_argument("--new")
1911
+ p.add_argument("--anchor")
1912
+ p.add_argument("--prune", action="store_true")
1913
+ p.add_argument("--force", action="store_true",
1914
+ help="re-render all slides, ignoring the skip optimisation")
1915
+ p.set_defaults(func=cmd_push)
1916
+
1917
+ p = sub.add_parser("pull")
1918
+ p.add_argument("deck")
1919
+ p.add_argument("--out", type=Path, required=True)
1920
+ p.add_argument("--all", action="store_true", help="export non-managed slides too")
1921
+ p.set_defaults(func=cmd_pull)
1922
+
1923
+ p = sub.add_parser("roundtrip")
1924
+ p.add_argument("--keep", action="store_true", help="keep the scratch deck")
1925
+ p.set_defaults(func=cmd_roundtrip)
1926
+
1927
+ p = sub.add_parser("layouts", help="list a deck's master layouts + placeholders")
1928
+ p.add_argument("deck")
1929
+ p.set_defaults(func=cmd_layouts)
1930
+
1931
+ p = sub.add_parser("make-templates",
1932
+ help="add branded tagged template slides to a deck")
1933
+ p.add_argument("deck")
1934
+ p.set_defaults(func=cmd_make_templates)
1935
+
1936
+ args = ap.parse_args()
1937
+ args.func(args)
1938
+
1939
+
1940
+ if __name__ == "__main__":
1941
+ main()