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/__init__.py +48 -0
- slidesync/_sync.py +1941 -0
- slidesync-0.1.0.dist-info/METADATA +137 -0
- slidesync-0.1.0.dist-info/RECORD +8 -0
- slidesync-0.1.0.dist-info/WHEEL +5 -0
- slidesync-0.1.0.dist-info/entry_points.txt +2 -0
- slidesync-0.1.0.dist-info/licenses/LICENSE +21 -0
- slidesync-0.1.0.dist-info/top_level.txt +1 -0
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 = "" #  -> 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"")
|
|
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()
|