--- name: slides-and-citations description: Build, revise or update a "Slides and Citations" companion Word document from a PowerPoint deck — turning a .pptx into a single-column .docx of floating slide images with citations, links and copyable prompts beside them. Use whenever the user says "make the slides and citations doc for this deck", "convert this ppt to a citations doc", "add citations to this deck", "revise the citations doc", "align the doc with the deck", "the doc doesn't match the slides anymore", "add the new slides to the doc", or "re-render the slide images" — and for any work at all on a file named "… Slides and Citations.docx", since a one-slide fix still has to honour the layout, type spec and citation rules encoded here. --- # Slides & Citations Companion Documents Turn a workshop or keynote `.pptx` into its companion Word document: every slide as a floating image with its citations, links and prompts beside it. The slide is the cue; this document is the record participants keep, copy from and click. This skill was written for a specific presenter's house style and then generalised. It runs out of the box and will produce a usable document with no editing at all. But the parts that make it *yours* — your fonts, your colours, your citation flags, where your files live — are marked **CUSTOMIZE** below. Change those and it becomes your skill rather than someone else's. --- ## ▶ CUSTOMIZE FIRST — the eight things that are not yours yet Work through these before the first run. Everything else can stay as written. **1. Where your decks and documents live.** Replace the placeholders throughout: ``` DECK_FOLDER = [YOUR PRESENTATIONS FOLDER] DOC_FOLDER = [YOUR PRESENTATIONS FOLDER] # usually the same SCRATCH = [A TEMPORARY WORKING FOLDER] # not inside the folder above ``` **2. Your ground-truth example.** The single most valuable customization, and the one people skip. Do not describe your style in prose — **show the AI a document you already made and like**, and let it extract the rules: > Here are two or three finished documents of the kind I want. Read them and write down > every formatting rule you can infer: fonts, sizes, colours, spacing, how images are > placed, how citations are punctuated, what always appears first and last, what never > appears at all. Where you see me doing something inconsistently, tell me and ask which > version I meant. Then rewrite the CUSTOMIZE section of this skill to match. Then add a standing instruction so the skill keeps re-grounding itself: > Before you start, glob `[YOUR FOLDER]/*Slides and Citations*.docx`, sort by modification > time, and read the newest one. I edit these by hand and my most recent edit is always the > current house style. Filenames drift — I save v2, v3 as I revise — so treat any list of > filenames as *which documents to expect*, not as literal paths. **3. Your type specification.** The table under **Type specification** below is a working default, not a recommendation. Replace the two accent colours, the font, and the sizes with your own. Keep the *structure* of the table — one row per element — because that is what makes the rules enforceable at the end. **4. Your header and end blocks.** The generic version writes a title, a subtitle, your name and your website. Replace with your own book links, discount codes, workshop URL, and the two composite images (`hdr/header.png`, `hdr/endstack.png`). Both can be lifted out of any finished document: unzip it and take the first and last images in `word/media/`. **5. Your standing citation flags.** See **Citation discipline** below. This is where you list the papers in *your* field that need permanent handling — retracted work, famous-but- fragile studies, quotes with no traceable original. Yours will not be the same as anyone else's, and the list needs a review date on it. **6. Your evidence tiers.** The bracketed tags — `[preprint]`, `[quasi-experimental, n=62]` — should match how your discipline actually sorts evidence. A historian's ladder is not an epidemiologist's. **7. Your fonts, if the deck uses one the renderer does not have.** See **Font substitution** under Gotchas. Name your deck font and a metrically similar installed substitute. **8. Your delivery rule.** The default is: deliver the `.docx` only, save under a new version number, never overwrite a file that might be open. Change if you work differently. --- ## Workflow 1. Render the deck to slide PNGs (scripts below). Extract QR codes, slide-relationship URLs and speaker notes. 2. Dump the deck's text so you can see what is on each slide. 3. Mine the **previous version** of this document, if one exists, and the most recently revised sibling document, for citations already verified. Overlap between decks is heavy — reuse, don't re-derive. 4. Write a `content_map.py` (one entry per slide) and run the builder. 5. Deliver the **.docx only**. PDF conversion is a separate, later request. 6. Save as a new filename (`… v2.docx`) rather than overwriting a document that may be open. Do not offer layout options. The style is settled before the run begins; that is the whole point of writing it down. ### Revising an existing document Most requests are revisions, not first builds: the deck gained slides, a citation was wrong, the document and the deck have drifted apart. Resist the temptation to hand-patch the `.docx` — the floating-image anchors and wrap breaks are generated as a set, and editing one entry in place tends to break the flow two pages later. Instead, recover or rebuild the content map, change the entries that need changing, and re-run the builder. **The document is an artifact of the map; the map is the thing you edit.** When aligning a document to a revised deck, diff the two first: which slide numbers are new, which shifted, which disappeared. Renumbering is the usual failure — an entry that still points at `n=34` after a slide was inserted at 20 will silently show the wrong image, and nothing in the build will complain. Check the rendered PNG for a few entries against what the map says the slide contains before delivering. --- ## What goes in the document **Skip the first slide.** Its content becomes the header block. **Skip the last slide.** Its content becomes the end block. **Signpost slides** (the "Research Findings / Design Principles / Examples" chapter markers) become large accent-coloured section headers, not images. **Activity slides** ("YOUR TURN", "TRY THIS") become text only, no image, with the prompt spelled out in full so participants can copy it into a chat window. **Pure-text slides** (a summary list, TURN & TALK) become text only, no image. **Video slides** become a short title plus one labeled link: `▸ Video embedded in this slide (YouTube): the url`. **Prompt slides keep their image** and carry the full prompt as copyable text underneath. The slide usually shows an abbreviated version, so the full template is additive, not a repeat. Label the copyable text `Complete Prompt:` in the accent colour immediately above it. **Drop strict-subset animation builds** — a slide whose content is contained entirely in the next one. Keep recap slides that differ, even slightly; check before deleting. ## Hard content rules - **Never repeat what is legible on the slide.** A label beside an image exists to add context the slide does not carry (the n, the setting, who built it, what happened next), or to name a category ("Example Assignment"). If the label just restates the slide, cut it. - **No slide numbers** except on text blocks — `TURN & TALK (slide 43)` in 9-pt grey — and on prompt blocks, where the number helps the presenter find it in the deck. - **Every QR code on a slide becomes a live link here.** That division of labour is the point: scan on the slide, click in the document. - **Every product named on a slide gets a link.** If you cannot verify a URL, leave it out and say which ones you skipped. Never guess a URL. - **Prompt templates always appear in full**, even long ones. They are the reason the document exists. --- ## Type specification **CUSTOMIZE — these are defaults, not recommendations.** Letter page, margins 0.6" top/bottom, 0.75" left/right → **7.0" usable width**. One font family throughout. Single spacing, zero space before/after on body paragraphs, so you control whitespace yourself rather than fighting Word's defaults. | Element | Size | Colour | Weight | |---|---|---|---| | Document title (1–2 lines) | 24 | `ACCENT1` | bold | | SLIDES & CITATIONS | 16 | `ACCENT2` | bold | | Presenter name | 13 | default | bold | | Header-block body + links | 12 | default / link blue | — | | Section header | 22 | `ACCENT1` | bold | | CITATIONS & RESOURCES | 18 | `ACCENT1` | bold | | Text-block title | 15 | `ACCENT1` | bold | | `(slide N)` after a title | 9 | `444444` | — | | Prompt-block title | 12 | `ACCENT1` | bold | | `Complete Prompt:` label | 12 | `ACCENT1` | bold | | Prompt body (copyable) | 12 | `212121` | — | | Sub-prompt label | 11 | `ACCENT1` | bold | | Slide label / explanation | 11 | `212121` | — | | Link line: `▸ ` + label | 11 | `ACCENT1` + `0563C1` underlined | ▸ bold | | Citation | 9 | `444444` | — | | Citation URL | 9 | `0563C1` underlined | — | | LIMITS caption | 9 | `444444` | **bold** | | Footer | 9 | `444444` | — | Defaults to replace: `ACCENT1` = `BF4E14`, `ACCENT2` = `984806`, font = Calibri. **Nothing smaller than 9 pt, and nothing between 9 and 11 pt.** 9 pt is reserved for citations, LIMITS blocks and the `(slide N)` marker. Everything else is 11 pt or larger. This rule exists so the document stays readable when printed, and because a checkable numeric rule survives handoff in a way that "keep it legible" does not. Footer, centered: `Presenter Name · Document Title · yoursite.com · PAGE field` --- ## Layout Single column. Slide images are **floating** with **wrap = Square** (Word: Layout → Wrap Text → Square) so text flows beside them and any image can be dragged where you want it. - Slide **with** citations/links/label: image floated left at **3.7"**, text beside it. - Two consecutive slides with **nothing** beside them: pair them, 3.32" each, left and right. - Emit a `textWrapping` break with `clear="all"` before each entry so entries stack predictably. Suppress the break if one was just emitted, to avoid double gaps. - Target roughly 4 slides per page. Slides with long citation lists take more room; bare slides pair up. Do not fight this — it self-balances. - Set `positionH` with an explicit `posOffset` (0 for left, `6400800 - width` EMU for right), not a `wp:align` element. This is what Word itself writes, and LibreOffice packs it more predictably. --- ## Citation discipline - **Reuse verified citations** from the most recently revised sibling document. Those have already been corrected by hand; re-deriving them reintroduces errors. - **Never invent a DOI.** If you cannot verify one, give the citation without a URL and bracket it `[DOI not verified]`. A plausible-looking wrong DOI is worse than none. - **Evidence tiers travel in square brackets**: `[working paper]`, `[preprint]`, `[quasi-experimental, n=62]`, `[vendor telemetry]`, `[practitioner case study, not an evaluation]`, `[framework paper, not empirical]`, `[RETRACTED — never cite as evidence]`. **CUSTOMIZE** these tags to your discipline's evidence ladder. - **Vendor and attitude surveys are legitimate** for what they measure. Describe them (`[attitude survey; industry-commissioned]`, `[vendor detection data]`) rather than editorialising about their weakness. - **LIMITS blocks** carry the caveat in 9-pt bold grey followed by their supporting citations. ### Standing flags — CUSTOMIZE, and date the list Every field has a handful of papers that need permanent, consistent handling: work that has been retracted, work that is cited far more than its design can bear, and quotes that circulate without a traceable original. List yours here so the handling never has to be re-argued, and so a reader can see the judgment rather than absorb it invisibly. Write each one as: **the item — the status — what to do with it.** For example: ``` - [Author, Year meta-analysis] — RETRACTED [date]. Never cite as evidence. If the finding still matters to the argument, pair it with [the bias-adjusted reanalysis]. - [Author, Year] — famous-but-fragile: cited far beyond what the sample and design support. Cite as a cultural phenomenon, not as evidence, and say so on the slide. - [The "..." quote] — no traceable original source. Mark it "attributed" or drop it. Flags last reviewed: [DATE]. Re-check before any deck is delivered. ``` Two cautions worth keeping. First, a standing flag is a *judgment*, not a fact; it belongs in a document you sign. Label it as your reading and give the reader the citation to check. Second, an undated flag list rots — a paper gets corrected, a critique is answered, a retraction is reversed — and a stale flag propagates silently into every document the skill builds. Put a date on it and honour it. --- ## Rendering pipeline Large decks and short shell timeouts mean this must be a chunked, resumable pipeline. Work in a scratch directory, not the source folder. ### Gotchas that will bite you - **Font substitution.** If the deck uses a font the renderer does not have, LibreOffice substitutes something much wider and every slide overflows. Replace the deck font with a metrically similar installed one in all XML parts before rendering. **CUSTOMIZE:** name your deck font and its substitute in `repack.py`. - **LibreOffice hangs** on a stale profile lock. `pkill -9 soffice` first and pass a fresh `-env:UserInstallation=file:///tmp/unique-name` on every run. - **pdftoppm** on a long deck exceeds the timeout; render in batches of ~40 pages. - **Resize the PNGs to 1110 px max** before embedding, or the `.docx` balloons past 30 MB. - **QR decoding needs two passes**: the whole image upscaled 2×, plus a 4×4 grid of overlapping tiles upscaled 3×. Union the results — neither pass alone finds them all. - Also read `ppt/slides/_rels/*.rels` for `Target="http…"` and the speaker notes; several URLs appear only there. ### Script 1 — shrink oversized media (resumable; run until `remaining 0`) ```python # shrink.py — run as: python3 shrink.py 35 (35 = seconds of budget) import zipfile, io, os, sys, time from PIL import Image Image.MAX_IMAGE_PIXELS = None SRC, CACHE, MAX = 'deck.pptx', 'mediacache', 1400 os.makedirs(CACHE, exist_ok=True) BUDGET = float(sys.argv[1]) if len(sys.argv) > 1 else 30.0 t0 = time.time(); z = zipfile.ZipFile(SRC) todo = [i for i in z.infolist() if i.filename.lower().startswith('ppt/media/') and i.filename.lower().endswith(('.png', '.jpg', '.jpeg')) and i.file_size > 300_000] todo.sort(key=lambda i: i.file_size) done = 0 for item in todo: key = os.path.join(CACHE, item.filename.split('/')[-1]) if os.path.exists(key): continue if time.time() - t0 > BUDGET: break data = z.read(item.filename); fn = item.filename.lower() try: im = Image.open(io.BytesIO(data)); w, h = im.size if max(w, h) > MAX: r = MAX / max(w, h) im = im.resize((max(1, int(w * r)), max(1, int(h * r))), Image.LANCZOS) buf = io.BytesIO() if fn.endswith('.png'): if im.mode not in ('RGBA', 'RGB', 'P', 'LA'): im = im.convert('RGBA') im.save(buf, 'PNG', optimize=True) else: if im.mode != 'RGB': im = im.convert('RGB') im.save(buf, 'JPEG', quality=85, optimize=True) nd = buf.getvalue() if len(nd) >= len(data): nd = data except Exception as e: print('skip', item.filename, e); nd = data open(key, 'wb').write(nd); done += 1 rem = sum(1 for i in todo if not os.path.exists(os.path.join(CACHE, i.filename.split('/')[-1]))) print(f'processed {done}, remaining {rem} of {len(todo)}') ``` ### Script 2 — repack with the font swap, then render ```python # repack.py import zipfile, os DECK_FONT = 'Your Deck Font' # CUSTOMIZE SUBSTITUTE = 'Lato' # CUSTOMIZE — an installed, metrically similar font src = zipfile.ZipFile('deck.pptx') out = zipfile.ZipFile('deck_small.pptx', 'w', zipfile.ZIP_DEFLATED, compresslevel=3) for item in src.infolist(): c = os.path.join('mediacache', item.filename.split('/')[-1]) data = open(c, 'rb').read() if (item.filename.lower().startswith('ppt/media/') and os.path.exists(c)) else src.read(item.filename) if item.filename.lower().endswith('.xml'): t = data.decode('utf-8') if DECK_FONT in t: data = t.replace(DECK_FONT, SUBSTITUTE).encode('utf-8') out.writestr(item.filename, data) out.close() print(round(os.path.getsize('deck_small.pptx') / 1e6, 1), 'MB') ``` ```bash pkill -9 soffice; rm -rf dpdf /tmp/lp1 soffice -env:UserInstallation=file:///tmp/lp1 --headless --norestore \ --convert-to pdf deck_small.pptx --outdir dpdf mkdir -p sl && pdftoppm -r 100 -f 1 -l 40 -png dpdf/deck_small.pdf sl/s pdftoppm -r 100 -f 41 -l 999 -png dpdf/deck_small.pdf sl/s python3 -c " from PIL import Image; import glob, os os.makedirs('sl2', exist_ok=True) for f in sorted(glob.glob('sl/*.png')): im = Image.open(f).convert('RGB'); im.thumbnail((1110,1110), Image.LANCZOS) im.save('sl2/' + os.path.basename(f), 'PNG', optimize=True)" ``` ### Script 3 — decode QR codes ```python import cv2, glob, re d = cv2.QRCodeDetector(); found = {} for f in sorted(glob.glob('sl/*.png')): n = int(re.search(r's-(\d+)', f).group(1)) img = cv2.imread(f); H, W = img.shape[:2]; vals = set() try: ok, dec, _, _ = d.detectAndDecodeMulti( cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)) vals |= {v for v in (dec or []) if v} except Exception: pass for gy in range(4): for gx in range(4): y0, y1 = int(H*gy/4*0.9), min(H, int(H*(gy+1.4)/4)) x0, x1 = int(W*gx/4*0.9), min(W, int(W*(gx+1.4)/4)) try: ok, dec, _, _ = d.detectAndDecodeMulti( cv2.resize(img[y0:y1, x0:x1], None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)) vals |= {v for v in (dec or []) if v} except Exception: pass if vals: found[n] = sorted(vals) for n in sorted(found): print(n, found[n]) ``` --- ## The content map One Python file per deck. Entry kinds: ```python SECTIONS = [ ("header", "RESEARCH FINDINGS"), ("slide", dict(n=17)), # bare — will pair with a neighbour ("slide", dict(n=24, label="context the slide doesn't carry", links=[("Label", "https://example.org")], cites=[("APA citation", "https://doi.org/10.0000/x"), ("Book with no URL.", None)], limits=("LIMITS. caveat", [("supporting cite", "https://example.org")]))), ("text", dict(title="TURN & TALK", n=43, noimage=True, body=["one line"])), ("text", dict(title="STAGE 1 · MOVE TO THE DESKTOP", n=23, body=["steps"], links=[("label", "https://example.org")])), ("prompt", dict(title="Example: AI Literacy Programs", n=30, intro=["one line of setup"], body=["the copyable prompt"], # gets a 'Complete Prompt:' label labelled=[("Map the Landscape", "prompt text")], after=["closing caution"], links=[], cites=[])), ] ``` `text` and `prompt` blocks show the slide image by default; pass `noimage=True` to suppress it. --- ## The builder ```python # -*- coding: utf-8 -*- import os, copy from docx import Document from docx.shared import Pt, Inches, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn from docx.oxml import OxmlElement from content_map import SECTIONS # ---- CUSTOMIZE: identity and style --------------------------------------- ACCENT1, ACCENT2 = RGBColor(0xBF,0x4E,0x14), RGBColor(0x98,0x48,0x06) GREY, BLUE, DARK = RGBColor(0x44,0x44,0x44), RGBColor(0x05,0x63,0xC1), RGBColor(0x21,0x21,0x21) FONT = 'Calibri' PRESENTER = 'Your Name' SITE_LABEL, SITE_URL = 'YourSite.com', 'https://yoursite.com/' MATERIALS_LABEL, MATERIALS_URL = 'Workshop Prompts & Materials', 'https://yoursite.com/workshop/' TITLE_L1, TITLE_L2 = 'DECK TITLE', 'Subtitle' # one line: set TITLE_L2 = None FOOTER_TITLE = 'Deck Title Subtitle' OUT = 'Deck Title Slides and Citations.docx' IMG_DIR = 'sl2' # -------------------------------------------------------------------------- doc = Document(); sec = doc.sections[0] sec.top_margin = sec.bottom_margin = Inches(0.6) sec.left_margin = sec.right_margin = Inches(0.75) st = doc.styles['Normal']; st.font.name = FONT; st.font.size = Pt(11) st.paragraph_format.space_before = st.paragraph_format.space_after = Pt(0) st.paragraph_format.line_spacing = 1.0 _rpr = st.element.get_or_add_rPr() _rf = _rpr.find(qn('w:rFonts')) if _rf is None: _rf = OxmlElement('w:rFonts'); _rpr.append(_rf) for a in ('w:ascii','w:hAnsi','w:cs'): _rf.set(qn(a), FONT) def run(p, text, size=11, color=None, bold=False): r = p.add_run(text); r.font.name = FONT; r.font.size = Pt(size); r.bold = bold if color is not None: r.font.color.rgb = color return r def para(space_before=0, space_after=0, align=None, keep=False): p = doc.add_paragraph(); pf = p.paragraph_format pf.space_before, pf.space_after, pf.line_spacing = Pt(space_before), Pt(space_after), 1.0 if align is not None: p.alignment = align if keep: pf.keep_with_next = True return p def hyperlink(p, text, url, size=11, color=BLUE): rid = p.part.relate_to(url, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', is_external=True) h = OxmlElement('w:hyperlink'); h.set(qn('r:id'), rid) r = OxmlElement('w:r'); pr = OxmlElement('w:rPr') f = OxmlElement('w:rFonts') for a in ('w:ascii','w:hAnsi','w:cs'): f.set(qn(a), FONT) pr.append(f) for tag in ('w:sz','w:szCs'): e = OxmlElement(tag); e.set(qn('w:val'), str(int(size*2))); pr.append(e) c = OxmlElement('w:color'); c.set(qn('w:val'), '%02X%02X%02X' % (color[0],color[1],color[2])); pr.append(c) u = OxmlElement('w:u'); u.set(qn('w:val'), 'single'); pr.append(u) r.append(pr) t = OxmlElement('w:t'); t.set(qn('xml:space'), 'preserve'); t.text = text r.append(t); h.append(r); p._p.append(h) def citation(text, url, size=9, bold=False): p = para(space_after=1) run(p, text + (' ' if url else ''), size=size, color=GREY, bold=bold) if url: hyperlink(p, url, url, size=size) def linkline(label, url, size=11): p = para(space_after=1) run(p, '▸ ', size=size, color=ACCENT1, bold=True) hyperlink(p, label, url, size=size) _Z = [1000] def float_picture(path, width, side='left'): """Floating picture, wrap = SQUARE.""" p = para(space_after=0); p.add_run().add_picture(path, width=width) inline = p._p.find(qn('w:r')).find(qn('w:drawing')).find(qn('wp:inline')) a = OxmlElement('wp:anchor') for k, v in (('distT','0'),('distB','91440'),('distL','114300'),('distR','114300'), ('simplePos','0'),('relativeHeight',str(_Z[0])),('behindDoc','0'), ('locked','0'),('layoutInCell','1'),('allowOverlap','0')): a.set(k, v) _Z[0] += 1 sp = OxmlElement('wp:simplePos'); sp.set('x','0'); sp.set('y','0'); a.append(sp) ph = OxmlElement('wp:positionH'); ph.set('relativeFrom','column') po = OxmlElement('wp:posOffset') po.text = str(0 if side == 'left' else max(0, 6400800 - int(width))) ph.append(po); a.append(ph) pv = OxmlElement('wp:positionV'); pv.set('relativeFrom','paragraph') off = OxmlElement('wp:posOffset'); off.text = '0'; pv.append(off); a.append(pv) for tag in ('wp:extent','wp:effectExtent'): el = inline.find(qn(tag)) if el is not None: a.append(copy.deepcopy(el)) wr = OxmlElement('wp:wrapSquare'); wr.set('wrapText','bothSides'); a.append(wr) for tag in ('wp:docPr','wp:cNvGraphicFramePr'): el = inline.find(qn(tag)) if el is not None: a.append(copy.deepcopy(el)) a.append(copy.deepcopy(inline.find(qn('a:graphic')))) d = inline.getparent(); d.remove(inline); d.append(a) _CLEARED = [False] def clear_wrap(space_after=6, force=False): if _CLEARED[0] and not force: return _CLEARED[0] = True p = para(space_after=0) r = OxmlElement('w:r'); br = OxmlElement('w:br') br.set(qn('w:type'),'textWrapping'); br.set(qn('w:clear'),'all') r.append(br); p._p.append(r); p.paragraph_format.space_after = Pt(space_after) def _dirty(): _CLEARED[0] = False # ---- header block (CUSTOMIZE the contents, keep the shape) float_picture('hdr/header.png', Inches(3.05), side='right') p = para(); run(p, TITLE_L1, size=24, color=ACCENT1, bold=True) if TITLE_L2: p = para(); run(p, TITLE_L2, size=24, color=ACCENT1, bold=True) p = para(space_before=2); run(p, 'SLIDES & CITATIONS', size=16, color=ACCENT2, bold=True) p = para(); run(p, PRESENTER, size=13, bold=True) para() p = para(); run(p, 'WebSite: ', size=12) hyperlink(p, SITE_LABEL, SITE_URL, size=12) linkline(MATERIALS_LABEL, MATERIALS_URL, size=12) clear_wrap() IMG_SOLO, IMG_PAIR = Inches(3.7), Inches(3.32) def has_side_text(d): return bool(d.get('label') or d.get('links') or d.get('cites')) def add_header(text): clear_wrap(); p = para(space_before=12, space_after=4) run(p, text, size=22, color=ACCENT1, bold=True); _dirty() def side_text(d): if d.get('label'): p = para(space_after=3); run(p, d['label'], size=11, color=DARK) for label, url in d.get('links', []): linkline(label, url) if d.get('cites'): if d.get('label') or d.get('links'): para(space_after=1) for t, u in d['cites']: citation(t, u) if d.get('limits'): cap, cites = d['limits']; para(space_after=1); citation(cap, None, bold=True) for t, u in cites: citation(t, u) def add_pair(a, b): clear_wrap() float_picture(f'{IMG_DIR}/s-{a["n"]:02d}.png', IMG_PAIR, side='left') if b is not None: float_picture(f'{IMG_DIR}/s-{b["n"]:02d}.png', IMG_PAIR, side='right') _dirty() def add_solo(d): clear_wrap() float_picture(f'{IMG_DIR}/s-{d["n"]:02d}.png', IMG_SOLO, side='left') side_text(d); _dirty() def add_text_block(d): clear_wrap() if d.get('n') and not d.get('noimage'): float_picture(f'{IMG_DIR}/s-{d["n"]:02d}.png', IMG_SOLO, side='left') p = para(space_before=8, space_after=4, keep=True) run(p, d['title'], size=15, color=ACCENT1, bold=True) if d.get('n'): run(p, f' (slide {d["n"]})', size=9, color=GREY) for i, line in enumerate(d.get('body', [])): p = para(space_after=3, keep=(i == 0)); run(p, line, size=11, color=DARK) for label, url in d.get('links', []): linkline(label, url) if d.get('limits'): cap, cites = d['limits']; para(space_after=1); citation(cap, None, bold=True) for t, u in cites: citation(t, u) for t, u in d.get('cites', []): citation(t, u) _dirty() def add_prompt(d): clear_wrap() if d.get('n') and not d.get('noimage'): float_picture(f'{IMG_DIR}/s-{d["n"]:02d}.png', IMG_SOLO, side='left') p = para(space_before=8, space_after=4, keep=True) run(p, d['title'], size=12, color=ACCENT1, bold=True) for line in d.get('intro', []): p = para(space_after=3); run(p, line, size=11, color=DARK) if d.get('body'): p = para(space_before=3, space_after=2, keep=True) run(p, d.get('promptlabel', 'Complete Prompt:'), size=12, color=ACCENT1, bold=True) for line in d.get('body', []): p = para(space_after=3); run(p, line, size=12, color=DARK) for lbl, txt in d.get('labelled', []): p = para(space_before=3, space_after=1, keep=True) run(p, lbl, size=11, color=ACCENT1, bold=True) p = para(space_after=3); run(p, txt, size=12, color=DARK) for line in d.get('after', []): p = para(space_before=3, space_after=3); run(p, line, size=11, color=DARK) if d.get('links'): para(space_after=1) for label, url in d['links']: linkline(label, url) if d.get('limits'): cap, cites = d['limits']; para(space_after=1); citation(cap, None, bold=True) for t, u in cites: citation(t, u) for t, u in d.get('cites', []): citation(t, u) _dirty() i = 0 while i < len(SECTIONS): kind, payload = SECTIONS[i] if kind == 'header': add_header(payload) elif kind == 'text': add_text_block(payload) elif kind == 'prompt': add_prompt(payload) else: if not has_side_text(payload): nxt = SECTIONS[i+1] if i+1 < len(SECTIONS) else None if nxt and nxt[0] == 'slide' and not has_side_text(nxt[1]): add_pair(payload, nxt[1]); i += 2; continue add_pair(payload, None) else: add_solo(payload) i += 1 # ---- end block (CUSTOMIZE: your books, links, offers) clear_wrap(); _dirty() p = para(space_before=12, space_after=6, keep=True) run(p, 'CITATIONS & RESOURCES', size=18, color=ACCENT1, bold=True) float_picture('hdr/endstack.png', Inches(1.25), side='left') p = para(space_after=3); run(p, '▸ ', size=12, color=ACCENT1, bold=True) hyperlink(p, SITE_LABEL, SITE_URL, size=12) run(p, ' contains many more resources, prompts, assignments and links.', size=12) clear_wrap(force=True) fp = sec.footer.paragraphs[0]; fp.alignment = WD_ALIGN_PARAGRAPH.CENTER run(fp, f'{PRESENTER} · {FOOTER_TITLE} · {SITE_LABEL} · ', size=9, color=GREY) _fld = OxmlElement('w:fldSimple'); _fld.set(qn('w:instr'), 'PAGE'); fp._p.append(_fld) doc.save(OUT) print('saved', OUT, round(os.path.getsize(OUT)/1e6, 1), 'MB') ``` --- ## Verify before delivering ```python import zipfile, re z = zipfile.ZipFile(OUT); doc = z.read('word/document.xml').decode('utf8') rels = z.read('word/_rels/document.xml.rels').decode('utf8') print('external links :', len(re.findall(r'TargetMode="External"', rels))) print('wrapSquare :', doc.count('wrapSquare'), ' inline (must be 0):', doc.count('wp:inline')) print('font sizes pt :', sorted({int(x)/2 for x in re.findall(r'w:sz w:val="(\d+)"', doc)})) ``` - Font sizes must contain **no value below 9 and none between 9 and 11**. - Inline images must be **0** — every picture floats. - Confirm every slide number from 2 to n−1 appears exactly once in the content map. - Render to PDF *for your own inspection only* and read a few pages to check flow. The point of this block is that the style rules above are not advice, they are testable. If a rule in your customized version cannot be checked by a script or by eye in ten seconds, it will drift, and you will not notice until a participant does. ## Reporting back Short. What the document contains (pages, slides covered, live-link count), then the two or three judgment calls to check — a rule you bent and why, a citation you could not verify, a place where the evidence cuts against what the slide claims. A summary of what was asked for is not useful; the disagreements are.