$ cat node-template.py

S

Slide Generator

// Generates a single presentation slide from text content and an instruction. Connect multiple Slide Generator outputs to a Presentation Composer to build a full presentation.

Process
Document
template.py
1import sys2import json3import re4import base645import mimetypes6import traceback7from html import escape as html_escape, unescape as html_unescape8from html.parser import HTMLParser910from gais import Gais1112SYSTEM_PROMPT = '''You are a presentation slide designer. Your task is to generate exactly ONE Reveal.js slide as an HTML <section> element.1314RULES:151. Output ONLY a single <section>...</section> element. No <!DOCTYPE>, no <html>, no <head>, no wrapper.162. Use <h2> for the slide title (not <h1>).173. Use semantic HTML: <ul>/<ol> for lists, <p> for text, <table> for data, <blockquote> for quotes.184. Use class="fragment" on elements that should appear one-by-one during the presentation.195. Use <aside class="notes">...</aside> for speaker notes (always include brief notes).206. Do NOT set data-background-color on <section>. The presentation theme controls all colors — never override it.217. Keep content concise — slides should be scannable, not walls of text.228. Do NOT include any CSS <style> blocks or <script> tags.239. Do NOT set color, background-color, or any color-related inline styles. Let the Reveal.js theme handle all colors.2410. Only use inline styles for LAYOUT purposes (display:flex, display:grid, gap, font-size). Never for colors or backgrounds.2511. Use plain HTML that Reveal.js themes will style automatically.2612. THEME × TEMPLATE COUPLING: each slide layout template in this system is theme-specific. marina-* templates (marina-intro, marina-content, marina-metrics, marina-timeline, marina-comparison, marina-outro) require the 'marina' Reveal theme and use Marina-only classes (.title-slide, .closing-slide, data-header-title, .card-grid, .tag.commercial, etc.). emblema-* templates (emblema-cover, emblema-agenda, emblema-section, …, emblema-closing) require the 'emblema' theme and use Emblema-only classes (.slide-cover, .slide-section, .slide-pad, .slide-bullets, etc.). NEVER emit a marina-* slide structure when the deck theme is emblema (the cover and closing collapse to unstyled white pages because emblema CSS does not define .title-slide / .closing-slide). NEVER emit an emblema-* slide structure when the deck theme is marina. If you are asked to produce a cover or closing for an emblema deck, use emblema-cover and emblema-closing — not marina-intro / marina-outro.2728IMAGE PLACEHOLDERS:29When a slide layout requires images, use picsum.photos for royalty-free placeholder images:30- Landscape: <img src="https://picsum.photos/seed/{topic}/600/400" alt="description">31- Portrait: <img src="https://picsum.photos/seed/{topic}/400/600" alt="description">32- Square: <img src="https://picsum.photos/seed/{topic}/400/400" alt="description">33Replace {topic} with a short keyword related to the slide content (e.g. "analytics", "team", "growth").34Always include a descriptive alt attribute. Set width via style if needed for layout.3536LAYOUT PATTERNS you can use:37- Title slide: <h2> + <p> subtitle38- Bullet points: <h2> + <ul> with <li class="fragment">39- Two columns: <div style="display:flex;gap:2em"> with two <div> children40- Data table: <h2> + <table>41- Quote: <blockquote> + attribution42- Image + text: <h2> + <div style="display:flex;gap:2em"> with <img> and text side by side4344Always output valid HTML. The content should be based ONLY on the provided source material — do not invent facts or data.'''4546# Layout instructions for each slide template47TEMPLATE_INSTRUCTIONS = {48    # ─── Marina Militare layouts ───49    "marina-intro": "[THEME GATE: this template is valid ONLY when the active deck theme is 'marina'. If the active theme is 'emblema', DO NOT emit this structure — switch to the 'emblema-cover' template instead, which is the Emblema brand cover. The Marina .title-slide class is unstyled in the Emblema theme and would render as a blank white slide.] "50        "Create a Marina Militare INTRO/COVER slide. The <section> MUST start with class='title-slide' — WITHOUT THIS CLASS, the Marina Militare crest will NOT render and the Graduate uppercase title font will NOT apply. The slide will collapse to a plain navy background with default text. Use this EXACT structure:\n"51        "<section class='title-slide'>\n"52        "  <h1>OPERATION OR BRIEFING TITLE</h1>\n"53        "  <p class='subtitle'>Subtitle line — short, two lines maximum</p>\n"54        "  <p class='meta'>Mission ID &nbsp;|&nbsp; Unit &nbsp;|&nbsp; Date &nbsp;|&nbsp; Classification</p>\n"55        "  <aside class='notes'>Cover slide — set the audience and the mission.</aside>\n"56        "</section>\n"57        "The Marina Militare crest is injected by the theme via a CSS pseudo-element above the title — do NOT add an <img> for the logo. The <h1> renders in uppercase Graduate font and tolerates a <br> mid-line for a two-line title. Keep the subtitle to one or two lines using <p class='subtitle'>. Use <p class='meta'> for the small operational metadata (mission ID, unit, date, classification). Keep all class names EXACTLY as shown — class='title-slide' on the section is LOAD-BEARING.",5859    "marina-content": "Create a Marina Militare CONTENT slide. This is the marina theme's GENERAL-PURPOSE / FREE-FORM content layout — use it for any textual, discursive, narrative or free-structure content, and whenever the user asks for a 'content' / 'testuale' / 'free-form' / 'libera' slide. The ONLY fixed parts are the <section data-header-title='...'> wrapper and the <h2> heading; everything below the <h2> is HTML you choose to fit the content. "60        "CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source → Italian heading). Extract the heading from the actual source content — do NOT invent generic English dashboard phrases. BANNED headings that you MUST NOT use as <h2>: 'Overview', 'Summary', 'Key Points', 'Main Content', 'Content Summary', 'Information', 'Details'. These are hallucinations from other templates and look unprofessional. Good examples for an Italian Navy briefing: 'Identificazione Missione', 'Assetto della Piattaforma', 'Quadro Operativo Generale'. "61        "The <section> MUST have a data-header-title='SHORT TITLE' attribute — WITHOUT THIS ATTRIBUTE, the top header bar with the Marina Militare logo and uppercase title will NOT render and the slide will look unbranded. After the <h2> you may put any mix of: prose <p> paragraphs, <ul>/<ol> bullet lists, sub-<h3> headings, a <table> for genuinely tabular data (fully theme-styled — navy header row, gold column headers, bordered rows; you do NOT need to switch to marina-comparison for a small table inside a content slide), an optional <p class='section-label'> small subtitle, and/or the marina .card-grid / .card blocks for numeric/KPI/dashboard data — in any order. Do NOT add background-color or color inline styles — the theme owns them. "62        "ALIGNMENT: the outer <section> MUST carry class='marina-content' in addition to data-header-title='SHORT TITLE' — that class is the CSS hook the theme uses to flush body content left. Inside that section everything below the header bar is LEFT-aligned by the theme — the <h2>, every <h3>, all <p> paragraphs, all <ul>/<ol> lists, and every <table>. The one EXCEPTION is <p class='section-label'>: it is the uppercase brand subtitle and the theme keeps it centered — leave it alone, don't try to left-align it. The theme owns alignment, so do NOT add style='text-align:center', style='text-align:left', a <center> tag, an align='center' attribute, or a centered wrapper <div> around any body element — just emit clean HTML and the CSS handles it. (Centered slide bodies belong only to the separate marina-intro and marina-outro layouts, which use different classes — never here.) "63        "EXAMPLE — free-form / discursive content (the default for narrative text):\n"64        "<section class='marina-content' data-header-title='SHORT SLIDE TITLE'>\n"65        "  <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n"66        "  <p class='section-label'>Optional small label / subtitle</p>\n"67        "  <h3>Optional sub-heading</h3>\n"68        "  <p>A paragraph of discursive content drawn from the source.</p>\n"69        "  <p>Another paragraph.</p>\n"70        "  <ul>\n"71        "    <li>A bullet point.</li>\n"72        "    <li>Another bullet point.</li>\n"73        "  </ul>\n"74        "  <aside class='notes'>Speaker notes.</aside>\n"75        "</section>\n"76        "EXAMPLE — data / KPI content (use cards when the slide is a set of numbers or capability readings):\n"77        "<section class='marina-content' data-header-title='SHORT SLIDE TITLE'>\n"78        "  <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n"79        "  <p class='section-label'>Optional small label / subtitle</p>\n"80        "  <div class='card-grid cols-3'>\n"81        "    <div class='card'><div class='label'>LABEL</div><div class='value gold'>VALUE</div><div class='detail'>Detail</div></div>\n"82        "    <!-- ...more cards... -->\n"83        "  </div>\n"84        "  <aside class='notes'>Speaker notes.</aside>\n"85        "</section>\n"86        "The data-header-title attribute on <section> drives the fixed header bar — the theme injects the Marina Militare logo on the left and this title text in uppercase on the right. The <h2> is the slide body heading. CHOOSING THE BODY: when the slide content is numeric / KPI / capability-reading / dashboard-shaped, PREFER a <div class='card-grid cols-2|cols-3|cols-4'> with <div class='card'> children over a plain <ul> — cards are the marina theme's signature visual for data. When the slide content is narrative / discursive, prose paragraphs, sub-headings and bullet lists are the right choice — that is NOT 'unbranded', it is exactly what this layout is for. "87        "WHEN YOU DO USE CARDS: each card contains <div class='label'> (uppercase blue), <div class='value'> (large, MUST add a color modifier class: .gold for highlights, .cyan for cool readings, .red for alerts, .green for OK status), and optional <div class='detail'>. "88        "CRITICAL CARD STATE COUPLING RULE (applies only when you use cards): the color modifier on <div class='value'> MUST be mirrored by a matching class on the parent <div class='card'>: "89        "<div class='value red'> (alert/threat/critical) → parent MUST be <div class='card alert'> (red border + red glow + red-tinted background + red label); "90        "<div class='value gold'> (the single most important metric) → parent MUST be <div class='card highlight'> (gold border + gold label); "91        "<div class='value cyan'> or <div class='value green'> → parent stays as plain <div class='card'> (no parent modifier). "92        "Do NOT emit <div class='card'><div class='value red'>...</div></div> — a red value inside a blue card looks inconsistent and unprofessional. Always pair .value.red with .card.alert and .value.gold with .card.highlight. "93        "Anti-pattern to AVOID: forcing a metrics / KPI dashboard (a set of numbers or capability readings) into a plain <ul> instead of a .card-grid — use cards there. (A <ul> for genuine narrative bullet points, or prose paragraphs for discursive content, is correct in a content slide — do not 'card-ify' everything.)",9495    "marina-metrics": "Create a Marina Militare METRICS / CAPABILITY DASHBOARD slide. "96        "CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's metric set, written in the SAME LANGUAGE as the source document. Extract the heading from the actual source content — do NOT invent generic English dashboard phrases. BANNED headings that you MUST NOT use as <h2>: 'Capability Snapshot', 'Key Metrics', 'Performance Overview', 'KPI Dashboard', 'Metrics Dashboard', 'Operational Metrics', 'Overview', 'Summary', 'Snapshot', 'Performance Dashboard'. These are hallucinations from other dashboard templates and look unprofessional in a naval briefing. Good examples for an Italian Navy briefing: 'ITS Margottini — Snapshot T+12 h', 'Assetto Piattaforma al T+24 h', 'Meteo e Condizioni del Mare', 'Readiness e Asset Operativi'. "97        "The <section> MUST have data-header-title='SHORT TITLE' AND the body MUST contain a <div class='card-grid cols-N'> with at least 3 <div class='card'> children — WITHOUT THE CARD GRID, this layout has no visual and degrades to plain text. Each card's <div class='value'> MUST have a color modifier class (.gold, .cyan, .red, or .green) — without it, values render in plain white and lose all semantic meaning. Use this EXACT structure:\n"98        "<section data-header-title='SHORT SLIDE TITLE'>\n"99        "  <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n"100        "  <p class='section-label'>Optional small label (e.g. timestamp or unit)</p>\n"101        "  <div class='card-grid cols-3'>\n"102        "    <div class='card'>\n"103        "      <div class='label'>METRIC LABEL</div>\n"104        "      <div class='value cyan'>VALUE</div>\n"105        "      <div class='detail'>Brief detail</div>\n"106        "    </div>\n"107        "    <!-- Repeat for 3-6 metric cards -->\n"108        "  </div>\n"109        "  <div class='metrics-callout highlight'>  <!-- OPTIONAL: include only if the source content has operational interpretation of the metrics -->\n"110        "    <div class='label'>IMPATTO OPERATIVO</div>\n"111        "    <p>Short editorial paragraph interpreting the metrics. Use <strong>bold</strong> on the most important phrases to highlight them in gold.</p>\n"112        "  </div>\n"113        "  <aside class='notes'>Speaker notes.</aside>\n"114        "</section>\n"115        "Produce 3-6 capability cards in a grid row (cols-2 / cols-3 / cols-4 depending on count). Each card MUST contain: <div class='label'> (uppercase blue), <div class='value COLOR'> with one of {gold|cyan|red|green} (gold for highlights, cyan for cool/neutral readings, red for alerts/threats, green for OK/operational), and <div class='detail'> for a short subtitle. Vary the colors based on the meaning of each value — do NOT make every card the same color. "116        "CRITICAL CARD STATE COUPLING RULE: the color modifier on <div class='value'> MUST be mirrored by a matching class on the parent <div class='card'>: "117        "<div class='value red'> (alert/threat/critical) → parent MUST be <div class='card alert'> (red border + red glow + red-tinted background + red label); "118        "<div class='value gold'> (the single most important metric) → parent MUST be <div class='card highlight'> (gold border + gold label); "119        "<div class='value cyan'> or <div class='value green'> → parent stays as plain <div class='card'> (no parent modifier). "120        "Do NOT emit <div class='card'><div class='value red'>...</div></div> — a red value floating inside a blue card looks broken and unprofessional. Always pair .value.red with .card.alert and .value.gold with .card.highlight. "121        "Extract real numbers from the source content. Optional class='fragment' on each card for staggered reveal. "122        "The <div class='metrics-callout highlight'> below the card grid is OPTIONAL — include it ONLY when the source content contains a short editorial interpretation of the metrics (e.g. 'IMPATTO OPERATIVO', 'VALUTAZIONE', 'SIGNIFICATO OPERATIVO'). It has a <div class='label'> header (uppercase, short) and a single <p> body with 1-3 sentences; use <strong> on key phrases to highlight them in gold. The .highlight variant has a gold left border and gold label; omit .highlight for a blue-accent variant. When there is no editorial interpretation to show, OMIT the whole .metrics-callout block. "123        "Anti-pattern to AVOID: producing plain text rows or <ul><li>Metric: value</li></ul> instead of the card grid.",124125    "marina-timeline": "Create a Marina Militare VERTICAL TIMELINE slide. "126        "CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing what this specific timeline covers, written in the SAME LANGUAGE as the source document. Extract the heading from the actual source content — do NOT invent generic English phrases. BANNED headings that you MUST NOT use as <h2>: 'Timeline Overview', 'Chronology', 'Events', 'Timeline', 'Event Timeline', 'Operational Timeline', 'Timeline Summary', 'Schedule'. These are hallucinations from generic templates. Good examples for an Italian Navy briefing: 'Cronologia Operativa — 13 Aprile 2026', 'Sequenza Eventi T-004', 'Fasi della Missione MV-2026-0413', 'Log Operativo'. "127        "The <section> MUST have data-header-title='SHORT TITLE' AND the body MUST contain a <div class='timeline'> with <div class='event'> children — WITHOUT THIS STRUCTURE, the vertical rail and circular markers will NOT render and the slide will degrade to plain text. Do NOT produce <ul><li>HH:MM Event</li></ul> for chronological content — always use the .timeline/.event structure. Use this EXACT structure:\n"128        "<section data-header-title='SHORT SLIDE TITLE'>\n"129        "  <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n"130        "  <div class='timeline-layout'>\n"131        "    <div class='timeline'>\n"132        "      <div class='event'><span class='time'>HH:MM</span> Event description</div>\n"133        "      <div class='event alert'><span class='time'>HH:MM</span> Critical event (red marker)</div>\n"134        "      <div class='event gold'><span class='time'>HH:MM</span> Milestone event (gold marker)</div>\n"135        "      <!-- Repeat for 4-8 events -->\n"136        "    </div>\n"137        "    <div class='timeline-aside'>  <!-- OPTIONAL: include only if the source content has a natural sidebar (next-actions, summary, related contacts, etc.) -->\n"138        "      <div class='card highlight'>\n"139        "        <div class='label'>PROSSIME AZIONI</div>\n"140        "        <ul><li>Action one</li><li>Action two</li></ul>\n"141        "      </div>\n"142        "      <div class='card'>\n"143        "        <div class='label'>RIEPILOGO FINALE</div>\n"144        "        <ul><li>Summary point one</li><li>Summary point two</li></ul>\n"145        "      </div>\n"146        "    </div>\n"147        "  </div>\n"148        "  <aside class='notes'>Speaker notes.</aside>\n"149        "</section>\n"150        "Vertical timeline with circular markers along a left rail. Each event is <div class='event'> with a leading <span class='time'> for the timestamp and the description text after the closing </span>. Add class='event alert' for critical events (red marker with glow halo) and class='event gold' for milestones (gold marker). Extract real timestamps and event descriptions from the source content (4-8 events ideal). "151        "The <div class='timeline-aside'> right column is OPTIONAL — include it ONLY when the source content naturally has sidebar information (next-actions, summary of contacts, related entities, follow-up items). Each aside card has a <div class='label'> header and a short <ul> or <p> body. Use class='card highlight' for the primary aside card (gold accent) and plain class='card' for secondary cards. When there is no sidebar content, OMIT the whole .timeline-layout wrapper and .timeline-aside — use just a bare <div class='timeline'>. "152        "Anti-pattern to AVOID: <ul><li>10:00 - Event</li><li>11:00 - Event</li></ul> — this completely bypasses the timeline visual.",153154    "marina-comparison": "Create a Marina Militare COMPARISON / TRACKING TABLE slide. "155        "CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing what this specific table shows, written in the SAME LANGUAGE as the source document. Extract the heading from the actual source content — do NOT invent generic English phrases. BANNED headings that you MUST NOT use as <h2>: 'Comparison Table', 'Data Table', 'Summary Table', 'Overview Table', 'Tracking Table', 'Comparison', 'Summary', 'Overview', 'Data'. These are hallucinations from generic templates. Good examples for an Italian Navy briefing: 'Tabella Tracce — T+12 h', 'Contatti Attivi nello Stretto', 'Confronto Assetti Alleati', 'Situazione Tattica Corrente'. "156        "The <section> MUST have data-header-title='SHORT TITLE' AND the body MUST contain a real <table> with <thead> and <tbody> — WITHOUT THE TABLE, this layout has no visual and degrades to plain text columns. Do NOT produce two-column <div> layouts or bullet lists for tabular content. Use this EXACT structure:\n"157        "<section data-header-title='SHORT SLIDE TITLE'>\n"158        "  <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n"159        "  <p class='section-label'>Optional context line (row count, source, etc.)</p>\n"160        "  <table>\n"161        "    <thead><tr><th>Col 1</th><th>Col 2</th><th>Col 3</th><th>Status</th></tr></thead>\n"162        "    <tbody>\n"163        "      <tr><td>Row 1</td><td>Value</td><td>Value</td><td><span class='tag commercial'>Commercial</span></td></tr>\n"164        "      <!-- Repeat for 4-10 rows -->\n"165        "    </tbody>\n"166        "  </table>\n"167        "  <aside class='notes'>Speaker notes.</aside>\n"168        "</section>\n"169        "Wide HTML table with a navy header (gold accent column labels) and bordered rows. For status pills inside cells, use <span class='tag X'>...</span> where X is one of: commercial (blue), allied (green), friendly (cyan), fishing (olive), high-threat (red), humanitarian (gold). For inline status indicators, use <span class='status-dot Y'></span> where Y is one of: green, gold, red, blue. To highlight a critical row, add class='alert' to the <tr> (red row background). Extract real comparison/tracking data from the source content (4-10 rows ideal). Anti-pattern to AVOID: producing <div> column layouts or <ul> lists instead of a real <table>.",170171    "marina-outro": "[THEME GATE: this template is valid ONLY when the active deck theme is 'marina'. If the active theme is 'emblema', DO NOT emit this structure — switch to the 'emblema-closing' template instead, which is the Emblema brand closing slide. The Marina .closing-slide class is unstyled in the Emblema theme and would render as a blank white slide.] "172        "Create a Marina Militare CLOSING slide. The <section> MUST start with class='closing-slide' — WITHOUT THIS CLASS, the Marina Militare crest will NOT render and the slide will collapse to a plain navy background with default text. Use this EXACT structure:\n"173        "<section class='closing-slide'>\n"174        "  <h1>Grazie</h1>\n"175        "  <p class='hashtag'>#NoiSiamoLaMarina</p>\n"176        "  <aside class='notes'>Closing slide.</aside>\n"177        "</section>\n"178        "The Marina Militare crest is injected by the theme via a CSS pseudo-element above the headline — do NOT add an <img> for the logo. The <h1> renders in Montserrat bold (use 'Grazie' / 'Grazie dell'attenzione' / 'Domande?' for Italian briefings, 'Thank You' for English). The <p class='hashtag'> renders in normal weight below — keep it relevant to the unit/operation (e.g. #NoiSiamoLaMarina, #MarinaMilitare). Keep all class names EXACTLY as shown — class='closing-slide' on the section is LOAD-BEARING.",179180    # ─── Emblema layouts ───181    "emblema-cover": "Create an Emblema COVER slide. The <section> MUST contain a <div class='slide-cover'> with two child columns; WITHOUT THIS WRAPPER the title will not render against the brand split-panel layout (white left, full-bleed teal right). CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h1> values: 'Welcome', 'Title Slide', 'Cover', 'Introduction', 'Presentation'. Good examples: 'Building thoughtful AI products.', 'Operazione Mediterraneo Sicuro 2026'. Use this EXACT structure:\n<section>\n  <div class='slide-cover'>\n    <div class='left'>\n      <div class='brand'><span class='eyebrow'>SHORT EYEBROW (2-4 words, uppercase)</span></div>\n      <div>\n        <h1>Headline derived<br>from source content.</h1>\n        <p class='subtitle'>One- or two-line subtitle pulled from the source.</p>\n      </div>\n      <p class='meta'>AUTHOR &nbsp;&middot;&nbsp; DATE &nbsp;&middot;&nbsp; VERSION</p>\n    </div>\n    <div class='right'></div>\n  </div>\n  <aside class='notes'>One-line speaker note.</aside>\n</section>\nThe <div class='right'> is intentionally empty -- the theme renders the teal panel + decorative gradients via CSS. The <h1> may use a <br> to break across lines. The eyebrow is the small uppercase label above the title, the subtitle is one or two lines of secondary copy, and the meta line carries author / date / version separated by middots. Do NOT add background-color or color inline styles -- the theme owns them.",182    "emblema-agenda": "Create an Emblema AGENDA slide. The body MUST contain a <div class='slide-pad slide-agenda'> with an <ol> of agenda items; WITHOUT THE .slide-agenda CLASS the numbered counters and 2-column grid will not render and the items will fall back to default <ol> bullets. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Agenda', 'Outline', 'Overview', 'Topics', 'Schedule', 'Today'. Good examples: 'What we'll cover today.', 'Programma del briefing.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-agenda'>\n    <div>\n      <span class='eyebrow'>Agenda</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <ol>\n      <li><span class='topic'>Topic name</span><span class='duration'>X min</span></li>\n      <!-- 4-8 items total -->\n    </ol>\n  </div>\n  <div class='brand-mark'>Brand</div>\n  <div class='page-num'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nEach <li> MUST contain a <span class='topic'> for the topic and <span class='duration'> for the duration -- these are CSS-targeted for two-column alignment. The leading numbered counter (01, 02, ...) is auto-generated by CSS counter, do NOT add it as text. Use 4-8 items; fewer collapses the layout, more wraps awkwardly. Anti-pattern to AVOID: <ul> bullets or plain <p> lines without <span> wrappers.",183    "emblema-section": "Create an Emblema SECTION DIVIDER slide. The body MUST contain a <div class='slide-section'> with a <div class='num'> and a <div class='meta'>; WITHOUT THIS STRUCTURE the big section number, teal background, and 2-column layout will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Section Two', 'Part One', 'Section Divider', 'Next Section', 'Chapter', 'Overview'. Good examples: 'The opportunity in front of us.', 'I numeri dietro la storia.'. Use this EXACT structure:\n<section>\n  <div class='slide-section'>\n    <div class='num'>NN</div>\n    <div class='meta'>\n      <span class='label'>Section N or Capitolo N</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <p>One- or two-sentence framing pulled from the source content.</p>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe <div class='num'> renders as a 280px Plex Sans Light digit pair (use '01', '02', etc.). The <span class='label'> is the small uppercase label above the section title. The <p> body is restrained to 1-3 sentences -- this is a divider, not a content slide. Anti-pattern to AVOID: cramming bullet lists or KPI cards into a section divider.",184    "emblema-statement": "Create an Emblema STATEMENT / PULL QUOTE slide. The body MUST contain a <div class='slide-statement'> with a <p class='quote'> that highlights one phrase via <em>; WITHOUT THE <em> the teal accent on the key phrase will not appear and the slide loses its rhetorical hook. CRITICAL HEADING RULE: The pull quote MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. The <em> wraps the SINGLE most important phrase in the quote (2-6 words). Use this EXACT structure:\n<section>\n  <div class='slide-statement'>\n    <div>\n      <p class='quote'>The quote text with one <em>key phrase</em> highlighted in teal.</p>\n      <p class='attribution'>&mdash; Source or speaker, ROLE</p>\n    </div>\n  </div>\n  <div class='corner-index'>SECTION <span class='num'>NN</span> &middot; SLIDE_NUM</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nDo NOT use <h1> or <h2> on this layout -- the quote IS the headline and is styled via .slide-statement .quote (64px Plex Light). Use exactly ONE <em> in the quote (zero loses the accent, multiple looks busy). The <p class='attribution'> is uppercase tracked-out text below the quote. Anti-pattern to AVOID: <blockquote> or <h2> instead of <p class='quote'>.",185    "emblema-two-col": "Create an Emblema TWO-COLUMN slide. The body MUST contain a <div class='slide-grid-2 slide-two-col'> with exactly two child <div>s; WITHOUT BOTH CLASSES the 50/50 grid and centered alignment will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Two Columns', 'Comparison', 'Side by Side', 'Overview', 'Summary'. Good examples: 'Knowledge work is drowning.', 'Domanda e offerta.'. Use this EXACT structure:\n<section>\n  <div class='slide-grid-2 slide-two-col'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <p>Body paragraph 1 from source.</p>\n      <p class='muted' style='font-size:16px'>Source or footnote.</p>\n    </div>\n    <div class='visual'>\n      <!-- Either an <img> or a decorative SVG. Theme styles .visual as an et-50 panel. -->\n    </div>\n  </div>\n  <div class='brand-mark'>Brand</div>\n  <div class='page-num'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nLeft column is text; right column has class='visual' which the theme styles as a soft teal-tinted panel for an SVG/image. If no visual is available, leave the .visual div empty -- do NOT replace it with a body paragraph. Anti-pattern to AVOID: 50/50 with TWO text columns (use emblema-bullets or emblema-compare instead).",186    "emblema-bullets": "Create an Emblema BULLET TAKEAWAYS slide. The body MUST contain a <div class='slide-pad slide-bullets'> with a <ul> of 3-4 <li> items; EACH <li> MUST contain a <span class='marker'>NN</span> and a <div> wrapping <h4>+<p>; WITHOUT THE .marker SPAN the numbered teal-tint circles will not render and bullets will fall back to plain dots. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Key Takeaways', 'Bullet Points', 'Summary', 'Main Points', 'Highlights', 'Overview'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-bullets'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <ul>\n      <li>\n        <span class='marker'>01</span>\n        <div>\n          <h4>Bullet headline.</h4>\n          <p>1-2 sentence body for this bullet.</p>\n        </div>\n      </li>\n      <!-- 3-4 bullets total -->\n    </ul>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nUse 3 or 4 bullets in a 2-column grid. Marker numbers MUST use leading zeros: 01, 02, 03, 04. Anti-pattern to AVOID: plain <ul><li>text</li></ul> without the marker/div structure -- the theme requires the wrapper to render the numbered circles.",187    "emblema-image": "Create an Emblema FULL-BLEED IMAGE slide. The body MUST contain a <div class='slide-image'> with a <div class='overlay'> child for the text; WITHOUT THIS STRUCTURE the bottom dark-gradient overlay and white text positioning will not render. The .slide-image background is a teal gradient by default; an <img> tag may be appended INSIDE .slide-image to override. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Image', 'Photo', 'Visual', 'Picture', 'Background'. Use this EXACT structure:\n<section>\n  <div class='slide-image'>\n    <!-- Optional: <img src='...' alt='...' style='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;'> -->\n    <div class='overlay'>\n      <span class='eyebrow eyebrow--inverse'>SHORT LOCATION OR LABEL</span>\n      <h2>Image headline pulled from source.</h2>\n      <p>Caption or context, 1-2 sentences.</p>\n    </div>\n  </div>\n  <div class='page-num' style='color: var(--text-on-color); opacity: 0.7;'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe .eyebrow MUST also have eyebrow--inverse so the small uppercase label renders in light teal against the dark background. Page number is forced to translucent white via inline style, the theme's var(--text-on-color) at opacity 0.7 (the only inline color allowed on this layout, and only because the chrome lives outside the gradient). Anti-pattern to AVOID: omitting the .overlay wrapper -- the text falls through onto a transparent background.",188    "emblema-magazine": "Create an Emblema MAGAZINE SPREAD slide. The body MUST contain a <div class='slide-mag'> with a <div class='photo-col'> on the left and <div class='text-col'> on the right; WITHOUT BOTH CHILDREN the asymmetric photo-left / text-right grid will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Magazine', 'Spread', 'Story', 'Customer Story', 'Case Study'. Good examples: 'From four spreadsheets to one workflow.', 'Da quattro fogli a un flusso.'. Use this EXACT structure:\n<section>\n  <div class='slide-mag'>\n    <div class='photo-col'>\n      <div class='photo photo--var2'><span class='photo-tag'>Photo &middot; MM.YYYY</span></div>\n    </div>\n    <div class='text-col'>\n      <span class='eyebrow'>Customer story &middot; Subject</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <p class='body'>Long-form body paragraph -- 3-5 sentences pulled from the source.</p>\n      <div class='meta'>\n        <span><strong>Photographed at</strong> &mdash; Place</span>\n        <span><strong>Words by</strong> &mdash; Author</span>\n        <span><strong>Month YYYY</strong></span>\n      </div>\n    </div>\n  </div>\n  <div class='page-num'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nPick ONE of .photo--var1 through .photo--var8 to set the gradient placeholder colour. To use a real image, replace the .photo div with an <img> covering the photo-col area. The .meta uses <strong> labels for byline-style metadata. Anti-pattern to AVOID: <table> or <ul> instead of the .meta <span> structure.",189    "emblema-two-img": "Create an Emblema TWO IMAGES slide. The body MUST contain a <div class='slide-pad slide-two-img'> with a <div class='grid'> of exactly two <div class='item'> children; WITHOUT THE .grid AND .item CLASSES the side-by-side layout and per-item caption styling will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Two Images', 'Photos', 'Pictures', 'Gallery', 'Examples'. Good examples: 'Two teams, two transformations.', 'Field studies &mdash; Spring 2026'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-two-img'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='grid'>\n      <div class='item'>\n        <div class='photo photo--var1'><span class='photo-tag'>Tag 1</span></div>\n        <h4>Item 1 title</h4>\n        <p>One- or two-sentence description.</p>\n        <div class='cap'>&mdash; Credit, Date</div>\n      </div>\n      <div class='item'>\n        <div class='photo photo--var6'><span class='photo-tag'>Tag 2</span></div>\n        <h4>Item 2 title</h4>\n        <p>One- or two-sentence description.</p>\n        <div class='cap'>&mdash; Credit, Date</div>\n      </div>\n    </div>\n  </div>\n  <div class='page-num'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nExactly TWO .item children -- adding a third breaks the equal-width grid. Each .item has photo + h4 + p + .cap. Anti-pattern to AVOID: omitting the .photo wrapper around an <img> (the .photo class is what gives images their teal-tint placeholder fallback).",190    "emblema-grid-img": "Create an Emblema 2x2 IMAGE GRID slide. The body MUST contain a <div class='slide-grid-img'> with a <div class='header'> and a <div class='grid'> of exactly four <div class='photo'> children; WITHOUT THIS STRUCTURE the full-bleed teal background, white header, and 2x2 image grid will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Image Grid', 'Gallery', 'Photos', '2x2', 'Examples'. Use this EXACT structure:\n<section>\n  <div class='slide-grid-img'>\n    <div class='header'>\n      <span class='eyebrow eyebrow--inverse'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <p>One- or two-sentence framing.</p>\n    </div>\n    <div class='grid'>\n      <div class='photo photo--var4'><span class='photo-tag'>Caption 1</span></div>\n      <div class='photo photo--var5'><span class='photo-tag'>Caption 2</span></div>\n      <div class='photo photo--var7'><span class='photo-tag'>Caption 3</span></div>\n      <div class='photo photo--var8'><span class='photo-tag'>Caption 4</span></div>\n    </div>\n  </div>\n  <div class='page-num' style='color: var(--text-on-color); opacity: 0.7;'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nUse eyebrow--inverse for light teal text against the dark background. Pick distinct .photo--varN values for visual variety. Exactly 4 photos. Anti-pattern to AVOID: 3 or 5 photos -- breaks the 2x2 grid.",191    "emblema-hero-centered": "Create an Emblema CENTERED HERO slide. The body MUST contain a <div class='slide-hero-centered'> with a <div class='header'>, a <div class='photo'>, and a <p class='caption'>; WITHOUT THIS STRUCTURE the centered alignment, hero photo sizing, and italic caption styling will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Hero', 'Image', 'Photo', 'Featured Image'. Good examples: 'The room where the first prototype shipped.', 'La sala riunioni del comando.'. Use this EXACT structure:\n<section>\n  <div class='slide-hero-centered'>\n    <div class='header'>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n    </div>\n    <div class='photo photo--var2'><span class='photo-tag'>Photo &middot; MM.YYYY</span></div>\n    <p class='caption'>Italic caption text. <em>Photographed by NAME &middot; Month YYYY</em></p>\n  </div>\n  <div class='brand-mark'>Brand</div>\n  <div class='page-num'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe .photo grows to fill the available vertical space between header and caption. To use a real <img>, place it INSIDE the .photo div with object-fit:cover. Anti-pattern to AVOID: large <img> without the .photo wrapper -- inherits composer's max-width:100% rule and shrinks awkwardly.",192    "emblema-hero-card": "Create an Emblema HERO + FLOATING CARD slide. The body MUST contain a <div class='slide-hero-card'> with a full-bleed <div class='photo'> and a floating <div class='card'>; WITHOUT BOTH CHILDREN the hero image background and white card with shadow will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Spotlight', 'Featured', 'Card', 'Highlight'. Good examples: 'Inside the launch of our largest deployment yet.', 'Dietro le quinte della missione X.'. Use this EXACT structure:\n<section>\n  <div class='slide-hero-card'>\n    <div class='photo photo--var5'><span class='photo-tag'>SHORT TAG</span></div>\n    <div class='card'>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <p>1-3 sentence body pulled from source.</p>\n      <div class='meta'>Subject &middot; Place &middot; Month YYYY</div>\n    </div>\n  </div>\n  <div class='page-num' style='color: var(--text-on-color); opacity: 0.7;'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe .card floats absolute bottom-left over the hero photo with a drop-shadow. The .meta is a tracked-out uppercase metadata line at the card's bottom. Anti-pattern to AVOID: putting all content inside .photo -- the card layer is what creates the magazine-style legibility against the photo.",193    "emblema-collage": "Create an Emblema IMAGE COLLAGE slide. The body MUST contain a <div class='slide-collage'> with a <div class='text-col'> and a <div class='stack'> of exactly THREE <div class='photo'> children; WITHOUT THE .stack AND THREE PHOTOS the rotated layered photo collage will not render (each photo has a nth-child rule that positions it). CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Collage', 'Gallery', 'Photos', 'Year in Review'. Use this EXACT structure:\n<section>\n  <div class='slide-collage'>\n    <div class='text-col'>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <p>One paragraph framing the collage.</p>\n      <p class='caption'>Optional caption identifying the photos clockwise.</p>\n    </div>\n    <div class='stack'>\n      <div class='photo photo--var1'><span class='photo-tag'>01</span></div>\n      <div class='photo photo--var3'><span class='photo-tag'>02</span></div>\n      <div class='photo photo--var5'><span class='photo-tag'>03</span></div>\n    </div>\n  </div>\n  <div class='brand-mark'>Brand</div>\n  <div class='page-num'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nExactly THREE .photo children -- the CSS targets :nth-child(1), :nth-child(2), :nth-child(3) for rotation/positioning. Anti-pattern to AVOID: 2 or 4 photos -- breaks the layered effect.",194    "emblema-stat": "Create an Emblema BIG STAT slide. The body MUST contain a <div class='slide-stat'> with a <div class='number'> and a <div class='copy'>; WITHOUT THIS STRUCTURE the 280px teal stat number and side-by-side copy will not render. CRITICAL HEADING RULE: The <h3> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h3> values: 'Big Number', 'Stat', 'Metric', 'Key Number'. The <h3> is the supporting headline -- the BIG NUMBER itself goes in <div class='number'> NOT in a heading. Use this EXACT structure:\n<section>\n  <div class='slide-stat'>\n    <div class='number'>NN<span class='unit'>%</span></div>\n    <div class='copy'>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h3>One-sentence interpretation derived from the source.</h3>\n      <p>1-2 sentence body explaining the number.</p>\n    </div>\n  </div>\n  <div class='brand-mark'>Brand</div>\n  <div class='page-num'>NN / NN</div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe <div class='number'> takes the bare numeric (no euro/dollar sign in front -- prefix those into the <span class='unit'> if needed; e.g. '<span class=\"unit\">&euro;</span>1.2M'). The <span class='unit'> renders at half size in lighter teal. Use a single number per slide -- for multiples use emblema-kpis. Anti-pattern to AVOID: <h2>NN%</h2> -- defeats the purpose of the layout.",195    "emblema-kpis": "Create an Emblema KPI GRID slide. The body MUST contain a <div class='slide-pad slide-kpis'> with a <div class='grid'> of BETWEEN 3 AND 9 <div class='kpi'> children — prefer multiples of 3 (3, 6, or 9); WITHOUT THE .kpi WRAPPER each metric loses its top teal accent, soft background, and large-number styling. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'KPIs', 'Key Metrics', 'Numbers', 'Performance', 'Dashboard', 'Overview', 'Stats'. Good examples: 'Three numbers we're proud of.', 'Tre KPI del Q1 2026'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-kpis'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='grid'>\n      <div class='kpi'>\n        <div class='num'>NN<span class='delta'>&uarr; X</span></div>\n        <h4>KPI title</h4>\n        <p>One-line context derived from source.</p>\n      </div>\n      <!-- 3 to 9 KPIs, prefer multiples of 3 -->\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nUse 3, 6, or 9 KPIs based on content density: 3 (single row) for headline metrics, 6 (2 rows) for paired-metric dashboards, 9 (3 rows) for full quarterly scorecards. CSS uses grid-template-columns: repeat(3, 1fr) — counts that aren't multiples of 3 leave a partial last row. The <span class='delta'> is OPTIONAL and renders in green by default; for a NEGATIVE delta change the arrow to &darr;. Anti-pattern to AVOID: more than 9 KPIs (likely belong in emblema-table or emblema-chart instead — the kpi card design loses impact at higher counts). Counts that aren't multiples of 3 (4, 5, 7, 8) are acceptable but visually weaker than a clean 3/6/9 grid.",196    "emblema-compare": "Create an Emblema BEFORE/AFTER COMPARISON slide. The body MUST contain a <div class='slide-pad slide-compare'> with a <div class='grid'> containing a .col, a .arrow span, and a .col.col--after; WITHOUT THE 1fr-80px-1fr GRID and the .col--after class on the right column, the highlighted 'after' state will not render and the arrow will not be centered. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Comparison', 'Before/After', 'Then/Now', 'Old vs New'. Good examples: 'How a quarterly review changes after Emblema.', 'Da manuale ad automatico.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-compare'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='grid'>\n      <div class='col'>\n        <h4>BEFORE / OLD STATE</h4>\n        <ul>\n          <li>Pain point 1</li>\n          <li>Pain point 2</li>\n          <li>Pain point 3</li>\n        </ul>\n      </div>\n      <div class='arrow'>&rarr;</div>\n      <div class='col col--after'>\n        <h4>AFTER / NEW STATE</h4>\n        <ul>\n          <li>Resolution 1</li>\n          <li>Resolution 2</li>\n          <li>Resolution 3</li>\n        </ul>\n      </div>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nCRITICAL: the AFTER column MUST have BOTH .col AND .col--after classes -- without .col--after it loses the teal-tint background and teal border that signal 'this is the improved state'. Match item count between columns (3-5 items each). Anti-pattern to AVOID: <table> with 2 columns, or putting both lists in a single .col div.",197    "emblema-chart": "Create an Emblema CHART slide. The body MUST contain a <div class='slide-pad slide-chart'> with a <div class='chart-wrap'> wrapping the visualization, plus a <div class='legend'>; WITHOUT THE .chart-wrap container the chart panel border and padding will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Chart', 'Graph', 'Data', 'Trend'. Good examples: 'Revenue lift across the cohort.', 'Andamento clienti 2024-2026'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-chart'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='chart-wrap'>\n      <!-- An inline <svg viewBox='0 0 W H'> for bar/line/area chart, OR a placeholder. -->\n      <svg viewBox='0 0 800 320' style='width:100%;height:auto'>\n        <!-- bars/lines using <rect>, <line>, <path>; reuse var(--et-500) color -->\n      </svg>\n    </div>\n    <div class='legend'>\n      <span><i style='background:var(--et-500)'></i>Series 1</span>\n      <span><i style='background:var(--et-300)'></i>Series 2</span>\n    </div>\n    <p class='muted' style='font-size:14px;margin-top:1em'>Source &mdash; reference</p>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe <i> in legend items is intentionally empty -- CSS sizes it as a 12px swatch. Inline style `background:var(--et-500)` is the ONLY allowed inline color (legend swatches need it because there is no enumerable class for arbitrary swatch colors). Anti-pattern to AVOID: <table> instead of an SVG chart -- use emblema-table for data tables.",198    "emblema-table": "Create an Emblema DATA TABLE slide. The body MUST contain a <div class='slide-pad slide-table'> with a real <table> containing <thead>, <tbody>, and optionally <tfoot>; WITHOUT A REAL <table> the column-aligned numerics and uppercase tracked-out header styling will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Data Table', 'Table', 'Numbers', 'Financials', 'Metrics'. Good examples: 'Q1 2026 financial summary.', 'Bilancio operativo del primo trimestre.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-table'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <table>\n      <thead><tr><th>Metric</th><th class='num'>Period A</th><th class='num'>Period B</th><th class='num'>Delta</th></tr></thead>\n      <tbody>\n        <tr><td class='label'>Row name</td><td class='num'>1,234</td><td class='num'>2,345</td><td class='num up'>+90%</td></tr>\n        <!-- 4-8 rows -->\n      </tbody>\n      <tfoot><tr><td>Total</td><td class='num'>X</td><td class='num'>Y</td><td class='num up'>Z</td></tr></tfoot>\n    </table>\n    <p class='src'>Source &mdash; reference</p>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nNumeric columns MUST have class='num' on every <th> and <td> -- this right-aligns and applies tabular-nums. For deltas, use class='up' (green), class='down' (red), or class='flat' (grey) on the <td>. The class='label' on the first body cell makes the row label semibold. Anti-pattern to AVOID: <div> grids with manual alignment -- use a real table.",199    "emblema-matrix": "Create an Emblema COMPARISON MATRIX slide. The body MUST contain a <div class='slide-pad slide-matrix'> with a <table> whose <thead> has EXACTLY ONE <th class='featured'>; WITHOUT .featured on one column the 'OUR PICK' badge and teal column highlight will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Comparison', 'Matrix', 'Options', 'Compare'. Good examples: 'Why Emblema vs the build/buy alternatives.', 'Confronto con le alternative di mercato.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-matrix'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <table>\n      <thead><tr><th>Feature</th><th>Option A</th><th class='featured'>Emblema</th><th>Option C</th></tr></thead>\n      <tbody>\n        <tr><td>Capability label</td><td><span class='no'>&times;</span></td><td class='featured'><span class='yes'>&check;</span></td><td><span class='partial'>partial</span></td></tr>\n        <!-- 4-8 rows -->\n      </tbody>\n    </table>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nEXACTLY ONE <th class='featured'> -- having two means the 'OUR PICK' badge appears twice and the visual hierarchy collapses. Each row's cell under the featured column ALSO needs class='featured' so it inherits the teal-tint background. Cell content uses <span class='yes'>&check;</span>, <span class='no'>&times;</span>, or <span class='partial'>partial</span>. Anti-pattern to AVOID: emoji ✓/✗ instead of the styled span variants -- emoji bypasses the theme color logic.",200    "emblema-spec": "Create an Emblema SPEC SHEET slide. The body MUST contain a <div class='slide-pad slide-spec'> with a <div class='spec-grid'> of 2 or 4 <div class='spec-section'> children, each with an <h4> and a <dl>; WITHOUT THIS STRUCTURE the 2-column spec layout, dashed dividers, and uppercase teal section headers will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Specifications', 'Specs', 'Details', 'Properties', 'Information'. Good examples: 'Platform technical specifications.', 'Specifiche piattaforma ITS Margottini.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-spec'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='spec-grid'>\n      <div class='spec-section'>\n        <h4>Section Name<span class='num'>01</span></h4>\n        <dl>\n          <div><dt>Key</dt><dd>Value <span class='badge'>BADGE</span></dd></div>\n          <div><dt>Key</dt><dd>Value</dd></div>\n          <!-- 3-6 entries per section -->\n        </dl>\n      </div>\n      <!-- 2 or 4 .spec-section blocks -->\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe <dl> uses a non-standard <div> wrapper around each <dt>/<dd> pair -- this is REQUIRED because the CSS uses .slide-spec dl > div for grid alignment. The <span class='badge'> is OPTIONAL inline highlight inside a <dd>. Anti-pattern to AVOID: flat <dl><dt><dd></dl> without div wrappers (loses the per-row alignment) or <table> (visual is wrong for spec sheets).",201    "emblema-status": "Create an Emblema PROJECT STATUS slide. The body MUST contain a <div class='slide-pad slide-status'> with a <table> whose status cells contain <span class='dot dot--COLOR'> elements; WITHOUT THE .dot--COLOR CLASSES the green/amber/red/gray RAG indicators will not render and the table looks like generic data. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Status', 'RAG', 'Project Status', 'Portfolio', 'Health'. Good examples: 'Q1 portfolio &mdash; status at week 11.', 'Stato del portafoglio progetti.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-status'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <table>\n      <thead><tr><th>Project</th><th class='owner'>Owner</th><th class='note'>Note</th><th class='status'>Status</th></tr></thead>\n      <tbody>\n        <tr><td>Project A</td><td class='owner'>Owner</td><td class='note'>Short note.</td><td class='status'><span class='dot dot--green'></span></td></tr>\n        <tr><td>Project B</td><td class='owner'>Owner</td><td class='note'>Short note.</td><td class='status'><span class='dot dot--amber'></span></td></tr>\n        <tr><td>Project C</td><td class='owner'>Owner</td><td class='note'>Short note.</td><td class='status'><span class='dot dot--red'></span></td></tr>\n        <!-- 4-8 rows -->\n      </tbody>\n    </table>\n    <div class='legend'>\n      <span><span class='dot dot--green'></span>On track</span>\n      <span><span class='dot dot--amber'></span>At risk</span>\n      <span><span class='dot dot--red'></span>Off track</span>\n      <span><span class='dot dot--gray'></span>Not started</span>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nSTATUS COUPLING RULE: dot--green = on track, dot--amber = at risk, dot--red = off track, dot--gray = not started. Use the dot color that matches the SEMANTICS of each project's status -- do NOT assign colors arbitrarily. Anti-pattern to AVOID: emoji 🟢🟡🔴 instead of styled spans (no theme control over color), or omitting the dot wrapper inside the .status td.",202    "emblema-process": "Create an Emblema PROCESS / STEPS slide. The body MUST contain a <div class='slide-pad slide-process'> with a <div class='steps'> of BETWEEN 4 AND 8 <div class='step'> children — prefer multiples of 4 (4 or 8); WITHOUT THE .step WRAPPER each card loses its left teal border and uppercase step label. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Process', 'Steps', 'Workflow', 'How it works', 'Methodology'. Good examples: 'How an Emblema rollout flows.', 'Le quattro fasi del nostro metodo.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-process'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='steps'>\n      <div class='step'>\n        <div class='icon'><svg viewBox='0 0 22 22'><!-- 22x22 icon --></svg></div>\n        <span class='num'>STEP 01</span>\n        <h4>Step title</h4>\n        <p>1-2 sentences describing the step.</p>\n      </div>\n      <!-- 4 to 8 .step children, prefer multiples of 4 -->\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nUse 4 (single row) or 8 (two rows) based on process complexity. CSS uses grid-template-columns: repeat(4, 1fr) — counts other than 4 or 8 (5, 6, 7) produce a partial last row that's acceptable but visually unbalanced. Use leading zeros on the step numbers (01, 02, ..., 0N). The <div class='icon'> can be empty if no icon is available -- it still renders as a teal-tinted square placeholder. Anti-pattern to AVOID: fewer than 4 steps (use emblema-statement or emblema-bullets for short sequences). Counts other than 4 or 8 are acceptable when content has that many genuine steps, but 4 and 8 are visually cleanest.",203    "emblema-timeline": "Create an Emblema HORIZONTAL TIMELINE slide. The body MUST contain a <div class='slide-pad slide-timeline'> with a <div class='track'> wrapping a <div class='line'> and a <div class='points'> of EXACTLY FIVE <div class='point'> children; STATE COUPLING RULE: at most ONE <div class='point now'> (the present moment), all earlier points use <div class='point done'>, all later points use plain <div class='point'>. Without these state classes the dots are blank circles. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Timeline', 'Schedule', 'Roadmap', 'Milestones', 'Plan'. Good examples: 'A year of Emblema, milestone by milestone.', 'I cinque snodi del 2025.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-timeline'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='track'>\n      <div class='line'></div>\n      <div class='points'>\n        <div class='point done'><div class='dot'></div><span class='date'>Q1 2025</span><h4>Milestone</h4><p>1-line description.</p></div>\n        <div class='point done'><div class='dot'></div><span class='date'>Q2 2025</span><h4>Milestone</h4><p>1-line description.</p></div>\n        <div class='point now'><div class='dot'></div><span class='date'>NOW</span><h4>Current focus</h4><p>1-line description.</p></div>\n        <div class='point'><div class='dot'></div><span class='date'>Q4 2025</span><h4>Upcoming</h4><p>1-line description.</p></div>\n        <div class='point'><div class='dot'></div><span class='date'>Q1 2026</span><h4>Upcoming</h4><p>1-line description.</p></div>\n      </div>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nEXACTLY 5 .point children -- the CSS uses grid-template-columns: repeat(5, 1fr). At most one .now; .done points must come before .now in DOM order; plain .point come after. The <div class='line'> is intentionally empty (CSS-styled track). Anti-pattern to AVOID: vertical <ul><li> timeline (use a different layout for vertical chronology) or 4/6 points (breaks the grid).",204    "emblema-roadmap": "Create an Emblema ROADMAP slide. The body MUST contain a <div class='slide-pad slide-roadmap'> with a <div class='phases'> of EXACTLY THREE <div class='phase'> children: 'Now' (default), 'Next' (.phase--next), and 'Later' (.phase--later); WITHOUT THE .phase--next AND .phase--later modifiers the three phases all render with the same teal header and the time hierarchy is lost. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Roadmap', 'Plan', 'Phases', 'Schedule', 'Timeline'. Good examples: 'What ships next, and what comes after.', 'Roadmap del prodotto 2026.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-roadmap'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='phases'>\n      <div class='phase'><header><h4>Now</h4><span class='when'>Q1 2026</span></header><ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></div>\n      <div class='phase phase--next'><header><h4>Next</h4><span class='when'>Q2 2026</span></header><ul><li>Item 1</li><li>Item 2</li></ul></div>\n      <div class='phase phase--later'><header><h4>Later</h4><span class='when'>H2 2026</span></header><ul><li>Item 1</li><li>Item 2</li></ul></div>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nPHASE COUPLING RULE: first .phase = default (teal header), second = .phase.phase--next (teal-mid header), third = .phase.phase--later (gray header). The DOM order encodes the time order. Each .phase has a <header> with <h4> + <span class='when'> followed by a <ul>. Anti-pattern to AVOID: 4 phases (breaks the 3-col grid) or omitting the modifier classes (all phases look identical).",205    "emblema-team": "Create an Emblema TEAM GRID slide. The body MUST contain a <div class='slide-pad slide-team'> with a <div class='grid'> of EXACTLY FOUR <div class='person'> children, each with .avatar + h4 + .role + p.bio; WITHOUT THE .person AND .avatar CLASSES the square avatar tiles and team-card layout will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Team', 'Our Team', 'Founders', 'People', 'Who We Are'. Good examples: 'Four people, fifteen years of compounding craft.', 'Il team che fa funzionare tutto.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-team'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='grid'>\n      <div class='person'>\n        <div class='avatar'>FN</div>  <!-- Two-letter initials, OR replace with <img src='...' alt='...'> -->\n        <h4>Full Name</h4>\n        <p class='role'>Role &middot; Department</p>\n        <p class='bio'>1-2 sentence bio derived from source content.</p>\n      </div>\n      <!-- Exactly 4 .person children -->\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nEXACTLY 4 people -- the CSS uses repeat(4, 1fr). The .avatar div renders as a teal-tinted square; if no real photo, place 2-letter initials inside; if a photo is available, use <img> with width/height filling the .avatar. Anti-pattern to AVOID: <ul> of names (loses the grid + avatar) or 5+ people (rows wrap awkwardly).",206    "emblema-testimonial": "Create an Emblema TESTIMONIAL slide. The body MUST contain a <div class='slide-testimonial'> with a <div class='photo'> on the left and a <div> wrapping <p class='quote'> + <p class='who'> + <p class='who-meta'> on the right; WITHOUT THE .quote CLASS the leading large opening-quote pseudo-element will not render. CRITICAL HEADING RULE: The pull quote MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. The quote is the headline, NOT a separate <h2>. Use this EXACT structure:\n<section>\n  <div class='slide-testimonial'>\n    <div class='photo'>FN</div>  <!-- Initials, OR replace with <img> -->\n    <div>\n      <p class='quote'>The full quote text from the source.</p>\n      <p class='who'>Speaker Name</p>\n      <p class='who-meta'>Role &middot; Company &middot; Subject</p>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nDo NOT use <blockquote> or <h2> for the quote -- the <p class='quote'> is what triggers the styled opening-quote pseudo-element and 36px Plex Light styling. The .photo renders as a circular gradient by default; replace with an <img> for a real headshot. Anti-pattern to AVOID: <em>quote</em> wrappers (italics fight the existing styling) or splitting attribution across multiple unstyled <p>.",207    "emblema-logos": "Create an Emblema LOGO CLOUD slide. The body MUST contain a <div class='slide-pad slide-logos'> with a <div class='grid'> of <div class='cell'> children (5 columns); WITHOUT THE .cell WRAPPER the 16:9 logo cells with hairline borders will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Logos', 'Customers', 'Partners', 'Clients', 'Brands'. Good examples: 'Teams that quietly run on Emblema.', 'I clienti che ci hanno scelto nel 2025.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-logos'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='grid'>\n      <div class='cell'>BRAND</div>\n      <!-- 10 or 15 cells (5 per row, 2-3 rows) -->\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nEach .cell contains a brand wordmark as plain text (Plex Sans 600), or replace with an <img>. Use a multiple of 5 cells (10 or 15) for clean rows. Anti-pattern to AVOID: <ul><li>brand</li></ul> (loses the cell border + 16:9 aspect ratio).",208    "emblema-ui": "Create an Emblema UI SHOWCASE slide. The body MUST contain a <div class='slide-ui'> with a <div class='left'> for copy and a <div class='browser'> for the screenshot mockup; WITHOUT THE .browser WRAPPER the chrome bar with traffic-light dots and URL bar will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'UI', 'Product', 'Screenshot', 'App', 'Interface'. Good examples: 'Inside the workspace -- one panel, one source of truth.', 'Una sola interfaccia per tutto il flusso operativo.'. Use this EXACT structure:\n<section>\n  <div class='slide-ui'>\n    <div class='left'>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <ul>\n        <li>Feature point 1</li>\n        <li>Feature point 2</li>\n        <li>Feature point 3</li>\n        <li>Feature point 4</li>\n      </ul>\n    </div>\n    <div class='browser'>\n      <div class='chrome'>\n        <span></span><span></span><span></span>\n        <div class='url'>app.emblema.ai/workspaces</div>\n      </div>\n      <div class='body'>\n        <!-- Optional: <img> for a real screenshot. Otherwise placeholder rows: -->\n        <div class='ui-row'></div>\n        <div class='ui-row short'></div>\n        <div class='ui-card'><h5>Card label</h5><p>Card body.</p></div>\n      </div>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe three empty <span> elements in .chrome are the traffic-light dots (CSS sizes them). The .url div is the address bar. Inside .body, use empty <div class='ui-row'> for placeholder lines or insert a real <img> screenshot. Anti-pattern to AVOID: full-bleed <img> outside the .browser wrapper (loses the framing chrome).",209    "emblema-features": "Create an Emblema FEATURE GRID slide. The body MUST contain a <div class='slide-pad slide-features'> with a <div class='grid'> of BETWEEN 6 AND 12 <div class='card'> children — prefer multiples of 3 (6, 9, or 12) so the 3-col grid wraps cleanly, each with .icon + h4 + p; WITHOUT THE .card AND .icon WRAPPERS the cards lose their hairline borders and teal-tint icon tiles. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Features', 'Capabilities', 'What we do', 'Highlights', 'Functionality'. Good examples: 'Six things Emblema does that others don't.', 'Sei capacita' che ci distinguono.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-features'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='grid'>\n      <div class='card'>\n        <div class='icon'><svg viewBox='0 0 22 22'><!-- 22x22 icon --></svg></div>\n        <h4>Feature title</h4>\n        <p>1-2 sentences derived from source.</p>\n      </div>\n      <!-- 6 to 12 .card children, prefer multiples of 3 -->\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nUse 6, 9, or 12 cards based on content density: 6 (3x2) for sparse content, 9 (3x3) for medium-dense feature lists, 12 (3x4) for very dense lists. CSS uses grid-template-columns: repeat(3, 1fr) — counts that aren't multiples of 3 (7, 8, 10, 11) leave a partial last row that's acceptable but visually weaker. The .icon div is a teal-tinted square; the inline SVG is OPTIONAL (an empty .icon still renders as the swatch). Anti-pattern to AVOID: fewer than 6 cards (under-fills the layout, leaves the slide looking sparse). Counts that aren't multiples of 3 are acceptable when content genuinely has that many discrete points, but multiples of 3 always look cleaner.",210    "emblema-pricing": "Create an Emblema PRICING TIERS slide. The body MUST contain a <div class='slide-pad slide-pricing'> with a <div class='grid'> of EXACTLY THREE <div class='tier'> children, with EXACTLY ONE marked .tier--featured (the recommended tier); WITHOUT .tier--featured on one card, the lifted shadow + teal CTA + 'Most popular' badge will not render. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Pricing', 'Plans', 'Tiers', 'Subscriptions', 'Cost'. Good examples: 'Pricing for teams of every size.', 'I tre piani di Emblema.'. Use this EXACT structure:\n<section>\n  <div class='slide-pad slide-pricing'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <hr class='divider'>\n    </div>\n    <div class='grid'>\n      <div class='tier'>\n        <h4>Tier name</h4>\n        <div class='price'>&euro;X<span class='per'>/mo</span></div>\n        <p class='desc'>Short description of who it's for.</p>\n        <ul class='feats'><li>Feature 1</li><li>Feature 2</li></ul>\n        <a class='cta'>Choose plan</a>\n      </div>\n      <div class='tier tier--featured'>\n        <span class='badge'>Most popular</span>\n        <h4>Tier name</h4>\n        <div class='price'>&euro;Y<span class='per'>/mo</span></div>\n        <p class='desc'>Description.</p>\n        <ul class='feats'><li>Feature 1</li><li>Feature 2</li><li>Feature 3</li></ul>\n        <a class='cta'>Choose plan</a>\n      </div>\n      <div class='tier'>\n        <!-- Same structure as tier 1 -->\n      </div>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nTIER COUPLING RULE: EXACTLY ONE .tier--featured (typically the middle one). The .badge span ONLY appears inside .tier--featured. Each tier has h4 (label) + .price (with .per for the period suffix) + .desc + ul.feats + a.cta. Anti-pattern to AVOID: zero or two .tier--featured (visual hierarchy collapses) or 4 tiers (breaks the 3-col grid).",211    "emblema-question": "Create an Emblema BIG QUESTION slide. The body MUST contain a <div class='slide-question'> with a <p class='q'> for the question; the CSS pseudo-element appends a TEAL '?' automatically -- do NOT include the question mark in the text. CRITICAL HEADING RULE: The question text MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. Use this EXACT structure:\n<section>\n  <div class='slide-question'>\n    <p class='q'>What if the smallest team did the most thoughtful work</p>\n    <p class='hint'>Optional one-line hint or framing for the audience.</p>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe <p class='q'> renders at 96px Plex Light. The CSS adds a teal '?' via ::after -- so the text inside <p class='q'> must NOT end with a question mark (otherwise you get two: '??'). The <p class='hint'> is OPTIONAL and renders smaller and tracked-out below. Anti-pattern to AVOID: <h1> or <h2> for the question text -- loses the styled '?' pseudo-element.",212    "emblema-definition": "Create an Emblema DEFINITION slide. The body MUST contain a <div class='slide-definition'> with a <div class='term'> on the left (with optional <span class='ipa'>) and a body block on the right with .pos + .meaning + .example; WITHOUT THE .term AND .meaning CLASSES the dictionary-style typography and teal vertical rule will not render. CRITICAL HEADING RULE: The term MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. Use this EXACT structure:\n<section>\n  <div class='slide-definition'>\n    <div class='term'>\n      Word or phrase\n      <span class='ipa'>/&aelig;Ipa\u02c8not&aelig;Si&Eta;n/</span>\n    </div>\n    <div>\n      <p class='pos'>Part of Speech</p>\n      <p class='meaning'>Plain-language definition derived from source.</p>\n      <p class='example'>An italic example sentence using the term.</p>\n    </div>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nThe <div class='term'> sits left of a teal vertical rule (CSS-applied). The <span class='ipa'> is OPTIONAL pronunciation; omit if the source doesn't include it. The .pos is the small uppercase part-of-speech label. Anti-pattern to AVOID: <dl><dt><dd> (semantically tempting but the styling is wrong) or wrapping everything in a single <p>.",213    "emblema-code": "Create an Emblema CODE slide. The body MUST contain a <div class='slide-code'> with a <div> for copy on the left and a <pre> on the right; WITHOUT <pre> THE DARK SYNTAX BACKGROUND AND IBM PLEX MONO STYLING will not render -- the theme styles `.slide-code pre` specifically. CRITICAL HEADING RULE: The <h2> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h2> values: 'Code', 'Snippet', 'Example', 'Programming'. Use this EXACT structure:\n<section>\n  <div class='slide-code'>\n    <div>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h2>HEADING_DERIVED_FROM_SOURCE_CONTENT</h2>\n      <p>1-2 sentence framing of what the code does.</p>\n    </div>\n    <pre>\n<span class='c'># Comment</span>\n<span class='k'>def</span> <span class='f'>function_name</span>(<span class='n'>arg</span>):\n    <span class='k'>return</span> <span class='s'>'string'</span>\n    </pre>\n  </div>\n  <aside class='notes'>Speaker notes.</aside>\n</section>\nUse the theme's syntax-color spans inside <pre>: .k = keyword (cyan), .s = string (yellow), .c = comment (grey italic), .f = function name (teal), .n = identifier (white). The <pre>::before pseudo-element shows a 'python' label in the top-right corner -- this is hardcoded in the theme; if you need a different language, the label still says 'python' (limitation of the theme; do not try to override). Indent code with spaces, not tabs. Anti-pattern to AVOID: emitting `<script>` (composer strips it) or styling code with inline color (bypasses the theme).",214    "emblema-closing": "Create an Emblema CLOSING slide. The body MUST contain a <div class='slide-closing'> with a <div class='left'> and a <div class='right'>; WITHOUT BOTH the teal split layout, gradient right panel, and contact-pair layout will not render. CRITICAL HEADING RULE: The <h1> MUST be 3-8 words describing THIS specific slide's content, written in the SAME LANGUAGE as the source document (Italian source -> Italian heading, English source -> English heading). Extract the heading from the actual source content -- do NOT invent generic phrases. BANNED <h1> values: 'Thank you', 'Questions?', 'The end' (use them ONLY if they're the literal close in the source language and tone). Good examples: 'Let's build something thoughtful together.', 'Costruiamo qualcosa di importante.'. Use this EXACT structure:\n<section>\n  <div class='slide-closing'>\n    <div class='left'>\n      <span class='eyebrow'>SHORT EYEBROW</span>\n      <h1>Closing headline derived from source.</h1>\n      <dl class='contacts'>\n        <div><dt>Email</dt><dd>hello@example.com</dd></div>\n        <div><dt>Web</dt><dd>example.com</dd></div>\n        <div><dt>Phone</dt><dd>+39 02 1234 5678</dd></div>\n        <div><dt>Address</dt><dd>City, Country</dd></div>\n      </dl>\n    </div>\n    <div class='right'></div>\n  </div>\n  <aside class='notes'>Closing slide.</aside>\n</section>\nThe <div class='right'> is intentionally empty -- the theme renders the radial gradient via CSS. The <dl class='contacts'> uses the same div-per-pair wrapper as the spec sheet. Up to 4 contact pairs (CSS uses 2x2 grid). Anti-pattern to AVOID: putting contact info inline as <p> rows -- breaks the styled key:value alignment.",215}216217218# Output-language directive (language-persistence workstream). Values are219# platform locale codes; legacy full names are accepted via the allowlist.220# Anything else (wired upstream output, free-form strings) falls back to221# auto: the value is interpolated into the system prompt, so unknown222# strings must never pass through.223LANGUAGE_NAMES = {224    "de": "German", "en": "English", "es": "Spanish", "fr": "French",225    "it": "Italian", "ja": "Japanese", "ko": "Korean", "nl": "Dutch",226    "pt": "Portuguese", "ru": "Russian", "zh": "Chinese",227}228229230def resolve_language_name(language):231    """Full display name for an allowlisted language value, else None."""232    if not isinstance(language, str):233        return None234    value = language.strip()235    if not value or value == "auto":236        return None237    if value in LANGUAGE_NAMES:238        return LANGUAGE_NAMES[value]239    if value in LANGUAGE_NAMES.values():240        return value241    print(f"[language] unrecognized value {value!r} ignored - using auto", file=sys.stderr)242    return None243244245def language_directive(language):246    """System-prompt suffix forcing the output language; '' when auto."""247    name = resolve_language_name(language)248    if not name:249        return ""250    return (251        f"\n\nOUTPUT LANGUAGE: {name}. All slide text — headings, body copy, "252        f"table/chart labels, and speaker notes — MUST be written in {name}. "253        "This OVERRIDES any 'same language as the source document' rule in the "254        "layout instructions above. Keep proper nouns, product names, and "255        "verbatim quotes in their original language."256    )257258259def build_layout_instruction(template_name):260    """Build additional system prompt instruction for the selected layout template.261262    A layout this node has no instruction for (e.g. one of a removed built-in263    brand, still stored on an existing node) is treated like "auto": the264    layout name alone tells the model nothing it can follow.265    """266    if not template_name or template_name == "auto":267        return ""268    instruction = TEMPLATE_INSTRUCTIONS.get(template_name, "")269    if not instruction:270        print(f"Slide Layout {template_name!r} is not available - using auto", file=sys.stderr)271        return ""272    result = f"\n\nIMPORTANT LAYOUT INSTRUCTION: The user selected the '{template_name}' layout. {instruction}\nFollow this layout pattern precisely while adapting the content."273    if template_name.startswith("marina-") or template_name.startswith("emblema-"):274        result += "\n\nANIMATION: Do NOT add class='fragment' to any element. The entire slide MUST appear at once — no staggered reveal. Arrow keys move between slides, not between items within a slide."275    return result276277278# Tags that have no closing tag in HTML — they never go on the open-tag stack.279# (A browser builds an <img> from an <image> start tag.)280_VOID_TAGS = frozenset({281    "area", "base", "br", "col", "embed", "hr", "image", "img", "input",282    "link", "meta", "param", "source", "track", "wbr",283})284285# Tags whose closing tag is optional in HTML — leftover open ones at end-of-parse286# are tolerated (browsers auto-close them) so we don't fail otherwise-valid slides.287_OPTIONAL_CLOSE_TAGS = frozenset({288    "p", "li", "td", "th", "tr", "thead", "tbody", "tfoot",289    "option", "dt", "dd", "colgroup", "caption",290})291292# Literal placeholder tokens from the prompt's layout templates. If any survive293# into the output, the model echoed the template scaffold or leaked its scratch294# work instead of producing a real slide — treat that as invalid so we retry.295_PLACEHOLDER_TOKENS = (296    "HEADING_DERIVED_FROM_SOURCE_CONTENT",297    "HEADING_DERIVED_FROM",298    "SHORT EYEBROW",299    "SHORT SLIDE TITLE",300    "...code...",301)302303304class _SlideHtmlValidator(HTMLParser):305    """Parse a slide candidate and record structural problems.306307    A valid slide is EXACTLY ONE well-formed top-level <section> element with no308    stray text or extra elements outside it and no unclosed structural tags.309    """310311    def __init__(self):312        super().__init__(convert_charrefs=True)313        self.stack = []314        self.root_tag = None315        self.top_level_starts = 0316        self.text_outside = False317        self.parse_error = None318319    def _note_start(self, tag):320        if len(self.stack) == 0:321            self.top_level_starts += 1322            if self.root_tag is None:323                self.root_tag = tag324325    def handle_starttag(self, tag, attrs):326        self._note_start(tag)327        if tag not in _VOID_TAGS:328            self.stack.append(tag)329330    def handle_startendtag(self, tag, attrs):331        # Self-closing tag, e.g. <img/> or <br/> — counts toward top level but332        # never goes on the open-tag stack.333        self._note_start(tag)334335    def handle_endtag(self, tag):336        if tag in _VOID_TAGS:337            return338        if tag not in self.stack:339            # Closing tag with no matching open tag → malformed.340            if self.parse_error is None:341                self.parse_error = f"stray closing tag </{tag}>"342            return343        # Pop down to the matching open tag. Optional-close tags (<li>, <p>, …)344        # left open in between are fine — browsers auto-close them. But a345        # structural tag (<div>, <ul>, …) being closed implicitly by an346        # ancestor's end tag means it was never closed → malformed nesting.347        while self.stack and self.stack[-1] != tag:348            popped = self.stack.pop()349            if popped not in _OPTIONAL_CLOSE_TAGS and self.parse_error is None:350                self.parse_error = f"unclosed <{popped}> before </{tag}>"351        if self.stack:352            self.stack.pop()353354    def handle_data(self, data):355        if len(self.stack) == 0 and data.strip():356            self.text_outside = True357358    def error(self, message):  # pragma: no cover - legacy HTMLParser hook359        if self.parse_error is None:360            self.parse_error = message361362363def validate_slide_html(html, placeholders=True):364    """Validate that `html` is a single, well-formed Reveal.js <section>.365366    Returns (is_valid, reason). `reason` is empty when valid. `placeholders`:367    also reject the prompt's scaffold tokens (the model's own output; a368    layout name the node stamps may hold one).369    """370    if not html or not html.strip():371        return False, "empty output"372373    stripped = html.strip()374375    for token in _PLACEHOLDER_TOKENS if placeholders else ():376        if token in stripped:377            return False, f"contains unfilled template placeholder '{token}'"378379    parser = _SlideHtmlValidator()380    try:381        parser.feed(stripped)382        parser.close()383    except Exception as exc:  # noqa: BLE001 - any parse failure means "invalid"384        return False, f"unparseable HTML: {exc}"385386    if parser.parse_error:387        return False, parser.parse_error388    if parser.root_tag != "section":389        return False, f"top-level element is <{parser.root_tag}>, expected <section>"390    if parser.top_level_starts != 1:391        return False, (392            f"expected exactly one top-level <section>, found "393            f"{parser.top_level_starts} top-level elements (leaked reasoning or "394            f"multiple draft slides)"395        )396    if parser.text_outside:397        return False, "stray text outside the <section> (leaked reasoning or prose)"398399    unclosed = [t for t in parser.stack if t not in _OPTIONAL_CLOSE_TAGS]400    if unclosed:401        return False, f"unclosed tag(s): {', '.join(unclosed)}"402403    return True, ""404405406def _strip_code_fences(text):407    text = re.sub(r"^```(?:html)?\s*", "", text, flags=re.MULTILINE)408    text = re.sub(r"```\s*$", "", text, flags=re.MULTILINE)409    return text.strip()410411412def extract_section(html: str) -> str:413    # Extract the <section>...</section> from LLM output, handling markdown414    # fences and reasoning-model output that interleaves prose with one or more415    # draft sections.416    html = _strip_code_fences(html)417418    # NON-greedy so reasoning prose between draft sections is NOT swallowed by a419    # single greedy first-open-to-last-close match (the bug that produced decks420    # full of leaked scratch work and blank renders).421    candidates = re.findall(r"<section\b[\s\S]*?</section>", html, re.IGNORECASE)422    if candidates:423        # Reasoning models put the final answer last — prefer the last candidate424        # that validates; otherwise return the last one and let the caller's425        # retry loop catch the invalid output.426        for candidate in reversed(candidates):427            is_valid, _ = validate_slide_html(candidate)428            if is_valid:429                return candidate.strip()430        return candidates[-1].strip()431432    # No <section> at all — return the raw text so validation fails and the433    # caller retries instead of shipping a wrapped blob of prose.434    return html435436437def _fix_flex_styles(html):438    # Ensure flex children have min-width:0 to prevent overflow. LLMs output439    # flex:1 without min-width:0, causing text to expand and hide images.440    result = []441    i = 0442    tag = 'style="'443    while i < len(html):444        pos = html.find(tag, i)445        if pos == -1:446            # Also try single quotes447            pos2 = html.find("style='", i)448            if pos2 == -1:449                result.append(html[i:])450                break451            pos = pos2452            tag = "style='"453            q = "'"454        else:455            q = '"'456        result.append(html[i:pos])457        # Find end of style attribute458        start = pos + len('style=') + 1459        end = html.find(q, start)460        if end == -1:461            result.append(html[pos:])462            break463        style_val = html[start:end]464        # Fix: add min-width:0 if has flex:1 but not min-width465        if 'flex:' in style_val and '1' in style_val.split('flex:')[-1][:3] and 'min-width' not in style_val:466            style_val = style_val.rstrip(';') + ';min-width:0;overflow:hidden'467        result.append(f'style={q}{style_val}{q}')468        i = end + 1469        tag = 'style="'470    return ''.join(result)471472473# ---------------------------------------------------------------------------474# Layout design mode (a Layout Name, no Layout Library)475# ---------------------------------------------------------------------------476# The slide is designed as one layout of a brand master: the Presentation477# Composer joins the slides into a deck, and document-render's html-to-master478# route (apps/document-render/renderers/html_master_renderer.py) turns every479# slide into a PowerPoint slide layout. The slide's look becomes the layout480# background; every element marked data-slot becomes a placeholder whose prompt481# is its sample text (a picture's or a table's: its alt attribute), and482# data-layout-name on the <section> names the layout. The model marks the483# slots; the code stamps the name and keeps the marks to what the converter484# and the library accept.485486# The converter's data-slot values. `fixed` keeps the element in the background.487LAYOUT_SLOT_KINDS = ("title", "text", "bullets", "picture", "table", "number", "fixed")488# The converter keeps 120 characters of a layout name.489LAYOUT_NAME_MAX_CHARS = 120490_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")491492493def clean_layout_name(value):494    """The layout name to stamp on the slide, or "" (layout design mode off).495496    Control characters become spaces, whitespace runs collapse, and the name497    is capped as the converter caps it, so the stamped name is the layout id498    the library ends up with and stays the same for the same input.499    """500    if not isinstance(value, str):501        return ""502    name = " ".join(_CONTROL_CHARS_RE.sub(" ", value).split())503    return name[:LAYOUT_NAME_MAX_CHARS].rstrip()504505506def build_layout_design_instruction(layout_name, template_name=None):507    """System prompt addition for layout design mode ("" without a name).508509    `template_name` is the built-in layout whose instruction is already in the510    prompt (None for auto): its structure still applies, with the slots marked.511    """512    if not layout_name:513        return ""514    if template_name:515        structure = (516            f"STRUCTURE: keep the structure, classes and attributes the '{template_name}' layout above "517            "requires (they are what make it render in the deck theme) and add the data-slot marks to its "518            "elements."519        )520    else:521        structure = (522            "STRUCTURE: design the layout freely within the deck theme, following the rules above on "523            "markup and colors."524        )525    return (526        "\n\nLAYOUT DESIGN MODE: do NOT write a finished slide. Design ONE reusable slide LAYOUT, named "527        f"{_quoted(layout_name)}, for a brand master. The slide is turned into a PowerPoint slide layout: its "528        "look becomes the layout's background and every element you mark with data-slot becomes a placeholder "529        "that real content fills later. This mode OVERRIDES every rule above about the slide's content: write "530        "no real facts, names, numbers, dates or quotes, and ignore any rule to take headings, figures or text "531        "from the source content. The instruction says what layout to design; the source content, if any, "532        "only hints at its topic and tone.\n"533        "SLOTS: add data-slot=\"KIND\" to every element that content will replace. KIND is one of:\n"534        "- title: the slide title. Use it once.\n"535        "- text: a text block (a subtitle, a paragraph, a label, a caption, a card's heading or body).\n"536        "- bullets: a list. Put it on the <ul> or <ol> itself, not on its <li>.\n"537        "- picture: the place for a photo. AT MOST ONE per layout: when the design shows several photos, mark "538        "one and leave the others unmarked. Use a box element the layout sizes (its photo or visual panel, or "539        "a <div> with a width and a height); use an <img> only for a provided image.\n"540        "- table: a <table>.\n"541        "- number: the page or slide number. If the layout has a page number (e.g. <div class='page-num'>), "542        "mark it data-slot=\"number\". Only the page number: PowerPoint fills it in, so a section number, a "543        "key figure, a statistic or a step number is text.\n"544        "- fixed: an element that is the same on every slide (a logo, a brand mark, a fixed label). It stays "545        "in the background.\n"546        "Unmarked elements stay in the background too, so leave decoration unmarked or mark it "547        "data-slot=\"fixed\". Do not nest marks (a mark inside a marked element is ignored), and never mark "548        "the <section> or anything inside <aside class='notes'>.\n"549        "SAMPLE TEXT: the text of each slot (for a picture or a table: its alt attribute) is the hint that "550        "later tells the writer what goes there. Write SHORT GUIDANCE in the slide's output language that "551        "says what the slot holds and how long it is, never the content itself: e.g. \"Section title, 3-6 "552        "words\" (in Italian: \"Titolo della sezione, 3-6 parole\"), alt=\"Product photo, landscape\". Give a "553        "bullets slot 3 to 5 items of guidance (e.g. \"Key point, at most 8 words\"). In repeated blocks "554        "(cards, columns, steps) mark the same elements in every block, with matching hints. Do not copy "555        "scaffold placeholders such as HEADING_DERIVED_FROM_SOURCE_CONTENT or SHORT EYEBROW, and do not use "556        "picsum.photos or any other external image URL: the layout is converted without network access.\n"557        f"{structure}\n"558        "In <aside class='notes'>, write one sentence saying what this layout is for."559    )560561562# Start tags that end an open element the browser closes implicitly (a <li>563# ends an open <li>, a block ends an open <p>, ...): enough of the HTML tree564# rules to tell which data-slot marks are outermost, as the converter sees them.565_IMPLIED_CLOSE = {566    "li": {"li"},567    "dt": {"dt", "dd"},568    "dd": {"dt", "dd"},569    "tr": {"tr", "td", "th"},570    "td": {"td", "th"},571    "th": {"td", "th"},572    "thead": {"thead", "tbody", "tfoot", "tr", "td", "th"},573    "tbody": {"thead", "tbody", "tfoot", "tr", "td", "th"},574    "tfoot": {"thead", "tbody", "tfoot", "tr", "td", "th"},575    "option": {"option"},576}577_CLOSES_P = frozenset({578    "address", "article", "aside", "blockquote", "details", "div", "dl", "fieldset", "figcaption",579    "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "main",580    "menu", "nav", "ol", "p", "pre", "section", "table", "ul",581})582583584def _opens_only(entry):585    """A self-closed tag the browser may leave open, swallowing what follows.586587    In HTML `<div ... />` only opens the div, and so does a div inside <svg>588    (HTML elements break out of it) or <foreignObject>. Inside SVG and MathML589    `<path></path>` is the same as `<path/>`, so closing every self-closed590    element that is not void is right wherever it stands.591    """592    return entry["self_closing"] and entry["tag"] not in _VOID_TAGS593594595class _SlotMarkScanner(HTMLParser):596    """Every start tag of a slide, where it is in the source and how it is marked.597598    Each entry of `tags` is a dict: start (offset in the source), raw (the tag599    as written), tag, attrs, self_closing, root (the first top-level element),600    parent (index of the enclosing element's entry, or None), nested (inside601    an element whose mark counts), in_notes, classes, marked (it carries a602    data-slot attribute), holds_marks (an element inside it does), and kind:603    the element's data-slot value lowercased and trimmed when the mark604    counts, else None. A mark does not count on the root, inside605    <aside class="notes"> (never shown) or with a value the converter does not606    know.607    """608609    def __init__(self, source):610        super().__init__(convert_charrefs=True)611        self.source = source612        self.line_starts = [0] + [m.end() for m in re.finditer("\n", source)]613        self.tags = []614        self.stack = []  # open elements: (tag, marked, in_notes, index in tags or None)615        self._end = 0616        self._has_root = False617618    def _offset(self, raw):619        line, col = self.getpos()620        start = self.line_starts[line - 1] + col621        if self.source[start:start + len(raw)] != raw:622            start = self.source.find(raw, self._end)623        return start624625    def _close_implied(self, tag):626        closes = set(_IMPLIED_CLOSE.get(tag, ()))627        if tag in _CLOSES_P:628            closes.add("p")629        target = None630        i = len(self.stack) - 1631        while i >= 0 and self.stack[i][0] in _OPTIONAL_CLOSE_TAGS:632            if self.stack[i][0] in closes:633                target = i634            i -= 1635        if target is not None:636            del self.stack[target:]637638    def _start(self, tag, attrs, self_closing):639        raw = self.get_starttag_text() or ""640        start = self._offset(raw)641        self._close_implied(tag)642        parent_marked, parent_notes = (self.stack[-1][1], self.stack[-1][2]) if self.stack else (False, False)643        classes = next((v for k, v in attrs if k == "class"), None) or ""644        in_notes = parent_notes or (tag == "aside" and "notes" in classes.split())645        root = not self._has_root646        self._has_root = True647        values = [v for k, v in attrs if k == "data-slot"]648        kind = (values[0] or "").strip().lower() if values else None649        if root or in_notes or kind not in LAYOUT_SLOT_KINDS:650            kind = None651        if values:652            for open_tag in self.stack:653                if open_tag[3] is not None:654                    self.tags[open_tag[3]]["holds_marks"] = True655        index = None656        if start >= 0:657            self._end = start + len(raw)658            index = len(self.tags)659            parent = next((entry[3] for entry in reversed(self.stack) if entry[3] is not None), None)660            self.tags.append({661                "start": start, "raw": raw, "tag": tag, "attrs": attrs, "self_closing": self_closing,662                "root": root, "parent": parent, "nested": parent_marked, "kind": kind, "in_notes": in_notes,663                "classes": classes, "marked": bool(values), "holds_marks": False,664            })665        if not self_closing and tag not in _VOID_TAGS:666            self.stack.append((tag, parent_marked or kind is not None, in_notes, index))667668    def handle_starttag(self, tag, attrs):669        self._start(tag, attrs, False)670671    def handle_startendtag(self, tag, attrs):672        self._start(tag, attrs, True)673674    def handle_endtag(self, tag):675        names = [entry[0] for entry in self.stack]676        if tag in _VOID_TAGS or tag not in names:677            return678        del self.stack[len(names) - 1 - names[::-1].index(tag):]679680681def _with_attr(attrs, name, value):682    """`attrs` with every `name` attribute removed and, unless `value` is None,683    one `name` attribute where the first one was (else at the end)."""684    out, placed = [], False685    for key, val in attrs:686        if key != name:687            out.append((key, val))688        elif value is not None and not placed:689            out.append((name, value))690            placed = True691    if value is not None and not placed:692        out.append((name, value))693    return out694695696# The words that name a photo in a class (photo, hero-photo, main_image,697# photo-col): _photo_box takes a class whose last word (after the last - or _)698# is one, so image-caption and photo-tag are not photo boxes; _photo_frame takes699# any word, so the magazine's photo-col frames its picture.700_PHOTO_WORDS = frozenset({"photo", "visual", "image", "img", "picture"})701# The tags of an image: a browser builds an <img> from an <image> start tag.702_IMAGE_TAGS = ("img", "image")703# The class of a page number, which tells it from the other number marks704# (the converter keeps one slide number).705_PAGE_NUMBER_CLASS = re.compile(r"(^|\s)(?:page-?num(?:ber)?|slide-?num(?:ber)?|corner-index)(\s|$)", re.IGNORECASE)706# A transparent pixel. The converter's page has no network: an external707# picture would be a broken image baked into the layout's background.708_BLANK_IMAGE = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"709710711def _photo_box(tags):712    """Index of the first photo box no one marked: an image, or an element713    with a class whose last word is photo, visual, image, img or picture that714    holds no mark itself."""715    for index, entry in enumerate(tags):716        if entry["root"] or entry["in_notes"] or entry["nested"] or entry["marked"] or entry["holds_marks"]:717            continue718        if entry["tag"] in _IMAGE_TAGS or any(719            re.split(r"[-_]+", token.lower())[-1] in _PHOTO_WORDS for token in entry["classes"].split()720        ):721            return index722    return None723724725def _photo_frame(tags, index):726    """Index of the element the blank image at `index` is meant to fill, or727    None: its nearest enclosing element with a photo word anywhere in a class728    (the magazine's photo-col), else its parent when the image is that729    element's only child (the cover's .right). The frame may hold a fixed mark730    (a photo tag) but no other. A blank image has no size of its own."""731    children = {}732    for i, entry in enumerate(tags):733        children[entry["parent"]] = children.get(entry["parent"], 0) + 1734    frame = parent = tags[index]["parent"]735    while parent is not None and not any(736        _PHOTO_WORDS & set(re.split(r"[-_]+", token.lower())) for token in tags[parent]["classes"].split()737    ):738        parent = tags[parent]["parent"]739    frame = parent if parent is not None else (frame if frame is not None and children[frame] == 1 else None)740    if frame is None or tags[frame]["root"] or tags[frame]["in_notes"] or tags[frame]["kind"] is not None:741        return None742    # Tags come in document order, an element's children after it: one pass743    # finds everything inside the frame.744    inside = {frame}745    for i in range(frame + 1, len(tags)):746        if tags[i]["parent"] in inside:747            inside.add(i)748            if i != index and tags[i]["kind"] not in (None, "fixed"):749                return None750    return frame751752753# Characters a browser strips around a URL (C0 controls and space) and removes754# inside it (tabs and newlines).755_URL_EDGE = "".join(chr(c) for c in range(0x21))756757758def _linked(src):759    """Whether an image source would load something: anything but inline data760    (data:) and the node's {{IMAGE}} placeholder, read as a browser reads a761    URL (`ht\\ntps://x` is https://x). The converter's page has no network."""762    url = re.sub(r"[\t\n\r]", "", src or "").strip(_URL_EDGE)763    return bool(url) and url != "{{IMAGE}}" and url[:5].lower() != "data:"764765766# The root <section>'s attributes a layout keeps: Reveal's others (data-767# visibility, data-background-image, ...) hide the slide or load a file.768_SECTION_ATTRIBUTES = ("class", "style", "id", "dir", "lang", "data-header-title")769770771def _closed(raw):772    """`<name ... />` closed: `<name ...></name>`, the tag otherwise as written773    (_readable_markup wrote it). String steps, not a regex: a `\\s*` scan of a774    long whitespace run in the tag is quadratic."""775    name = re.match(r"<\s*([^\s/>]+)", raw).group(1)776    body = raw[:-1].rstrip()777    if body.endswith("/"):778        body = body[:-1].rstrip()779    return f"{body}></{name}>"780781782# A tag and an attribute name the code writes: anything else (a name with a783# quote, a `<` or an `=`, or outside ASCII) is no name a slide needs, and could784# read differently in a browser than in Python's parser.785_TAG_NAME_RE = re.compile(r"[a-z][a-z0-9_.:-]*")786_ATTRIBUTE_NAME_RE = re.compile(r"[a-z_:][a-z0-9_:.-]*")787788789# What a slide never needs and Python's parser and a browser read differently,790# or that reaches outside the slide, is left out. Elements whose content is791# text go with their content (Python's list of them depends on its version, a792# browser's on the namespace: <title> holds text in HTML and tags in SVG); the793# page's own tags (a stray <body style> is merged into the deck's <body>),794# link, meta and base, and <dialog> (shown only when opened) go as tags, and795# what they hold stays.796_DROPPED_ELEMENTS = frozenset({797    "script", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript", "plaintext", "template",798    "dialog",799})800_DROPPED_TAGS = frozenset({"html", "head", "body", "frameset", "frame", "link", "meta", "base", "object", "embed"})801# Attributes that hide an element without CSS (the converter would drop a802# slot), load a file (Reveal copies data-src into src when a slide shows), run803# script, or set a color or font past the brand kit; display, visibility and804# opacity stay when they keep the element shown (an SVG gridline's opacity).805_HIDING_ATTRIBUTES = frozenset({"hidden", "popover"})806_SHOWING_ATTRIBUTES = frozenset({"display", "visibility", "opacity"})807_LOADING_ATTRIBUTES = frozenset({"data-src", "data-srcset", "srcset", "imagesrcset", "poster", "background", "ping"})808_STYLING_ATTRIBUTES = frozenset({"color", "face", "bgcolor", "text", "link", "vlink", "alink", "font-family"})809# A url() in an attribute (an SVG fill, filter or mask) may point inside the810# slide (url(#gradient)) only.811_ATTRIBUTE_URL_RE = re.compile(r"url\(\s*['\"]?\s*(?!#)", re.IGNORECASE)812_HTML_SPACE = "\t\n\f\r "813_COMMENT_END_RE = re.compile(r"--!?>")814815816def _read_tag(html, i):817    """(name, [(name, raw value or None)], self-closing, index after the tag)818    of the tag whose name starts at `i`, read as a browser's tokenizer reads819    it (names lowercased A-Z only); None when the slide ends inside it, where a820    browser drops it."""821    n, j = len(html), i822    while j < n and html[j] not in "\t\n\f\r />":823        j += 1824    name, attrs = html[i:j].translate(_ASCII_LOWER), []825    while True:826        while j < n and html[j] in _HTML_SPACE:827            j += 1828        if j >= n:829            return None830        if html[j] == ">":831            return name, attrs, False, j + 1832        if html[j] == "/":833            if html[j + 1:j + 2] == ">":834                return name, attrs, True, j + 2835            j += 1836            continue837        start, j = j, j + 1  # a name may start with '='838        while j < n and html[j] not in "\t\n\f\r />=":839            j += 1840        key, value = html[start:j].translate(_ASCII_LOWER), None841        while j < n and html[j] in _HTML_SPACE:842            j += 1843        if j < n and html[j] == "=":844            j += 1845            while j < n and html[j] in _HTML_SPACE:846                j += 1847            if j < n and html[j] in "\"'":848                end = html.find(html[j], j + 1)849                if end == -1:850                    return None851                value, j = html[j + 1:end], end + 1852            else:853                start = j854                while j < n and html[j] not in "\t\n\f\r >":855                    j += 1856                value = html[start:j]857        attrs.append((key, value))858859860# A numeric character reference Python's parser decodes to nothing (&#1;, a861# noncharacter) and a browser to the character: written as text, both read it862# the same.863_DROPPED_CHARREF_RE = re.compile(r"&(?=#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?)")864865866def _python_drops(match):867    code = int(match.group(1), 16) if match.group(1) else int(match.group(2))868    return code < 0x110000 and html_unescape(f"&#{code};") == ""869870871def _written_tag(name, attrs, self_closing):872    """A start tag written from raw values (character references as written,873    as _read_tag reads them): each name once (a browser keeps the first),874    only names of _ATTRIBUTE_NAME_RE, values in double quotes with ", < and >875    escaped, so the tag ends where every parser ends it, and a character876    reference Python's parser drops written as text (_DROPPED_CHARREF_RE)."""877    seen, parts = set(), [name]878    for key, value in attrs:879        if key in seen or not _ATTRIBUTE_NAME_RE.fullmatch(key):880            continue881        seen.add(key)882        value = (value or "").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")883        value = _DROPPED_CHARREF_RE.sub(lambda m: "&amp;" if _python_drops(m) else "&", value)884        parts.append(f'{key}="{value}"')885    return "<" + " ".join(parts) + (" />" if self_closing else ">")886887888def _kept_attribute(tag, key, value):889    """Whether an attribute of a start tag stays (`value` decoded): not one890    that hides the element, loads a file, runs script or styles past the brand891    kit; a src only on an image (mark_layout_slots blanks it) or when inline,892    an href only on a link or when it points inside the slide."""893    if key in _HIDING_ATTRIBUTES or key in _LOADING_ATTRIBUTES or key in _STYLING_ATTRIBUTES:894        return False895    if key.startswith("on") or (tag == "details" and key == "name"):  # a handler; an exclusive group closes one896        return False897    if key in _SHOWING_ATTRIBUTES:898        return _shown_value(key, _css_tokens(value)) is not None899    if key == "src" and tag not in _IMAGE_TAGS:900        return not _linked(value)901    if key in ("href", "xlink:href"):902        url = re.sub(r"[\t\n\r]", "", value).strip(_URL_EDGE).lower()903        if tag == "a":904            return not url.startswith(("javascript:", "vbscript:", "data:"))905        return url.startswith(("#", "data:"))906    return key == "style" or not _ATTRIBUTE_URL_RE.search(value)907908909def _element_end(html, lower, name, i):910    """Index after the end of the `name` element whose content starts at `i`911    (its end tag; nested ones counted for <template>, whose content is912    markup), or the end of `html`."""913    depth, pos, n = 1, i, len(html)914    while True:915        close = _tag_at(lower, f"</{name}", pos)916        if name == "template":917            opened = _tag_at(lower, "<template", pos)918            if opened != -1 and (close == -1 or opened < close):919                depth, pos = depth + 1, opened + 9920                continue921        if close == -1:922            return n923        depth -= 1924        end = _read_tag(html, close + 2)925        pos = n if end is None else end[3]926        if depth == 0 or pos == n:927            return pos928929930def _readable_markup(html):931    """`html` written again as a browser reads it, in a form Python's HTML932    parser reads the same way: every tag written by _written_tag (an end tag933    without attributes) with the attributes _kept_attribute keeps (a closed934    <details> is opened), and every other `<` written `&lt;`, which both read935    as text. Comments, CDATA, doctypes, processing instructions and bogus end936    tags go (a browser shows none; `<!-->` ends a comment), and so do937    _DROPPED_ELEMENTS with their content and _DROPPED_TAGS; a comment or tag938    left open at the end is text. A <style> keeps its CSS with each `<` as939    `\\3c ` (no tag can hide in it, in HTML or SVG) and ends with </style>.940    The slot and style cleaners then see the tags a browser sees."""941    html = html.replace("\x00", "")942    lower = html.translate(_ASCII_LOWER)  # str.lower() changes some lengths (U+0130)943    out, i, n = [], 0, len(html)944    unclosed_comments = n  # from here on no comment is closed945    while True:946        lt = html.find("<", i)947        if lt == -1:948            out.append(html[i:])949            return "".join(out)950        out.append(html[i:lt])951        i = lt + 1952        following = lower[lt + 1:lt + 2]953        end_tag = following == "/" and "a" <= lower[lt + 2:lt + 3] <= "z"954        if lower.startswith("<!--", lt):955            if lower.startswith((">", "->"), lt + 4):  # <!--> and <!---> end at once956                i = lower.index(">", lt + 4) + 1957                continue958            close = _COMMENT_END_RE.search(lower, lt + 4) if lt < unclosed_comments else None959            if close:960                i = close.end()961            else:962                unclosed_comments = lt963                out.append("&lt;")964            continue965        if lower.startswith("<![cdata[", lt):966            close = lower.find("]]>", lt + 9)967            if close != -1:968                i = close + 3969                continue970        if following in ("!", "?") or (following == "/" and not end_tag):971            # a doctype, a processing instruction (<?xml ...?>), a bogus end tag: a browser shows none of them972            close = lower.find(">", lt + 1)973            if close != -1:974                i = close + 1975                continue976        if not ("a" <= following <= "z" or end_tag):977            out.append("&lt;")978            continue979        tag = _read_tag(html, lt + (2 if end_tag else 1))980        if tag is None or not _TAG_NAME_RE.fullmatch(tag[0]):981            out.append("&lt;")982            continue983        name, attrs, self_closing, after = tag984        if name in _DROPPED_ELEMENTS:985            i = after986            if not end_tag and not self_closing:987                i = _element_end(html, lower, name, after)988            continue989        i = after990        if name in _DROPPED_TAGS:991            continue992        if end_tag:993            out.append(f"</{name}>")994            continue995        attrs = [(k, v) for k, v in attrs if _kept_attribute(name, k, html_unescape(v or ""))]996        if name == "details" and not any(k == "open" for k, _ in attrs):997            attrs.append(("open", ""))998        out.append(_written_tag(name, attrs, self_closing))999        if name == "style" and not self_closing:1000            close = _tag_at(lower, "</style", i)1001            # \3c in CSS (a string's "<") is text in SVG too, where a style holds markup1002            out.append(html[i:n if close == -1 else close].replace("<", "\\3c ") + "</style>")1003            if close == -1:1004                return "".join(out)1005            end = _read_tag(html, close + 2)1006            i = n if end is None else end[3]100710081009def _rewritten(entry, attrs, self_closing=None):1010    """The tag of a scanner `entry` written with `attrs` (as the HTML parser1011    decodes them): an attribute whose value did not change keeps its raw1012    text (a URL's &copy= stays as written, which a browser leaves too), a new1013    or changed one is escaped."""1014    raw = dict(_read_tag(entry["raw"], 1)[1])1015    old = dict(entry["attrs"])1016    values = [1017        (key, raw[key] if key in raw and old.get(key) == val else html_escape(val or "", quote=True))1018        for key, val in attrs1019    ]1020    return _written_tag(entry["tag"], values, entry["self_closing"] if self_closing is None else self_closing)102110221023def mark_layout_slots(html, layout_name):1024    """The slide as a layout: `layout_name` stamped as data-layout-name on its1025    <section> (any the model wrote is replaced), and its data-slot marks kept1026    to what the converter and the library accept.10271028    - a value outside LAYOUT_SLOT_KINDS, a mark on the <section> and a mark in1029      the speaker notes are removed;1030    - among the outermost marks (the ones the converter reads), the first1031      picture and the first title stay; a later picture becomes fixed (a1032      library layout takes at most one picture) and a later title text;1033    - a self-closed HTML element (`<div data-slot="picture" />`) is closed1034      (`<div ...></div>`): the browser only opens it, and it would swallow1035      the rest of the slide, slots included (in SVG the two are the same);1036    - when the model marked content but no picture, the first photo box1037      (_photo_box) becomes the picture: a photo the model forgot to mark is1038      otherwise background. A slide with no marks at all is left to the1039      converter's own rules;1040    - of more than one number mark, the one whose class names a page number1041      (_PAGE_NUMBER_CLASS) stays the number; the others are figures (key1042      numbers, steps) and become text, which keeps them as placeholders (a1043      layout has one page number, and the converter keeps one slide number);1044    - the <section> keeps only _SECTION_ATTRIBUTES;1045    - an image (<img>, or <image>, which a browser makes an <img>) with a1046      link (_linked), or no source, gets a transparent pixel instead: the1047      converter's page has no network, and an empty src draws the browser's1048      broken-image icon; a srcset, which would load instead, is removed;1049    - a picture that is such a blank image gives its mark (and its alt, the1050      picture's hint) to its frame (_photo_frame), when it has one: a blank1051      image has no size of its own.1052    The slide is first written again as a browser reads it1053    (_readable_markup); tags that need no change then keep that text.1054    """1055    html = _readable_markup(html)1056    scanner = _SlotMarkScanner(html)1057    scanner.feed(html)1058    scanner.close()1059    seen = set()1060    counts = {}1061    dropped = demoted_pictures = demoted_titles = closed = blanked = emptied = 01062    boxed = framed = False1063    picture = None  # index of the picture mark1064    numbers = []  # indices of the outermost number marks1065    tags = scanner.tags1066    changed = {}  # index in tags -> the tag's new attributes1067    for index, entry in enumerate(tags):1068        attrs = entry["attrs"]1069        if entry["root"]:1070            if entry["tag"] == "section":1071                dropped += any(k == "data-slot" for k, _ in attrs)1072                attrs = [(k, v) for k, v in attrs if k in _SECTION_ATTRIBUTES]1073                changed[index] = _with_attr(attrs, "data-layout-name", layout_name)1074            continue1075        if entry["tag"] == "section":1076            attrs = [(k, v) for k, v in attrs if k in _SECTION_ATTRIBUTES or k == "data-slot"]1077        values = [v for k, v in attrs if k == "data-slot"]1078        if values:1079            kind = entry["kind"]1080            if kind is None:1081                dropped += 11082            elif not entry["nested"]:1083                if kind in ("picture", "title") and kind in seen:1084                    if kind == "picture":1085                        kind, demoted_pictures = "fixed", demoted_pictures + 11086                    else:1087                        kind, demoted_titles = "text", demoted_titles + 11088                if kind == "picture":1089                    picture = index1090                seen.add(kind)1091                counts[kind] = counts.get(kind, 0) + 11092                if kind == "number":1093                    numbers.append(index)1094            if values != ([kind] if kind else []):1095                attrs = _with_attr(attrs, "data-slot", kind)1096        if entry["tag"] in _IMAGE_TAGS:1097            src = next((v for k, v in attrs if k == "src"), None)1098            if _linked(src):1099                attrs, blanked = _with_attr(attrs, "src", _BLANK_IMAGE), blanked + 11100            elif not (src or "").strip():1101                attrs, emptied = _with_attr(attrs, "src", _BLANK_IMAGE), emptied + 11102        if entry["tag"] in (*_IMAGE_TAGS, "source") and any(k == "srcset" for k, _ in attrs):1103            attrs = _with_attr(attrs, "srcset", None)  # it would load instead of src1104        if attrs is not entry["attrs"]:1105            changed[index] = attrs1106    figures = []1107    if len(numbers) > 1:1108        # The page number, if one of them is it, stays the number.1109        page = next((i for i in numbers if _PAGE_NUMBER_CLASS.search(tags[i]["classes"])), None)1110        figures = [i for i in numbers if i != page]1111        for index in figures:1112            changed[index] = _with_attr(changed.get(index, tags[index]["attrs"]), "data-slot", "text")1113        counts["text"] = counts.get("text", 0) + len(figures)1114        if page is None:1115            counts.pop("number")1116        else:1117            counts["number"] = 11118    if any(k != "fixed" for k in counts) and "picture" not in counts:1119        index = _photo_box(tags)1120        if index is not None:1121            changed[index] = _with_attr(changed.get(index, tags[index]["attrs"]), "data-slot", "picture")1122            counts["picture"] = 11123            boxed, picture = True, index1124    image = changed.get(picture, tags[picture]["attrs"]) if picture is not None else []1125    frame = _photo_frame(tags, picture) if picture is not None and tags[picture]["tag"] in _IMAGE_TAGS and (1126        next((v for k, v in image if k == "src"), None) == _BLANK_IMAGE) else None1127    if frame is not None:1128        changed[picture] = _with_attr(image, "data-slot", None)1129        attrs = _with_attr(changed.get(frame, tags[frame]["attrs"]), "data-slot", "picture")1130        alt = next((v for k, v in image if k == "alt"), None)1131        changed[frame] = attrs if not alt or any(k == "alt" for k, _ in attrs) else _with_attr(attrs, "alt", alt)1132        framed = True1133    edits = [1134        (entry, changed.get(index, entry["attrs"]))1135        for index, entry in enumerate(tags)1136        if index in changed or _opens_only(entry)1137    ]11381139    out, pos = [], 01140    for entry, attrs in edits:1141        out.append(html[pos:entry["start"]])1142        if _opens_only(entry) and attrs is entry["attrs"]:1143            out.append(_closed(entry["raw"]))1144            closed += 11145        elif _opens_only(entry):1146            out.append(_rewritten(entry, attrs, False) + f"</{entry['tag']}>")1147            closed += 11148        else:1149            out.append(_rewritten(entry, attrs))1150        pos = entry["start"] + len(entry["raw"])1151    out.append(html[pos:])11521153    summary = ", ".join(f"{k} {counts[k]}" for k in LAYOUT_SLOT_KINDS if k in counts) or "none"1154    notes = []1155    if demoted_pictures:1156        notes.append(f"{demoted_pictures} extra picture(s) -> fixed")1157    if demoted_titles:1158        notes.append(f"{demoted_titles} extra title(s) -> text")1159    if dropped:1160        notes.append(f"{dropped} data-slot mark(s) removed")1161    if closed:1162        notes.append(f"{closed} self-closed tag(s) closed")1163    if boxed:1164        notes.append("the unmarked photo box became the picture")1165    if framed:1166        notes.append("the blank picture's mark moved to its photo frame")1167    if figures:1168        notes.append(f"{len(figures)} number marks -> text (figures, not the page number)")1169    if blanked:1170        notes.append(f"{blanked} linked image(s) blanked")1171    if emptied:1172        notes.append(f"{emptied} image(s) without a source blanked")1173    print(1174        f"Layout {layout_name!r}: slots {summary}" + (f"; {'; '.join(notes)}" if notes else ""),1175        file=sys.stderr,1176    )1177    if not any(k != "fixed" for k in counts):1178        print("Layout has no data-slot placeholder: the converter will guess its slots", file=sys.stderr)1179    return "".join(out)118011811182# ---------------------------------------------------------------------------1183# Design (layout design mode)1184# ---------------------------------------------------------------------------1185# `design` is the brand's design rules (a design.md: shapes, borders, shadows,1186# type weight and case, spacing). In layout design mode it ranks above the1187# instruction: the instruction says which layout this is and what it holds,1188# the design says how it looks. Without one, rules 8-10 forbid styling and the1189# deck theme gives every layout its look; with one, the model may write one1190# <style> block, which the code keeps to this layout: every rule is scoped to1191# the layout's <section data-layout-name>, so one layout's CSS never restyles1192# another in the composed deck. Colors stay the brand kit's: the block may1193# name the theme's color variables but no color of its own, so a color change1194# in the kit still recolors every layout, and the fonts stay the kit's.11951196DESIGN_MAX_CHARS = 80001197DESIGN_CSS_MAX_CHARS = 120001198DESIGN_DROPPED_MAX_CHARS = 60011991200# The theme's color variables, which the Presentation Composer's brand kit sets.1201_DESIGN_COLOR_VARS = (1202    "var(--et-500) (the brand's primary), var(--et-600) (darker), var(--et-400), var(--et-300), "1203    "var(--et-100), var(--et-50) (lighter tints), var(--text-primary), var(--text-secondary), "1204    "var(--text-tertiary) (muted), var(--text-on-color), var(--bg), var(--bg-soft), var(--bg-card), "1205    "var(--border-strong), var(--border-subtle) (hairlines)"1206)12071208# What the Emblema theme draws that the slide's markup does not show: its1209# pseudo-elements and gradient fills (_EMBLEMA_CSS in the Presentation1210# Composer). A layout is told about the entries whose classes its built-in1211# layout uses, so the model can remove a glow and keep an agenda's numbers.1212THEME_DECORATION = (1213    (".brand-mark::before", "the brand mark's round dot", "decoration"),1214    (".photo", "rounded corners and a gradient fill on each photo placeholder", "decoration"),1215    (".photo::after", "a soft shading over each photo placeholder", "decoration"),1216    (".slide-cover .right::after", "a soft radial glow over the right panel", "decoration"),1217    (".slide-agenda li::before", "each agenda item's number", "content"),1218    (".slide-section::before", "a large soft gradient circle", "decoration"),1219    (".slide-image::before", "a gradient over the background image", "decoration"),1220    (".slide-image .overlay", "a gradient behind the text", "decoration"),1221    (".slide-matrix thead th.featured::after", "the featured column's rounded badge", "decoration"),1222    (".slide-timeline .line::before", "the timeline's line", "content"),1223    (".slide-testimonial .quote::before", "the opening quote mark", "content"),1224    (".slide-question .q::after", "the question mark", "content"),1225    (".slide-code pre::before", "the code window's three dots", "decoration"),1226    (".slide-closing .right", "a gradient fill on the right panel", "decoration"),1227    (".slide-closing .right::after", "a soft glow over the right panel", "decoration"),1228)12291230_HTML_COMMENT_RE = re.compile(r"<!--.*?(?:-->|$)", re.DOTALL)1231# A-Z to a-z and nothing else, so offsets in the result are offsets in the source.1232_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")1233# A </style> left in the slide after its blocks are taken out: written as1234# text, so it can never end the block the code adds.1235_STYLE_TAG_LEFT_RE = re.compile(r"<(/?)(style)(?=[\s/>]|$)", re.IGNORECASE)1236# A ```css block in the design: its rules apply to every layout.1237_DESIGN_CSS_RE = re.compile(r"```css[ \t]*\n(.*?)```", re.IGNORECASE | re.DOTALL)1238# A color of the model's own (the kit's colors come as variables): CSS's named1239# colors and system colors. transparent and currentColor are not one.1240_NAMED_COLORS = frozenset("""1241aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood1242cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray1243darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen1244darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue1245firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew1246hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan1247lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray1248lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue1249mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred1250midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid1251palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple1252rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue1253slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white1254whitesmoke yellow yellowgreen1255accentcolor accentcolortext activetext buttonborder buttonface buttontext canvas canvastext field fieldtext graytext1256highlight highlighttext linktext mark marktext selecteditem selecteditemtext visitedtext1257activeborder activecaption appworkspace background buttonhighlight buttonshadow captiontext inactiveborder1258inactivecaption inactivecaptiontext infobackground infotext menu menutext scrollbar threeddarkshadow threedface1259threedhighlight threedlightshadow threedshadow window windowframe windowtext1260""".split())1261# A keyword with a vendor prefix (-webkit-link is a color) is not a value the design needs.1262_VENDOR_PREFIXES = ("-webkit-", "-moz-", "-ms-", "-o-", "-internal-")12631264# The design's CSS, and the model's, is kept to an allowlist. It is read token1265# by token as CSS Syntax reads it (a comment is a space), every rule is checked1266# against the small grammar below, and what passes is written again from its1267# tokens: the slide only ever gets CSS the code wrote, so no comment, escape or1268# spelling can mean one thing to the cleaner and another to the browser.1269_CSS_TOKEN_RE = re.compile(1270    r"(?P<space>(?:\s|/\*.*?(?:\*/|\Z))+)"1271    r"|(?P<string>\"(?:[^\"\\\n]|\\[0-9a-f]{1,6}[ \t]?)*\"|'(?:[^'\\\n]|\\[0-9a-f]{1,6}[ \t]?)*')"1272    r"|(?P<number>[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:%|[a-z]+)?)"1273    r"|(?P<function>-?[a-z_][a-z0-9_-]*)\("1274    r"|(?P<ident>-?-?[a-z_][a-z0-9_-]*)"1275    r"|(?P<hash>#[a-z0-9_-]+)"1276    r"|(?P<delim>[@{}()\[\]:;,.>+~*/=!|^$-])"1277    r"|(?P<other>.)",1278    re.IGNORECASE | re.ASCII | re.DOTALL,1279)1280_CSS_ESCAPE_RE = re.compile(r"\\([0-9a-f]{1,6})[ \t]?", re.IGNORECASE)1281_CSS_NUMBER_RE = re.compile(r"([+-]?(?:\d+(?:\.\d+)?|\.\d+))(%|[a-z]*)", re.IGNORECASE | re.ASCII)1282_CSS_NAME_RE = re.compile(r"-?[a-z_][a-z0-9_-]*", re.IGNORECASE | re.ASCII)12831284# Selectors: elements, `*`, classes, ids, attributes, these pseudo-classes,1285# :not() of one compound selector and a final ::before or ::after, joined by a1286# space or `>`. A + or ~ would reach the slides after this one in the deck.1287_PSEUDO_CLASSES = ("first-child", "last-child", "only-child", "first-of-type", "last-of-type", "only-of-type",1288                   "empty")1289_NTH_PSEUDO_CLASSES = ("nth-child", "nth-last-child", "nth-of-type", "nth-last-of-type")1290_NTH_RE = re.compile(r"odd|even|[+-]?\d*n(?:[+-]\d+)?|[+-]?\d+", re.IGNORECASE | re.ASCII)1291_PSEUDO_ELEMENTS = ("before", "after")1292_ATTRIBUTE_OPERATORS = ("=", "~=", "|=", "^=", "$=", "*=")1293# Elements outside the slide: a rule on them would restyle the page.1294_PAGE_ELEMENTS = frozenset({"html", "head", "body"})12951296# Properties a declaration may set; a name ending in * is every property that1297# starts so (border* is border, border-top, border-radius, ...). The surface1298# properties go on any element, a built-in layout's own included. Geometry1299# moves and sizes things: on a built-in layout it stays off the layout's own1300# elements (a flex cover with centered children collapses its empty photo1301# panel; a wider marker covers its text) and goes on the shapes the model1302# draws, a ::before or ::after the theme does not use for content. Not listed:1303# a font family (the brand kit's), clip-path, mask, filter, content-visibility,1304# and text stroke or text fill colors, which the master's text does not carry.1305_SURFACE_PROPERTIES = (1306    "color", "background*", "border*", "outline*", "box-shadow", "opacity", "visibility",1307    "font-weight", "font-style", "font-size", "font-variant*", "font-stretch", "line-height", "letter-spacing",1308    "word-spacing", "text-*", "white-space", "word-break", "overflow-wrap", "hyphens",1309    "padding*", "margin*", "box-sizing", "list-style*", "vertical-align", "object-fit", "object-position", "overflow*",1310    "z-index", "fill", "fill-opacity", "stroke*", "content", "quotes", "counter-reset", "counter-increment",1311    "mix-blend-mode", "isolation",1312)1313_GEOMETRY_PROPERTIES = (1314    "display", "position", "top", "right", "bottom", "left", "inset*", "float", "clear", "width", "height",1315    "min-*", "max-*", "inline-size", "block-size", "aspect-ratio", "flex*", "grid*", "gap",1316    "row-gap", "column-gap", "columns", "column-*", "order", "align-*", "justify-*", "place-*", "transform*",1317    "translate", "rotate", "scale",1318)1319# Values: numbers, bare or in these units, keywords that name no color, the1320# theme's color variables (var() of nothing else: a variable the CSS sets1321# itself could carry anything), strings in content, and these functions,1322# nested at most three deep.1323_THEME_COLOR_VARS = frozenset(re.findall(r"var\((--[a-z0-9-]+)\)", _DESIGN_COLOR_VARS))1324_CSS_UNITS = frozenset({1325    "px", "em", "rem", "ex", "ch", "lh", "vw", "vh", "vmin", "vmax", "cm", "mm", "q", "in", "pt", "pc",1326    "deg", "rad", "grad", "turn", "fr", "s", "ms",1327})1328_CSS_FUNCTIONS = (1329    "calc", "min", "max", "clamp", "linear-gradient", "radial-gradient", "conic-gradient",1330    "repeating-linear-gradient", "repeating-radial-gradient", "translate", "translatex", "translatey", "rotate",1331    "scale", "skew", "skewx", "skewy", "repeat", "minmax", "counter",1332)1333_MATH_FUNCTIONS = ("calc", "min", "max", "clamp")1334_CSS_NESTING = 31335# The converter drops a slot it does not see: shown() in1336# apps/document-render/renderers/html_master_renderer.py rejects display none,1337# an opacity of 0.02 or less and a visibility other than visible. On an1338# element the cleaner keeps visibility visible, an opacity above the cut-off1339# (rounded as Chromium stores it: 0.02000004 is 0.02) and only these display1340# values, which all draw the element's content (contents, none and1341# table-column draw nothing of it).1342_DISPLAY_KEYWORDS = frozenset({1343    "block", "inline", "inline-block", "flex", "inline-flex", "grid", "inline-grid", "flow", "flow-root",1344    "list-item", "table", "inline-table", "table-row", "table-cell", "table-caption", "table-row-group",1345    "table-header-group", "table-footer-group",1346})1347_HIDDEN_OPACITY = 0.0213481349# The design's own `</design` would end its block in the prompt early.1350_DESIGN_CLOSE_RE = re.compile(r"</(?=design)", re.IGNORECASE)135113521353def clean_design(value):1354    """The design rules to follow, or "" (none).13551356    HTML comments are notes, not rules: a design that is only a comment (the1357    brand library's "Design del Brand" ships with one, since a Text node1358    cannot be empty) is no design. Line endings become \n. Past1359    DESIGN_MAX_CHARS the prose is cut and the ```css blocks are kept whole1360    (up to DESIGN_CSS_MAX_CHARS): they come last in a design.md and hold the1361    rules every layout applies.1362    """1363    if not isinstance(value, str):1364        return ""1365    design = _HTML_COMMENT_RE.sub("", value.replace("\r\n", "\n").replace("\r", "\n")).strip()1366    if len(design) > DESIGN_MAX_CHARS:1367        css = design_css(design)[:DESIGN_CSS_MAX_CHARS]1368        prose = _DESIGN_CSS_RE.sub("", design).strip()[:DESIGN_MAX_CHARS].rstrip()1369        print(1370            f"Design is {len(design)} characters: the first {DESIGN_MAX_CHARS} of its text are used"1371            + (", and its css block" if css else ""),1372            file=sys.stderr,1373        )1374        design = prose + (f"\n```css\n{css}\n```" if css else "")1375    return design137613771378def design_css(design):1379    """The CSS of the design's ```css blocks ("" without one)."""1380    return "\n".join(block.strip() for block in _DESIGN_CSS_RE.findall(design or ""))138113821383def _template_classes(template_name):1384    classes = set()1385    for value in re.findall(r"class=['\"]([^'\"]+)['\"]", TEMPLATE_INSTRUCTIONS.get(template_name or "", "")):1386        classes.update(value.split())1387    return classes138813891390def _names(names):1391    return ", ".join(names)139213931394def build_design_instruction(design, template_name=None):1395    """System prompt addition for a design in layout design mode ("" without one).13961397    `template_name` is the built-in layout whose structure applies (None for1398    auto): the theme's decoration on its classes is named, so the design can1399    override what the markup does not show. The lists of what the CSS may use1400    come from the allowlist itself.1401    """1402    if not design:1403        return ""1404    classes = _template_classes(template_name)1405    drawn = [1406        f"- `{selector}`: {what} ({kind})"1407        for selector, what, kind in THEME_DECORATION1408        if classes and set(re.findall(r"\.([\w-]+)", selector)) <= classes1409    ]1410    look = "rounded corners, soft shadows, gradient fills and light (300) headings"1411    look += ", and it draws:\n" + "\n".join(drawn) + "\n" if drawn else ".\n"1412    geometry = (1413        "KEEP THE LAYOUT'S GEOMETRY: the layout above already places and sizes everything; restyle its "1414        "surfaces, do not rebuild it. In your <style> block the geometry properties are removed on its "1415        "elements (position may stay relative or static). Borders, shadows, backgrounds, corners, margins, type and the "1416        "padding of text are yours; keep small fixed elements (markers, icons, dots) their size, without "1417        "padding. A shape of your own (a sticker, a bar, a frame) is a ::before or ::after you add, positioned "1418        "absolutely: it keeps any geometry. The theme's own ::before and ::after that show content keep their "1419        "place.\n"1420    ) if template_name else ""1421    applied = (1422        "The design's ```css block is already applied to this layout, cleaned by the rules below: do not "1423        "repeat it. Your block adds what this layout needs beyond it, and always settles the theme's look "1424        "below, which that CSS does not reach.\n"1425    ) if design_css(design) else ""1426    pseudo_classes = _names([f":{name}" for name in _PSEUDO_CLASSES] + [f":{name}()" for name in _NTH_PSEUDO_CLASSES])1427    geometry_where = "only on a ::before or ::after you add (below), " if template_name else ""1428    shown = _DESIGN_CLOSE_RE.sub(r"<\\/", design)1429    return (1430        "\n\nBRAND DESIGN: the brand's design rules are between the <design> tags below. They have the "1431        "HIGHEST priority: where the instruction, the layout above or any rule above about styling disagrees "1432        "with them, follow the design. The instruction still says which layout this is and what it holds.\n"1433        f"<design>\n{shown}\n</design>\n"1434        f"{applied}"1435        "STYLING: this overrides rules 8, 9 and 10 above. Apply the design in ONE <style> block inside the "1436        "<section> (style attributes are fine for a one-off value). The block applies to this layout only: "1437        "write selectors for the elements inside the slide (e.g. .right, h1, .kpi) and `section` for the "1438        "slide itself; never :root, html, body or .reveal. A rule for every element (e.g. square corners "1439        "everywhere) is one rule on `section *, section *::before, section *::after`.\n"1440        f"COLORS: only the theme's color variables, which carry the brand's colors: {_DESIGN_COLOR_VARS}; "1441        "and transparent or currentColor. Never a hex value, rgb(), hsl() or a color name: they are removed.\n"1442        "FONTS: keep the deck's font families (font and font-family are removed); set the weight, case, size, "1443        "letter-spacing and line-height the design asks for.\n"1444        "PLAIN CSS ONLY: the code keeps what is listed here and removes everything else.\n"1445        "- Selectors: elements, *, classes, ids, attributes, the pseudo-classes "1446        f"{pseudo_classes}, :not() of one compound selector (no ::before inside), and a final ::before or "1447        "::after; joined by a space or > only (never +, ~, :is(), :where() or :has()).\n"1448        f"- Properties (a * stands for the rest of the name): {_names(_SURFACE_PROPERTIES)}; and, "1449        f"{geometry_where}the geometry properties {_names(_GEOMETRY_PROPERTIES)}. content only on a ::before or "1450        "::after.\n"1451        f"- Values: numbers, bare or with a unit ({_names(sorted(_CSS_UNITS))} or %), keywords, the color "1452        f"variables, strings in content (\\201C or the character itself), and the functions "1453        f"{_names(f'{name}()' for name in _CSS_FUNCTIONS)}.\n"1454        "- Removed besides: @-rules (@import, @font-face, @media, ...), url() and every other function, "1455        "custom properties (--x), vendor-prefixed properties and keywords (-webkit-...), !important and "1456        "backslash escapes outside a string; and anything that hides an element (display: none, contents or "1457        "table-column, visibility other than visible, opacity of 0.02 or less) except on a ::before or "1458        "::after, since a hidden slot drops out of the master.\n"1459        f"{geometry}"1460        f"THE THEME'S LOOK, which the design overrides where it disagrees: {look}"1461        "Settle EACH of these against the design, one rule each, and override what it rules out: a gradient fill is a "1462        "background image, so set the `background` shorthand to a color variable (background-color alone "1463        "leaves the gradient); a drawn decoration goes with `display: none` on its ::before or ::after; "1464        "content is restyled to the design but kept. The slot marks and the layout's structure stay as "1465        "asked above."1466    )146714681469def _css_tokens(css):1470    """The tokens of `css` as (kind, text): space (whitespace and comments),1471    string, number (with its unit or %), function (its name and the open1472    parenthesis), ident, hash, delim (one character) and other (anything1473    else: a backslash, a lone quote, a character outside ASCII)."""1474    return [(match.lastgroup, match.group()) for match in _CSS_TOKEN_RE.finditer(css)]147514761477def _css_text(tokens):1478    """Tokens as written, whitespace and comments collapsed (for the log)."""1479    return " ".join("".join(" " if kind == "space" else text for kind, text in tokens).split())148014811482def _opens(token):1483    return token[0] == "function" or token in (("delim", "("), ("delim", "["))148414851486def _closes(token):1487    return token in (("delim", ")"), ("delim", "]"))148814891490def _trimmed(tokens):1491    start, end = 0, len(tokens)1492    while start < end and tokens[start][0] == "space":1493        start += 11494    while end > start and tokens[end - 1][0] == "space":1495        end -= 11496    return tokens[start:end]149714981499def _split_tokens(tokens, sep):1500    """`tokens` split on the delimiter `sep` outside parentheses and brackets."""1501    parts, depth, start = [], 0, 01502    for i, token in enumerate(tokens):1503        if _opens(token):1504            depth += 11505        elif _closes(token):1506            depth = max(depth - 1, 0)1507        elif token == ("delim", sep) and depth == 0:1508            parts.append(tokens[start:i])1509            start = i + 11510    parts.append(tokens[start:])1511    return parts151215131514def _closing(tokens, i):1515    """Index of the token that closes the function, ( or [ at `i`; -1 if none."""1516    depth = 01517    for j in range(i, len(tokens)):1518        if _opens(tokens[j]):1519            depth += 11520        elif _closes(tokens[j]):1521            depth -= 11522            if depth == 0:1523                return j1524    return -1152515261527def _css_rules(tokens):1528    """(prelude, block) token lists of each top-level rule; the block None for1529    an @-rule, a rule whose block holds a block (nesting) and one left open."""1530    rules, i, n = [], 0, len(tokens)1531    while i < n:1532        if tokens[i][0] == "space" or tokens[i] == ("delim", "}"):1533            i += 11534            continue1535        at, start, depth = tokens[i] == ("delim", "@"), i, 01536        while i < n and not (depth == 0 and tokens[i] in (("delim", "{"), ("delim", ";") if at else None)):1537            depth += 1 if _opens(tokens[i]) else -1 if _closes(tokens[i]) and depth else 01538            i += 11539        prelude = _trimmed(tokens[start:i])1540        if i == n or tokens[i] == ("delim", ";"):1541            rules.append((prelude, None))1542            i += 11543            continue1544        j, depth = i + 1, 11545        while j < n and depth:1546            depth += {("delim", "{"): 1, ("delim", "}"): -1}.get(tokens[j], 0)1547            j += 11548        if depth:1549            rules.append((prelude, None))1550            break1551        block = tokens[i + 1:j - 1]1552        rules.append((prelude, None if at or ("delim", "{") in block else block))1553        i = j1554    return rules155515561557def _attribute_selector(tokens):1558    """`[name]` or `[name op "value"]` from the tokens between the brackets,1559    or None."""1560    words = [token for token in tokens if token[0] != "space"]1561    if not words or words[0][0] != "ident":1562        return None1563    name = words[0][1].lower()1564    if len(words) == 1:1565        return f"[{name}]"1566    operator, value = "".join(text for _, text in words[1:-1]), words[-1]1567    if operator not in _ATTRIBUTE_OPERATORS or any(kind != "delim" for kind, _ in words[1:-1]):1568        return None1569    if value[0] == "string":1570        string = _string_value(value[1])1571        return None if string is None else f"[{name}{operator}{_css_string(string)}]"1572    return f"[{name}{operator}{_css_string(value[1])}]" if value[0] in ("ident", "number") else None157315741575def _compound(tokens, i, inner=False):1576    """(the compound selector at `i` written again, the index after it, its1577    pseudo-element or ""), or None when it is not one the design may use.1578    `inner`: the argument of :not(), with no pseudo-element and no :not()."""1579    n, out, pseudo = len(tokens), [], ""1580    if i < n and (tokens[i][0] == "ident" or tokens[i] == ("delim", "*")):1581        out.append(tokens[i][1].lower())1582        i += 11583    while i < n and not pseudo:1584        kind, text = tokens[i]1585        following = tokens[i + 1] if i + 1 < n else ("", "")1586        if kind == "hash" and _CSS_NAME_RE.fullmatch(text[1:]):1587            out.append(text)1588            i += 11589        elif (kind, text) == ("delim", ".") and following[0] == "ident":1590            out.append("." + following[1])1591            i += 21592        elif (kind, text) == ("delim", "["):1593            end = next((j for j in range(i, n) if tokens[j] == ("delim", "]")), -1)1594            attribute = _attribute_selector(tokens[i + 1:end]) if end != -1 else None1595            if attribute is None:1596                return None1597            out.append(attribute)1598            i = end + 11599        elif (kind, text) != ("delim", ":"):1600            break1601        elif following == ("delim", ":") and not inner:1602            name = tokens[i + 2] if i + 2 < n else ("", "")1603            if name[0] != "ident" or name[1].lower() not in _PSEUDO_ELEMENTS:1604                return None1605            pseudo = "::" + name[1].lower()1606            out.append(pseudo)1607            i += 31608        elif following[0] == "ident" and following[1].lower() in _PSEUDO_CLASSES:1609            out.append(":" + following[1].lower())1610            i += 21611        elif following[0] == "ident" and following[1].lower() in _PSEUDO_ELEMENTS and not inner:1612            pseudo = "::" + following[1].lower()  # the legacy :before and :after1613            out.append(pseudo)1614            i += 21615        elif following[0] == "function":1616            name, end = following[1][:-1].lower(), _closing(tokens, i + 1)1617            args = _trimmed(tokens[i + 2:end]) if end != -1 else []1618            nth = "".join(text for kind, text in args if kind != "space").lower()1619            if name in _NTH_PSEUDO_CLASSES and _NTH_RE.fullmatch(nth):1620                out.append(f":{name}({nth})")1621            elif name == "not" and not inner and args:1622                simple = _compound(args, 0, inner=True)1623                if simple is None or simple[1] != len(args):1624                    return None1625                out.append(f":not({simple[0]})")1626            else:1627                return None1628            i = end + 11629        else:1630            return None1631    return ("".join(out), i, pseudo) if out else None163216331634def _complex_selector(tokens):1635    """[(combinator, compound, pseudo-element), ...] of a selector whose1636    compounds are joined by a space or `>`, or None."""1637    i, n, parts, combinator = 0, len(tokens), [], ""1638    while True:1639        compound = _compound(tokens, i)1640        if compound is None:1641            return None1642        text, i, pseudo = compound1643        parts.append((combinator, text, pseudo))1644        spaced = i < n and tokens[i][0] == "space"1645        while i < n and tokens[i][0] == "space":1646            i += 11647        if i == n:1648            return parts1649        if pseudo:1650            return None1651        if tokens[i] == ("delim", ">"):1652            combinator = ">"1653            i += 11654            while i < n and tokens[i][0] == "space":1655                i += 11656        elif spaced:1657            combinator = " "1658        else:1659            return None166016611662def _scoped_selector(parts, scope):1663    """The selector `parts` (_complex_selector) under `scope`, or None for one1664    that reaches outside the slide."""1665    if any(re.match(r"[a-z0-9-]*", text).group() in _PAGE_ELEMENTS for _, text, _ in parts):1666        return None1667    # `.reveal h1` is h1 on this slide; `.reveal` alone is the whole deck.1668    if parts[0][1] == ".reveal":1669        parts = parts[1:]1670        if parts and parts[0][1] == ".slides":1671            parts = parts[1:]1672        if not parts:1673            return None1674    first = parts[0][1]1675    head = scope + first[7:] if re.match(r"section(?![a-z0-9_-])", first) else f"{scope} {first}"1676    return head + "".join((" > " if combinator == ">" else " ") + text for combinator, text, _ in parts[1:])167716781679def _string_value(text):1680    """The text of a string token, its \\XXXX escapes decoded (a model writes1681    content: "\\201C"), or None for an escape of no character. _css_string1682    escapes it again."""1683    def char(match):1684        code = int(match.group(1), 16)1685        if not 0 < code <= 0x10FFFF or 0xD800 <= code <= 0xDFFF:1686            raise ValueError(code)1687        return chr(code)1688    try:1689        return _CSS_ESCAPE_RE.sub(char, text[1:-1])1690    except ValueError:1691        return None169216931694def _css_value(tokens, strings=False, math=False, depth=0):1695    """`tokens` as a value written again, or None when one is not allowed:1696    numbers, bare or in _CSS_UNITS, keywords that name no color and have no1697    vendor prefix, var() of a theme color variable, _CSS_FUNCTIONS, commas1698    and slashes, the operators of a calc(), and strings where `strings`1699    (re-escaped: they cannot end the <style> element)."""1700    out, i, n = [], 0, len(tokens)1701    while i < n:1702        kind, text = tokens[i]1703        if kind == "number":1704            number, unit = _CSS_NUMBER_RE.fullmatch(text).groups()1705            if unit not in ("", "%") and unit.lower() not in _CSS_UNITS:1706                return None1707            out.append(number + unit.lower())1708        elif kind == "ident":1709            if text.startswith(("--", *_VENDOR_PREFIXES)) or text.lower() in _NAMED_COLORS:1710                return None1711            out.append(text)1712        elif kind == "string" and strings:1713            string = _string_value(text)1714            if string is None:1715                return None1716            out.append(_css_string(string))1717        elif kind == "function":1718            name, end = text[:-1].lower(), _closing(tokens, i)1719            if end == -1 or depth >= _CSS_NESTING:1720                return None1721            args = _trimmed(tokens[i + 1:end])1722            if name == "var" and len(args) == 1 and args[0][1] in _THEME_COLOR_VARS:1723                inner = args[0][1]1724            elif name in _CSS_FUNCTIONS:1725                inner = _css_value(args, math=name in _MATH_FUNCTIONS, depth=depth + 1)1726                if not inner:1727                    return None1728            else:1729                return None1730            out.append(f"{name}({inner})")1731            i = end1732        elif not (kind == "space" or (kind == "delim" and (text in ",/" or (math and text in "+-*")))):1733            return None1734        elif kind == "delim":1735            out.append(text)1736        i += 11737    value = ""1738    for part in out:1739        value += part if part == "," or not value else " " + part1740    return value174117421743def _shown_value(prop, tokens):1744    """The value of display, visibility or opacity on an element, written1745    again, when it keeps the element shown; else None."""1746    words = [token for token in tokens if token[0] != "space"]1747    if prop == "display":1748        names = [text.lower() for kind, text in words if kind == "ident"]1749        return " ".join(names) if 0 < len(names) == len(words) <= 2 and set(names) <= _DISPLAY_KEYWORDS else None1750    if prop == "visibility":1751        return "visible" if len(words) == 1 and words[0][0] == "ident" and words[0][1].lower() == "visible" else None1752    match = _CSS_NUMBER_RE.fullmatch(words[0][1]) if len(words) == 1 and words[0][0] == "number" else None1753    if not match or match.group(1)[0] in "+-" or match.group(2) not in ("", "%"):1754        return None1755    opacity = round(float(match.group(1)) / (100 if match.group(2) else 1), 4)1756    return words[0][1] if opacity > _HIDDEN_OPACITY else None175717581759def _listed(prop, names):1760    return any(prop == name or (name.endswith("*") and prop.startswith(name[:-1])) for name in names)176117621763def _declaration(prop, tokens, pseudo, geometry):1764    """The value a declaration keeps, written again, or None when the design1765    rules drop it. `pseudo`: it styles a ::before or ::after, which may be1766    hidden and hold content. `geometry`: it may move and size things (auto1767    layouts, style attributes, a shape the model draws)."""1768    if prop is None or tokens is None:1769        return None1770    if prop in ("display", "visibility", "opacity") and not pseudo:1771        return _shown_value(prop, tokens) if geometry or prop != "display" else None1772    if prop == "content" and not pseudo:1773        return None1774    if prop.startswith("background") and any(kind == "ident" and text.lower() == "text" for kind, text in tokens):1775        return None  # background-clip: text paints the slot's sample text into the master's background1776    if prop == "position" and not geometry:1777        value = _css_value(tokens)1778        return value if value and value.lower() in ("relative", "static") else None1779    if not (_listed(prop, _SURFACE_PROPERTIES) or (geometry and _listed(prop, _GEOMETRY_PROPERTIES))):1780        return None1781    return _css_value(tokens, strings=prop in ("content", "quotes")) or None178217831784def _css_declarations(tokens):1785    """(property or None, value tokens or None, the declaration as written)1786    of each declaration in a block: the property lowercased (None when there1787    is no `name:`) and the value without its `!important` (None when1788    anything else follows a `!`: design CSS must not outrank the converter,1789    which hides a slot's text and picture with !important)."""1790    for declaration in _split_tokens(tokens, ";"):1791        declaration = _trimmed(declaration)1792        if not declaration:1793            continue1794        written = _css_text(declaration)1795        colon = declaration.index(("delim", ":")) if ("delim", ":") in declaration else -11796        name = _trimmed(declaration[:colon]) if colon > 0 else []1797        if len(name) != 1 or name[0][0] != "ident":1798            yield None, None, written1799            continue1800        value = declaration[colon + 1:]1801        if ("delim", "!") in value:1802            bang = value.index(("delim", "!"))1803            rest = _trimmed(value[bang + 1:])1804            important = len(rest) == 1 and rest[0][0] == "ident" and rest[0][1].lower() == "important"1805            value = value[:bang] if important else None1806        yield name[0][1].lower(), value, written180718081809def _css_string(value):1810    """`value` as a double-quoted CSS string: anything but letters, digits,1811    spaces, '-' and '_' escaped, so it cannot end the string or the element."""1812    return '"' + "".join(ch if ch.isalnum() or ch in " -_" else f"\\{ord(ch):x} " for ch in value) + '"'181318141815# The simple selectors of a compound written by _compound (li, .x, #y, [a="b"],1816# :not(.z), ::before), :not() whole.1817_SIMPLE_SELECTOR_RE = re.compile(r"::?[a-z-]+(?:\([^()]*\))?|[.#][\w-]+|\[[^\]]*\]|^[a-z*][\w-]*")181818191820def _simple_selectors(compound):1821    return set(_SIMPLE_SELECTOR_RE.findall(compound))182218231824def _theme_content_pseudos(template_name):1825    """The simple selectors of the last compound of each of the theme's1826    content pseudo-elements on the layout (li::before is {li, ::before}): a1827    rule whose last compound holds all of one (li.x:first-child::before), or1828    only part of one (::before, *::before), styles that content."""1829    classes = _template_classes(template_name)1830    return [1831        _simple_selectors(_complex_selector(_css_tokens(selector))[-1][1])1832        for selector, _what, kind in THEME_DECORATION1833        if kind == "content" and classes and set(re.findall(r"\.([\w-]+)", selector)) <= classes1834    ]183518361837def scope_design_css(css, layout_name, template_name=None):1838    """(css, rules kept, parts dropped): the rules of `css` the allowlist1839    keeps, written again and scoped to the layout's section.18401841    On a built-in layout (`template_name`) the geometry properties are1842    removed but on a ::before or ::after the theme does not use for content1843    (an agenda's numbers). Nothing may hide an element but a ::before or1844    ::after. A selector that does not parse leaves its rule's other1845    selectors in place.1846    """1847    scope = f".reveal .slides section[data-layout-name={_css_string(layout_name)}]"1848    content_pseudos = _theme_content_pseudos(template_name)1849    out, kept, dropped, size = [], 0, [], 01850    for prelude, block in _css_rules(_css_tokens(css)):1851        selectors = []1852        for tokens in _split_tokens(prelude, ","):1853            parts = _complex_selector(_trimmed(tokens))1854            scoped = _scoped_selector(parts, scope) if parts else None1855            if scoped:1856                selectors.append((parts[-1], scoped))1857        if block is None or not selectors:1858            dropped.append(_css_text(prelude) or "{...}")1859            continue1860        pseudo = all(last[2] for last, _ in selectors)1861        geometry = not template_name or (pseudo and not any(1862            theme <= rule or rule - {"*"} <= theme1863            for rule in (_simple_selectors(last[1]) for last, _ in selectors) for theme in content_pseudos))1864        declarations = []1865        for prop, tokens, written in _css_declarations(block):1866            value = _declaration(prop, tokens, pseudo, geometry)1867            if value is None:1868                dropped.append(written)1869            else:1870                declarations.append(f"{prop}: {value}")1871        if not declarations:1872            continue1873        rule = f"{', '.join(scoped for _, scoped in selectors)} {{ {'; '.join(declarations)}; }}"1874        if size + len(rule) > DESIGN_CSS_MAX_CHARS:1875            dropped.append(f"the rules after {DESIGN_CSS_MAX_CHARS} characters")1876            break1877        out.append(rule)1878        kept += 11879        size += len(rule) + 11880    return "\n".join(out), kept, dropped188118821883def design_dropped(design, layout_name, template_name=None):1884    """The `designDropped` output: what this layout loses of the design's ```css1885    block, as scope_design_css reports it ("" for nothing), for an agent to read1886    and tell the user. The slide's own <style> blocks do not count: the model1887    wrote those, not the design. scope_design_css writes nothing to stderr, so1888    reading the block again here adds no line to apply_design_style's."""1889    dropped = scope_design_css(design_css(design), layout_name, template_name)[2]1890    return "; ".join(d[:80] for d in dropped)[:DESIGN_DROPPED_MAX_CHARS]189118921893def _clean_style_attributes(html):1894    """(`html` with every style attribute keeping only what the allowlist1895    keeps, the parts dropped). The slide's own geometry stays (rule 10 lets1896    it lay itself out with style attributes); nothing may hide an element.1897    `html` comes from _readable_markup, so the HTML parser and a browser read1898    the same tags and attributes; a tag with a style is written again1899    (_rewritten), its other values as written."""1900    scanner = _SlotMarkScanner(html)1901    scanner.feed(html)1902    scanner.close()1903    dropped, out, pos = [], [], 01904    for entry in scanner.tags:1905        if not any(key == "style" for key, _ in entry["attrs"]):1906            continue1907        attrs = []1908        for key, val in entry["attrs"]:1909            if key == "style":1910                kept = []1911                for prop, tokens, written in _css_declarations(_css_tokens(val or "")):1912                    value = _declaration(prop, tokens, pseudo=False, geometry=True)1913                    if value is None:1914                        dropped.append(written)1915                    else:1916                        kept.append(f"{prop}: {value}")1917                if not kept:1918                    continue1919                val = "; ".join(kept)1920            attrs.append((key, val))1921        out.append(html[pos:entry["start"]])1922        out.append(_rewritten(entry, attrs))1923        pos = entry["start"] + len(entry["raw"])1924    out.append(html[pos:])1925    return "".join(out), dropped192619271928def _tag_at(lower, name, pos):1929    """Index of the next `name` (`<style` or `</style`) from `pos` that is a1930    tag, followed by whitespace, '/', '>' or the end as the HTML tokenizer1931    reads one; -1 when there is none."""1932    index = lower.find(name, pos)1933    while index != -1 and lower[index + len(name):index + len(name) + 1] not in ("", " ", "\t", "\n", "\r", "\f", "/", ">"):1934        index = lower.find(name, index + 1)1935    return index193619371938def _extract_style_blocks(html):1939    """(`html` without its <style> elements, their CSS), in one linear pass.19401941    As a browser reads them: `</style x>` and `</style/>` end a block, and a1942    block left open runs to the end. Each block becomes a space, so what was1943    on its two sides is never joined into a new tag, and a </style> left1944    over is written as text. `html` comes from _readable_markup: it holds no1945    comment that could hide a <style>.1946    """1947    lower = html.translate(_ASCII_LOWER)  # str.lower() changes some lengths (U+0130)1948    out, blocks, pos = [], [], 01949    while True:1950        start = _tag_at(lower, "<style", pos)1951        if start == -1:1952            break1953        out.append(html[pos:start] + " ")1954        open_end = lower.find(">", start)1955        close = -1 if open_end == -1 else _tag_at(lower, "</style", open_end + 1)1956        if close == -1:1957            if open_end != -1:1958                blocks.append(html[open_end + 1:])1959            pos = len(html)1960            break1961        blocks.append(html[open_end + 1:close])1962        close_end = lower.find(">", close)1963        pos = len(html) if close_end == -1 else close_end + 11964    out.append(html[pos:])1965    return _STYLE_TAG_LEFT_RE.sub(r"&lt;\1\2", "".join(out)), blocks196619671968def apply_design_style(html, layout_name, design="", template_name=None):1969    """`html` with the design's ```css block and its own <style> blocks, in1970    that order, merged into one block at the end of the <section>1971    (scope_design_css: the allowlist, scoped to this layout; on a built-in1972    `template_name` the layout's geometry kept; each of the two capped on its1973    own, so a long design never pushes out the layout's rules), and its style1974    attributes cleaned by the same allowlist (their geometry kept). The1975    markup is made readable first (_readable_markup). Without a design it1976    still runs: a <style> the model wrote anyway is kept to its layout."""1977    html, blocks = _extract_style_blocks(_readable_markup(html))1978    html, inline_dropped = _clean_style_attributes(html)1979    if inline_dropped:1980        print(1981            f"Design: dropped {len(inline_dropped)} style attribute declaration(s): "1982            + "; ".join(d[:80] for d in inline_dropped),1983            file=sys.stderr,1984        )1985    if design and not blocks:1986        print("Design: the slide has no <style> block of its own", file=sys.stderr)1987    parts = [scope_design_css(css, layout_name, template_name) for css in (design_css(design), "\n".join(blocks))]1988    css = "\n".join(part[0] for part in parts if part[0])1989    kept, dropped = sum(part[1] for part in parts), [d for part in parts for d in part[2]]1990    if design or blocks:1991        print(1992            f"Design: {kept} CSS rule(s) scoped to the layout"1993            + (f"; dropped {len(dropped)}: {'; '.join(d[:80] for d in dropped)}" if dropped else ""),1994            file=sys.stderr,1995        )1996    # On the original string: lower() changes the length of some characters.1997    ends = list(re.finditer(r"</section", html, re.IGNORECASE))1998    if not css or not ends:1999        return html2000    end = ends[-1].start()2001    return f"{html[:end]}<style>\n{css}\n</style>\n{html[end:]}"200220032004# ---------------------------------------------------------------------------2005# Fixed layouts (layout design mode, no model)2006# ---------------------------------------------------------------------------2007# A built-in layout whose structure, slots and sample text are written here2008# instead of by the model: the same inputs give the same bytes, a design fix2009# does not redraw the layouts, and only the design's ```css block styles them.2010# The markup holds [key] for a text of FIXED_LAYOUT_TEXT, and # in a repeated2011# block for its number. The samples are guidance, sized on purpose: the2012# converter sizes a box on its sample, so a narrow title wraps to two lines on2013# the 1280 canvas (uppercase, as the theme writes it, and in lowercase), a body2014# is as long as it should hold, and a compare item is as wide as its column2015# (Reveal shrinks that list to its text). Every text is plain: a word's own2016# style is lost in the master.201720182019def _times(count, block):2020    """`block` `count` times, each # its number with a leading zero."""2021    return "".join(block.replace("#", f"{number:02d}") for number in range(1, count + 1))202220232024_FIXED_HEADING = (2025    '<div><span class="eyebrow" data-slot="text">[eyebrow]</span><h2 data-slot="title">[title]</h2>'2026    '<hr class="divider"></div>'2027)2028_FIXED_BRAND_MARK = '<div class="brand-mark" data-slot="fixed">Brand</div>'2029_FIXED_PAGE_NUMBER = '<div class="page-num" data-slot="number">00 / 00</div>'2030_FIXED_STEP_ICON = (2031    '<svg viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 11h13M12 6l5 5-5 5"></path>'2032    "</svg>"2033)20342035FIXED_LAYOUTS = {2036    "emblema-cover": (2037        '<div class="slide-cover"><div class="left">'2038        '<div class="brand"><span class="eyebrow" data-slot="text">[eyebrow]</span></div>'2039        '<div><h1 data-slot="title">[title-narrow]</h1><p class="subtitle" data-slot="text">[cover-subtitle]</p></div>'2040        '<p class="meta" data-slot="text">[cover-meta]</p></div><div class="right"></div></div>'2041    ),2042    "emblema-agenda": (2043        f'<div class="slide-pad slide-agenda">{_FIXED_HEADING}<ol data-slot="bullets">'2044        + _times(5, '<li><span class="topic">[agenda-topic]</span><span class="duration">[agenda-duration]</span></li>')2045        + f"</ol></div>{_FIXED_BRAND_MARK}{_FIXED_PAGE_NUMBER}"2046    ),2047    "emblema-section": (2048        '<div class="slide-section"><div class="num" data-slot="text">00</div><div class="meta">'2049        '<span class="label" data-slot="text">[section-label]</span><h2 data-slot="title">[title-wide]</h2>'2050        '<p data-slot="text">[section-intro]</p></div></div>'2051    ),2052    # The quote is the title: this layout has no heading. The corner holds the2053    # page number and no text: a long text there is a one-character-wide column.2054    "emblema-statement": (2055        '<div class="slide-statement"><div><p class="quote" data-slot="title">[statement-quote]</p>'2056        '<p class="attribution" data-slot="text">[statement-attribution]</p></div></div>'2057        '<div class="corner-index" data-slot="number">00 / 00</div>'2058    ),2059    "emblema-two-col": (2060        '<div class="slide-grid-2 slide-two-col"><div><span class="eyebrow" data-slot="text">[eyebrow]</span>'2061        '<h2 data-slot="title">[title-wide]</h2><p data-slot="text">[two-col-body]</p>'2062        '<p class="muted" data-slot="text">[two-col-note]</p></div>'2063        '<div class="visual" data-slot="picture" alt="[two-col-photo]"></div></div>'2064        f"{_FIXED_BRAND_MARK}{_FIXED_PAGE_NUMBER}"2065    ),2066    "emblema-bullets": (2067        f'<div class="slide-pad slide-bullets">{_FIXED_HEADING}<ul data-slot="bullets">'2068        + _times(4, '<li><span class="marker">#</span><div><h4>[bullets-item]</h4><p>[bullets-line]</p></div></li>')2069        + "</ul></div>"2070    ),2071    "emblema-magazine": (2072        '<div class="slide-mag"><div class="photo-col">'2073        '<div class="photo photo--var2" data-slot="picture" alt="[magazine-photo]"></div></div>'2074        '<div class="text-col"><span class="eyebrow" data-slot="text">[eyebrow]</span>'2075        '<h2 data-slot="title">[title-medium]</h2><p class="body" data-slot="text">[magazine-body]</p>'2076        '<div class="meta"><span data-slot="text">[magazine-place]</span>'2077        '<span data-slot="text">[magazine-author]</span><span data-slot="text">[magazine-date]</span></div></div></div>'2078        f"{_FIXED_PAGE_NUMBER}"2079    ),2080    "emblema-kpis": (2081        f'<div class="slide-pad slide-kpis">{_FIXED_HEADING}<div class="grid">'2082        + _times(3, '<div class="kpi"><div class="num" data-slot="text">[kpi-figure]</div>'2083                    '<h4 data-slot="text">[kpi-title]</h4><p data-slot="text">[kpi-line]</p></div>')2084        + "</div></div>"2085    ),2086    "emblema-table": (2087        f'<div class="slide-pad slide-table">{_FIXED_HEADING}<table data-slot="table" alt="[table-hint]"><thead><tr>'2088        '<th>[table-column]</th><th class="num">[table-value] A</th><th class="num">[table-value] B</th>'2089        '<th class="num">[table-change]</th></tr></thead><tbody>'2090        + _times(3, '<tr><td class="label">[table-row]</td><td class="num">00</td><td class="num">00</td>'2091                    '<td class="num">+0%</td></tr>')2092        + '</tbody><tfoot><tr><td>[table-total]</td><td class="num">00</td><td class="num">00</td><td class="num">+0%</td>'2093        '</tr></tfoot></table><p class="src" data-slot="text">[table-source]</p></div>'2094    ),2095    "emblema-compare": (2096        f'<div class="slide-pad slide-compare">{_FIXED_HEADING}<div class="grid">'2097        '<div class="col"><h4 data-slot="text">[compare-before]</h4><ul data-slot="bullets">'2098        + _times(3, "<li>[compare-before-item]</li>")2099        + '</ul></div><div class="arrow">&rarr;</div><div class="col col--after">'2100        '<h4 data-slot="text">[compare-after]</h4><ul data-slot="bullets">'2101        + _times(3, "<li>[compare-after-item]</li>")2102        + "</ul></div></div></div>"2103    ),2104    # The icon and the step label are fixed: a step's number is the same on2105    # every slide, and four more slots would only hold it.2106    "emblema-process": (2107        f'<div class="slide-pad slide-process">{_FIXED_HEADING}<div class="steps">'2108        + _times(4, f'<div class="step"><div class="icon">{_FIXED_STEP_ICON}</div>'2109                    '<span class="num" data-slot="fixed">[process-step] #</span>'2110                    '<h4 data-slot="text">[process-title]</h4><p data-slot="text">[process-line]</p></div>')2111        + "</div></div>"2112    ),2113    # A contact line reads for itself, with no label: a model put the values in2114    # the small uppercase label slots. A dd in the dl: the theme's rule for the2115    # value hangs on it, and the dl's grid lays the four out 2x2.2116    "emblema-closing": (2117        '<div class="slide-closing"><div class="left"><span class="eyebrow" data-slot="text">[eyebrow]</span>'2118        '<h1 data-slot="title">[title-narrow]</h1><dl class="contacts">'2119        '<dd data-slot="text">[contact-email]</dd><dd data-slot="text">[contact-web]</dd>'2120        '<dd data-slot="text">[contact-phone]</dd><dd data-slot="text">[contact-place]</dd>'2121        '</dl></div><div class="right"></div></div>'2122    ),2123}21242125# The sample text of each language, and under a layout's own key its speaker2126# note. No quote, < or & in a text: it goes into the markup as written.2127FIXED_LAYOUT_TEXT = {2128    "it": {2129        "eyebrow": "Etichetta, 2-4 parole",2130        "title": "Titolo della slide, una riga",2131        "title-narrow": "Titolo breve, due righe",2132        "title-medium": "Titolo, massimo due righe",2133        "title-wide": "Titolo della slide, due righe",2134        "cover-subtitle": "Sottotitolo della presentazione, una o due righe di testo secondario",2135        "cover-meta": "Autore · Data · Versione",2136        "agenda-topic": "Argomento, 2-5 parole",2137        "agenda-duration": "00 min",2138        "section-label": "Capitolo, 1-2 parole",2139        "section-intro": "Introduzione alla sezione, una o due frasi che dicono di che cosa parla.",2140        "statement-quote": "Frase chiave o citazione, una sola idea in poche parole, al massimo due righe",2141        "statement-attribution": "Fonte o autore, ruolo",2142        "two-col-body": (2143            "Testo principale, due o tre frasi: il punto del titolo, il motivo per cui conta e che cosa ne segue. "2144            "Niente elenchi: per un elenco si sceglie un altro layout."2145        ),2146        "two-col-note": "Fonte o nota, una riga",2147        "two-col-photo": "Foto, formato verticale",2148        "bullets-item": "Titolo del punto, 2-4 parole",2149        "bullets-line": "Spiegazione del punto, una o due frasi brevi.",2150        "magazine-photo": "Foto, formato verticale",2151        "magazine-body": (2152            "Testo del racconto, da tre a cinque frasi: che cosa è successo, chi ha coinvolto, quale risultato ha "2153            "portato e perché conta per chi ascolta. Resta entro questa lunghezza."2154        ),2155        "magazine-place": "Luogo, 1-2 parole",2156        "magazine-author": "Nome dell'autore",2157        "magazine-date": "Mese e anno",2158        "kpi-figure": "Cifra",2159        "kpi-title": "Nome del dato, 2-4 parole",2160        "kpi-line": "Contesto del dato, una o due righe brevi al massimo.",2161        "table-hint": "Tabella: 4 colonne, 3 righe di dati e una riga di totale",2162        "table-column": "Voce",2163        "table-value": "Valore",2164        "table-change": "Variazione",2165        "table-row": "Riga",2166        "table-total": "Totale",2167        "table-source": "Fonte dei dati, una riga",2168        "compare-before": "Situazione di partenza",2169        "compare-before-item": "Un problema della situazione di oggi, in 8 parole",2170        "compare-after": "Situazione di arrivo",2171        "compare-after-item": "Un risultato della nuova situazione, in 8 parole",2172        "process-step": "Fase",2173        "process-title": "Nome fase, 2-4 parole",2174        "process-line": "Che cosa succede in questa fase e che cosa ne esce, una o due frasi brevi.",2175        "contact-email": "Indirizzo email",2176        "contact-web": "Sito web",2177        "contact-phone": "Numero di telefono",2178        "contact-place": "Città, Paese",2179        "emblema-cover": "Copertina della presentazione: titolo, sottotitolo e dati dell'autore.",2180        "emblema-agenda": "Agenda della presentazione: gli argomenti in ordine, ciascuno con la sua durata.",2181        "emblema-section": "Apertura di una sezione: il suo numero, il titolo e una breve introduzione.",2182        "emblema-statement": "Una frase chiave o una citazione a tutta pagina, con la sua fonte.",2183        "emblema-two-col": "Un argomento spiegato a parole a sinistra, con una foto a destra.",2184        "emblema-bullets": "Quattro punti chiave, ciascuno con un titolo breve e una spiegazione.",2185        "emblema-magazine": "Un racconto con una grande foto a sinistra e il testo a destra.",2186        "emblema-kpis": "Tre numeri chiave, ciascuno con il suo nome e il suo contesto.",2187        "emblema-table": "Dati messi a confronto in una tabella, con una riga di totale e la fonte.",2188        "emblema-compare": "Il confronto tra due situazioni, tre punti per parte.",2189        "emblema-process": "Un processo in quattro fasi, ciascuna con il suo nome e una breve descrizione.",2190        "emblema-closing": "Chiusura della presentazione: il messaggio finale e i contatti.",2191    },2192    "en": {2193        "eyebrow": "Label, 2-4 words",2194        "title": "Slide title, one line",2195        "title-narrow": "Short title, two lines",2196        "title-medium": "Title, at most two lines",2197        "title-wide": "Slide title, up to two lines of text",2198        "cover-subtitle": "Subtitle of the presentation, one or two lines of secondary text",2199        "cover-meta": "Author · Date · Version",2200        "agenda-topic": "Topic, 2-5 words",2201        "agenda-duration": "00 min",2202        "section-label": "Chapter, 1-2 words",2203        "section-intro": "Introduction to the section, one or two sentences on what it covers.",2204        "statement-quote": "Key sentence or quote, a single idea in a few words, at most two lines",2205        "statement-attribution": "Source or author, role",2206        "two-col-body": (2207            "Main text, two or three sentences: the point of the title, why it matters and what follows from it. "2208            "No lists: a list takes another layout."2209        ),2210        "two-col-note": "Source or note, one line",2211        "two-col-photo": "Photo, portrait format",2212        "bullets-item": "Point title, 2-4 words",2213        "bullets-line": "Explanation of the point, one or two short sentences.",2214        "magazine-photo": "Photo, portrait format",2215        "magazine-body": (2216            "Story text, three to five sentences: what happened, who it involved, what result it brought and why "2217            "it matters to the audience. Stay within this length."2218        ),2219        "magazine-place": "Place, 1-2 words",2220        "magazine-author": "Name of the author",2221        "magazine-date": "Month and year",2222        "kpi-figure": "Figure",2223        "kpi-title": "Name of the figure, 2-4 words",2224        "kpi-line": "Context of the figure, one or two short lines at most.",2225        "table-hint": "Table: 4 columns, 3 data rows and a total row",2226        "table-column": "Item",2227        "table-value": "Value",2228        "table-change": "Change",2229        "table-row": "Row",2230        "table-total": "Total",2231        "table-source": "Data source, one line",2232        "compare-before": "Starting point",2233        "compare-before-item": "One problem of the situation today, in 8 words",2234        "compare-after": "Outcome",2235        "compare-after-item": "One result of the new situation, in 8 words",2236        "process-step": "Step",2237        "process-title": "Step name, 2-4 words",2238        "process-line": "What happens in this step and what comes out of it, one or two short sentences.",2239        "contact-email": "Email address",2240        "contact-web": "Website",2241        "contact-phone": "Phone number",2242        "contact-place": "City, Country",2243        "emblema-cover": "Cover of the presentation: title, subtitle and author details.",2244        "emblema-agenda": "Agenda of the presentation: the topics in order, each with its duration.",2245        "emblema-section": "Opening of a section: its number, its title and a short introduction.",2246        "emblema-statement": "One key sentence or quote across the page, with its source.",2247        "emblema-two-col": "One topic explained in words on the left, with a photo on the right.",2248        "emblema-bullets": "Four key points, each with a short title and an explanation.",2249        "emblema-magazine": "A story with a large photo on the left and the text on the right.",2250        "emblema-kpis": "Three key figures, each with its name and its context.",2251        "emblema-table": "Data compared in a table, with a total row and the source.",2252        "emblema-compare": "Two situations compared, three points each.",2253        "emblema-process": "A process in four steps, each with its name and a short description.",2254        "emblema-closing": "Close of the presentation: the final message and the contacts.",2255    },2256}22572258# The language values with a text table. Auto means Italian: this template's2259# text is Italian. Anything else (another language, a value resolve_language_name2260# would drop) goes to the model.2261_FIXED_LAYOUT_LANGUAGES = {"auto": "it", "": "it", "it": "it", "Italian": "it", "en": "en", "English": "en"}226222632264def fixed_layout_language(language):2265    """The key of FIXED_LAYOUT_TEXT for a `language` input, else None."""2266    if language is None:2267        return "it"2268    return _FIXED_LAYOUT_LANGUAGES.get(language.strip()) if isinstance(language, str) else None226922702271def fixed_layout_markup(template_name, language):2272    """The built-in layout's <section> in `language` (a key of2273    FIXED_LAYOUT_TEXT), before mark_layout_slots stamps its name."""2274    text = FIXED_LAYOUT_TEXT[language]2275    body = re.sub(r"\[([\w-]+)\]", lambda match: text[match.group(1)], FIXED_LAYOUTS[template_name])2276    return f'<section>{body}<aside class="notes">{text[template_name]}</aside></section>'227722782279def fixed_layout_slide(inputs):2280    """The output of a node that asks for a fixed layout, with no model; None2281    (a note on stderr if it asked) when it does not or cannot: every input the2282    model path reads is left to the model path, a broken library handle too."""2283    if inputs.get("fixedLayout") is not True:2284        return None2285    layout_name = clean_layout_name(inputs.get("layoutName"))2286    template_name = inputs.get("template_name", "auto")2287    language = fixed_layout_language(inputs.get("language", "auto"))2288    try:2289        library = library_handle(inputs.get("library"))2290    except ValueError:2291        library = inputs.get("library")2292    reason = None2293    if library is not None:2294        reason = "a Layout Library is connected"2295    elif not layout_name:2296        reason = "it applies with a Master Layout Name"2297    elif not isinstance(template_name, str) or template_name not in FIXED_LAYOUTS:2298        reason = f"Slide Layout {template_name!r} has no fixed layout"2299    elif language is None:2300        reason = f"no fixed layout text in language {inputs.get('language')!r}"2301    if reason:2302        print(f"Fixed layout not used: {reason}. Asking the model", file=sys.stderr)2303        return None2304    design = clean_design(inputs.get("design"))2305    if inputs.get("image"):2306        print("Image ignored: a fixed layout's picture is a placeholder", file=sys.stderr)2307    if design and not design_css(design):2308        print("Design has no css block: a fixed layout uses only that, so the theme's look stays", file=sys.stderr)2309    html = apply_design_style(2310        mark_layout_slots(fixed_layout_markup(template_name, language), layout_name),2311        layout_name, design, template_name,2312    )2313    is_valid, reason = validate_slide_html(html, placeholders=False)2314    if not is_valid:2315        raise ValueError(f"Fixed layout {template_name!r} is not valid slide HTML: {reason}")2316    print(f"Fixed layout {template_name!r} in {language}: {len(html)} chars, no model", file=sys.stderr)2317    return {"slide_html": html, "designDropped": design_dropped(design, layout_name, template_name)}231823192320# ---------------------------------------------------------------------------2321# Library mode (docs/designs/brand-master-layout-libraries.md, Slide Generator)2322# ---------------------------------------------------------------------------2323# With a Layout Library connected, the slide is one layout of a customer's2324# PowerPoint master. The LLM writes slot JSON (the gais.slides format), and2325# gais.slides validates it (capacity, required slots, group counts) and2326# renders the <section data-library data-layout> with data-slot children.2327# The catalog's rules live in gais.slides; this section builds the prompt,2328# runs the 3-attempt loop and raises the named LibraryError. gais.slides is2329# reached through the facade on first use, so the built-in path below never2330# imports it. There is no truncation: text that does not fit fails the node.23312332LIBRARY_MAX_ATTEMPTS = 32333_LAYOUT_ERRORS = ("LAYOUT_REQUIRED", "LAYOUT_NOT_IN_LIBRARY")2334# Kept out of the prompt: nothing to write there (fill/render own them).2335_UNWRITABLE_SLOTS = ("slideNumber", "unsupported")23362337LIBRARY_SYSTEM_PROMPT = '''You write the text of ONE slide that uses a fixed layout from a customer's PowerPoint master. The master fixes the layout's design, positions and fonts; you only decide what text goes into each of its slots. You answer with slot JSON.23382339OUTPUT: exactly one JSON object and nothing else: no markdown code fences, no explanation, no reasoning.2340{"slots": {"<slot id>": <value>, ...}, "notes": "<speaker notes>"}23412342The value depends on the slot's kind:2343- text slot: {"text": "One paragraph. Use \\n for a line break."}2344- bullets slot: {"bullets": [{"text": "A point", "level": 0}, {"text": "A sub-point", "level": 1}]} (level 0 to 4)2345- table slot: {"table": {"header": ["Column", "Column"], "rows": [["Cell", "Cell"]]}}2346Inside any text you may use <b>, <i> and <u>. No other HTML and no markdown (no **bold**, no "- " list markers, no # headings).23472348RULES:23491. Use only the slot ids listed for this layout, each with the kind listed for it.23502. Fill every REQUIRED slot. Fill an optional slot only when the source gives you something real for it; otherwise leave it out (an empty slot is removed from the slide).23513. Every limit is hard: text that does not fit fails the slide, and nothing is cut for you. Characters include spaces. Aim for about 80% of each character limit, and remember that long words wrap onto extra lines. In a bullets slot every item, sub-points included, starts on a new line: the number of items and the length of each item are what fill the box. In a table the header row counts as a row.23524. Slot groups are repeated sets of slots (for example number, title and description). Fill whole groups in order, starting with the first, without gaps. When a group says "fill exactly N groups", fill all N from the content's own items, never invented ones.23535. Picture slots are filled with the connected image for you: never write a value for a picture slot.23546. Write in the same language as the source content unless an output language is required below. Use ONLY facts from the source content: never invent names, numbers, dates or quotes.23557. Write slide text, not prose: short, scannable phrases, with no period at the end of titles or bullet points.23568. "notes": 1 to 3 plain-text sentences for the speaker.'''235723582359def library_handle(raw):2360    """The connected Layout Library handle, or None when no library is connected.23612362    The handle arrives as an object; a JSON string is accepted, as in the PPTX2363    Builder. Any other non-empty value is a wiring mistake and fails the node.2364    """2365    if raw is None or raw == {}:2366        return None2367    if isinstance(raw, str):2368        if not raw.strip():2369            return None2370        try:2371            raw = json.loads(raw)2372        except json.JSONDecodeError:2373            raw = None2374    catalog = raw.get("catalog") if isinstance(raw, dict) else None2375    library_id = raw.get("libraryId") if isinstance(raw, dict) else None2376    if (2377        not isinstance(catalog, dict)2378        or not isinstance(catalog.get("layouts"), list)2379        or not isinstance(library_id, str)2380        or not library_id2381    ):2382        raise ValueError(2383            "Input 'library' is not a Layout Library handle: connect the Layout Library node's library output"2384        )2385    return raw238623872388def _quoted(value, limit=120):2389    """A master- or curator-supplied string for one prompt line: whitespace2390    collapsed, capped, JSON-quoted (so it can never break the prompt's lines)."""2391    text = " ".join(str(value if value is not None else "").split())2392    if len(text) > limit:2393        text = text[: limit - 1].rstrip() + "…"2394    return json.dumps(text, ensure_ascii=False)239523962397def _count(n, one, many=None):2398    return f"{n} {one if n == 1 else (many or one + 's')}"239924002401def _numbers(values):2402    """[2, 5, 7] -> "2, 5 and 7"."""2403    words = [str(v) for v in values]2404    return words[0] if len(words) == 1 else ", ".join(words[:-1]) + " and " + words[-1]240524062407# A level-1 sub-point is worth a note in the brief when it takes this much more2408# room than a main point, or allows this much less text per line.2409_SUB_POINT_ROOM = 1.152410_SUB_POINT_CHARS = 0.85241124122413def _spacing_pt(spacing, size):2414    """A slot-style spacing (["pct", x] or ["pts", x]) in points. Only ratios of2415    these are used, so the typeface's line factor is a constant 1.2."""2416    if not isinstance(spacing, (list, tuple)) or len(spacing) != 2:2417        return 0.02418    kind, value = spacing2419    return float(value) if kind == "pts" else float(value) * size * 1.2242024212422def _sub_points(slot, main_chars):2423    """(characters per line, room) of a level-1 sub-point, or None.24242425    Read from the slot's style (gais.slides measures with the same sizes,2426    spacing and indents): room is its height, spacing included, in main2427    points; characters per line scale `main_chars` by width and font size.2428    A master's level 1 can be set larger than level 0, which a model cannot2429    guess from the item count.2430    """2431    style = slot.get("style") if isinstance(slot.get("style"), dict) else {}2432    levels = style.get("levels") if isinstance(style.get("levels"), list) else []2433    if len(levels) < 2 or not all(isinstance(level, dict) for level in levels[:2]):2434        return None2435    main, sub = levels[0], levels[1]2436    try:2437        main_size, sub_size = float(main["sizePt"]), float(sub["sizePt"])2438        if not (0 < main_size < 1000 and 0 < sub_size < 1000):  # also False for NaN2439            return None24402441        def room(level, size):2442            return sum(_spacing_pt(level.get(key, ["pct", 1.0] if key == "line" else ["pts", 0]), size)2443                       for key in ("line", "spaceBefore", "spaceAfter"))24442445        ratio = room(sub, sub_size) / room(main, main_size)2446        if not 0 < ratio < 10:2447            return None2448        width = 1.02449        box, insets = slot.get("box"), style.get("insets") or {}2450        if isinstance(box, dict) and box.get("w"):2451            inner = float(box["w"]) - float(insets.get("l", 0)) - float(insets.get("r", 0))2452            main_w, sub_w = inner - float(main.get("marL", 0)), inner - float(sub.get("marL", 0))2453            if main_w > 0 and sub_w > 0:2454                width = sub_w / main_w2455        chars = max(1, int(main_chars * width * main_size / sub_size))2456    except (KeyError, TypeError, ValueError, ZeroDivisionError, OverflowError):2457        return None2458    return chars, ratio245924602461def _bullets_limit(slot, chars, lines, items):2462    """The binding limits of a bullets slot: item count and one line per item.24632464    `maxChars // maxLines` characters fit on one line (gais.slides keeps a2465    wrap margin in maxChars), and `maxItems` one-line items fill `maxLines`2466    with the spacing between them, so these two numbers also keep the total2467    under maxChars.2468    """2469    per_item = max(1, chars // lines) if lines else max(1, chars)2470    if items == 1:2471        return f"1 item of at most {per_item} characters, on one line"2472    text = (2473        f"at most {items} items, each at most {per_item} characters so it fits on one line "2474        f"({items} one-line items fill the {_count(lines, 'line')} of the box, the space between "2475        "items included; an item that wraps onto a second line takes the room of 2)"2476    )2477    sub = _sub_points(slot, per_item)2478    if sub:2479        sub_chars, room = sub2480        if room >= _SUB_POINT_ROOM:2481            text += (2482                f"; sub-points (level 1) are set larger in this slot: at most {sub_chars} characters each, "2483                f"and each takes the room of about {room:.1f} items (at most {max(1, int(items / room))} items "2484                "if all of them are sub-points)"2485            )2486        elif sub_chars < per_item * _SUB_POINT_CHARS:2487            text += f"; sub-points (level 1) are indented: at most {sub_chars} characters each"2488    return text248924902491def _slot_limit(slot):2492    """The slot's capacity as words the model can act on."""2493    cap = slot.get("capacity") or {}2494    if not cap:2495        return "keep it short"2496    if slot.get("content") == "table":2497        rows = cap.get("maxRows")2498        data_rows = f" (a header and up to {rows - 1} data rows)" if isinstance(rows, int) and rows > 1 else ""2499        return (2500            f"at most {rows} rows INCLUDING the header row{data_rows}, at most {cap.get('maxCols')} columns, "2501            f"{cap.get('maxCellChars')} characters per cell; a cell that wraps onto more lines makes its row "2502            "count as 2 or more rows"2503        )2504    chars = int(cap.get("maxChars") or 0)2505    lines = int(cap.get("maxLines") or 0)2506    items = cap.get("maxItems") if slot.get("content") == "bullets" else None2507    if items:2508        return _bullets_limit(slot, chars, lines, int(items))2509    if lines <= 1:2510        # A one-line limit is the full line (multi-line limits keep a wrap2511        # margin), so text right at it can still wrap onto a second line.2512        return f"1 line, at most {chars} characters (aim for about {max(1, int(chars * 0.8))}: a full line can wrap)"2513    return f"at most {lines} lines (about {chars // lines} characters per line), {chars} characters in total"251425152516def _slot_line(slot):2517    kind = slot.get("content")2518    purpose = _quoted(slot.get("hint") or str(slot.get("type", "")).lower(), 80)2519    if kind == "picture":2520        return f'- "{slot["idx"]}": picture, {purpose}: the connected image goes here; do not write it'2521    flag = "REQUIRED" if slot.get("required") else "optional"2522    return (2523        f'- "{slot["idx"]}": {kind}, {flag}, for {purpose} (placeholder {slot.get("type")}): '2524        f"{_slot_limit(slot)}"2525    )252625272528def _group_line(group):2529    sets = group.get("slots") or []2530    ids = ", ".join("(" + ", ".join(f'"{idx}"' for idx in members) + ")" for members in sets)2531    if group.get("fixedCount"):2532        rule = f"fill exactly {len(sets)} groups"2533    else:2534        rule = f"fill as many groups as the content needs, up to {len(sets)}, from the first and without gaps"2535    return f"- {_quoted(group.get('name') or group.get('id'))}: {len(sets)} groups of slots {ids}; {rule}"253625372538def build_library_messages(layout, *, content, instruction, language, has_image):2539    """System + user messages asking for the slot JSON of `layout`."""2540    lines = [2541        f"Layout {_quoted(layout.get('label') or layout['layoutId'])} "2542        f"(master layout {_quoted(layout['layoutId'])})"2543    ]2544    if layout.get("description"):2545        lines.append(f"Use: {_quoted(layout['description'], 600)}")2546    lines.append("Slots (id: kind, required or optional, what goes there, limit):")2547    for slot in layout.get("slots") or []:2548        kind = slot.get("content")2549        if kind in _UNWRITABLE_SLOTS or (kind == "picture" and not has_image):2550            continue2551        lines.append(_slot_line(slot))2552    groups = layout.get("groups") or []2553    if groups:2554        lines.append("Slot groups:")2555        lines.extend(_group_line(group) for group in groups)2556    user_message = (2557        "<layout>\n" + "\n".join(lines) + "\n</layout>\n\n"2558        f"<instruction>{instruction}</instruction>\n\n"2559        f"<source_content>\n{content}\n</source_content>"2560    )2561    return [2562        {"role": "system", "content": LIBRARY_SYSTEM_PROMPT + language_directive(language)},2563        {"role": "user", "content": user_message},2564    ]256525662567_JSON_FENCE_RE = re.compile(r"^\s*```[\w-]*\s*$", re.MULTILINE)2568_THINK_RE = re.compile(r"<think>[\s\S]*?</think>", re.IGNORECASE)2569_MAX_JSON_STARTS = 50257025712572def _close_brackets(text):2573    """`text` with mismatched closing brackets put right, or None.25742575    Small models often end an array with "}" (`[{…}}`). Outside strings, a2576    closer that does not match the innermost open bracket first closes the2577    brackets opened after its match, and a closer with no match is dropped.2578    A reply that ends with brackets still open is None: it was cut short, and2579    closing it would silently drop the rest of the slide. The result is parsed2580    and validated like any reply.2581    """2582    out, stack, in_string, escaped = [], [], False, False2583    for ch in text:2584        if in_string:2585            out.append(ch)2586            if escaped:2587                escaped = False2588            elif ch == "\\":2589                escaped = True2590            elif ch == '"':2591                in_string = False2592            continue2593        if ch == '"':2594            in_string = True2595        elif ch in "{[":2596            stack.append("}" if ch == "{" else "]")2597        elif ch in "}]":2598            if ch not in stack:2599                continue2600            while stack[-1] != ch:2601                out.append(stack.pop())2602            stack.pop()2603        out.append(ch)2604    if in_string or stack:2605        return None2606    return "".join(out)260726082609def _slot_map(candidate):2610    """A bare {"<idx>": …} map (slot ids are digits; "notes" may sit beside them)."""2611    keys = [key for key in candidate if key != "notes"]2612    return bool(keys) and all(isinstance(key, str) and key.strip().isdigit() for key in keys)261326142615def _json_error(text, exc):2616    excerpt = text[max(0, exc.pos - 40): exc.pos + 20].replace("\n", " ")2617    return f"invalid JSON ({exc.msg}: character {exc.pos}, near {json.dumps(excerpt, ensure_ascii=False)})"261826192620def parse_slot_json(raw):2621    """(slot JSON as {"slots": {...}, "notes": "…"}, None) or (None, reason).26222623    Takes the whole reply as JSON when it parses, else the reply with its2624    closing brackets put right, else the first object in it that carries2625    "slots" (fences and <think> blocks are dropped first). A bare2626    {"<idx>": …} map is accepted and wrapped; "notes" put inside "slots" is2627    moved out. Anything else is invalid JSON, with where it broke.2628    """2629    text = _JSON_FENCE_RE.sub("", _THINK_RE.sub("", raw or "")).strip()2630    if not text:2631        return None, "empty response"2632    try:2633        data = json.loads(text)2634    except json.JSONDecodeError as exc:2635        data, error = None, _json_error(text, exc)2636        start = text.find("{")2637        repaired = _close_brackets(text[start:]) if start >= 0 else None2638        try:2639            fixed = json.loads(repaired) if repaired else None2640        except json.JSONDecodeError:2641            fixed = None2642        if isinstance(fixed, dict) and ("slots" in fixed or _slot_map(fixed)):2643            data = fixed2644        decoder = json.JSONDecoder()2645        bare = None2646        for _ in range(_MAX_JSON_STARTS):2647            if data is not None or start < 0:2648                break2649            try:2650                candidate, _end = decoder.raw_decode(text, start)2651            except json.JSONDecodeError:2652                candidate = None2653            # Only slot JSON counts, and an object with "slots" wins over an2654            # earlier bare map: an inner value of a broken reply, such as2655            # {"text": "…"}, is not slot JSON.2656            if isinstance(candidate, dict):2657                if "slots" in candidate:2658                    data = candidate2659                elif bare is None and _slot_map(candidate):2660                    bare = candidate2661            start = text.find("{", start + 1)2662        data = data if data is not None else bare2663        if data is None:2664            return None, error2665    if not isinstance(data, dict):2666        return None, "the response is not a JSON object"2667    if "slots" in data:2668        slots = data["slots"]2669    else:2670        slots = {key: value for key, value in data.items() if key != "notes"}2671    if not isinstance(slots, dict):2672        return None, '"slots" must be an object keyed by slot id'2673    notes = data.get("notes")2674    if isinstance(slots.get("notes"), str):2675        # Never a slot id: the speaker notes, one level too deep.2676        slots = {key: value for key, value in slots.items() if key != "notes"}2677        if not (isinstance(notes, str) and notes.strip()):2678            notes = data["slots"]["notes"]2679    return {"slots": slots, "notes": notes if isinstance(notes, str) else ""}, None268026812682_SIZE_WORDS = {2683    "chars": ("character", "characters"),2684    "lines": ("line", "lines"),2685    "items": ("item", "items"),2686    "rows": ("row", "rows"),2687    "cols": ("column", "columns"),2688    "maxCellChars": ("character in the longest cell", "characters in the longest cell"),2689}269026912692def _sizes(values):2693    """{"chars": 69, "lines": 2} -> "69 characters, 2 lines"."""2694    parts = []2695    for key, n in (values or {}).items():2696        one, many = _SIZE_WORDS.get(key, (key, key))2697        parts.append(f"{n} {one if n == 1 else many}")2698    return ", ".join(parts)269927002701def _ints(values):2702    return [v for v in values if isinstance(v, int) and not isinstance(v, bool)] if isinstance(values, list) else []270327042705def _bullets_advice(violation, over, slot):2706    """Sentences for an over-full bullets slot, one per dimension over.27072708    Lines are what a list usually runs out of, and cutting characters does not2709    fix them: the advice names how many items fit (gais.slides' fitItems),2710    the items that wrap (wrappedItems) and the sub-points (subItems) when this2711    slot sets them larger than main points.2712    """2713    limit = violation["limit"]2714    sentences = []2715    if "chars" in over:2716        sentences.append(f"cut it to about {max(1, int(limit['chars'] * 0.8))} characters in total")2717    if "lines" in over:2718        lines = limit.get("lines") or 12719        per_item = max(1, int(limit.get("chars") or 0) // lines)2720        sentences.append(2721            f"use fewer items or shorter items: it takes {violation['size'].get('lines')} lines and the limit "2722            f"is {lines}, the space between items included; each item over about {per_item} characters wraps "2723            "onto 2 lines"2724        )2725        fit = violation.get("fitItems")2726        if isinstance(fit, int) and not isinstance(fit, bool):2727            sentences.append(2728                f"only the first {_count(fit, 'item')} fit, so keep at most {fit}: merge or drop the least "2729                "important ones"2730            )2731        wrapped = _ints(violation.get("wrappedItems"))2732        if wrapped:2733            which = f"item {wrapped[0]} wraps" if len(wrapped) == 1 else f"items {_numbers(wrapped)} wrap"2734            sentences.append(f"{which} onto a second line: keep every item under {per_item} characters")2735        subs = violation.get("subItems")2736        sub = _sub_points(slot, per_item) if isinstance(slot, dict) else None2737        if isinstance(subs, int) and subs > 0 and sub and sub[1] >= _SUB_POINT_ROOM:2738            sentences.append(2739                f"{'its sub-point is' if subs == 1 else f'its {subs} sub-points are'} set larger than a main "2740                f"point here: each takes the room of about {sub[1]:.1f} items and wraps over about {sub[0]} "2741                "characters; turn sub-points into main points or drop them"2742            )2743    if "items" in over:2744        sentences.append(f"use at most {limit['items']} items")2745    return sentences274627472748def _table_advice(violation, over):2749    """Sentences for an over-full table slot: the row limit counts the header2750    row (headerRow / dataRows) and every extra line a wrapped row takes2751    (wrappedRows)."""2752    limit = violation["limit"]2753    first = []2754    if over & {"rows", "cols"}:2755        first.append(f"use at most {limit.get('rows')} rows and {limit.get('cols')} columns")2756    if "maxCellChars" in over:2757        first.append(f"keep every cell under {limit['maxCellChars']} characters")2758    sentences = ["; ".join(first)] if first else []2759    if "rows" in over:2760        rows = limit.get("rows")2761        header, data = violation.get("headerRow"), violation.get("dataRows")2762        if isinstance(header, bool) and isinstance(data, int) and not isinstance(data, bool):2763            sent = int(header) + data2764            what = (f"a header row and {_count(data, 'data row')} ({sent} rows)" if header2765                    else _count(data, "row"))2766            line = f"the {rows} rows include the header row: you sent {what}"2767            if isinstance(rows, int) and sent > rows:2768                line += f", so keep at most {_count(rows - int(header), 'data row' if header else 'row')}"2769            sentences.append(line)2770        else:2771            sentences.append(f"the {rows} rows include the header row")2772        wrapped = _ints(violation.get("wrappedRows"))2773        if wrapped:2774            which = f"row {wrapped[0]} has" if len(wrapped) == 1 else f"rows {_numbers(wrapped)} have"2775            sentences.append(2776                f"{which} cells that wrap onto more lines (the header is row 1), and each extra line counts "2777                "as a row: shorten those cells"2778            )2779    return sentences278027812782def _fix_advice(violation, slot=None):2783    """What to change in one over-full slot, per dimension over its limit."""2784    size, limit = violation.get("size") or {}, violation.get("limit") or {}2785    over = {key for key, n in limit.items() if isinstance(n, (int, float)) and (size.get(key) or 0) > n}2786    if "items" in limit:2787        sentences = _bullets_advice({**violation, "size": size, "limit": limit}, over, slot)2788    elif "rows" in limit:2789        sentences = _table_advice({**violation, "size": size, "limit": limit}, over)2790    else:2791        sentences = []2792        if over & {"chars", "lines"} and limit.get("chars"):2793            line = f"cut it to about {max(1, int(limit['chars'] * 0.8))} characters"2794            if "lines" in over and (limit.get("lines") or 0) > 1:2795                line += " and use fewer line breaks"2796            sentences.append(line)2797    sentences = [s for s in sentences if s] or ["shorten it"]2798    return ". ".join(s[:1].upper() + s[1:] for s in sentences) + "."279928002801def describe_problem(error, layout=None):2802    """Lines telling the model how to fix one gais.slides LibraryError.28032804    `layout` (the catalog layout) lets the advice read a slot's style, e.g.2805    sub-points set larger than main points."""2806    p = error.params or {}2807    if error.code == "SLOT_OVER_CAPACITY":2808        slots = {s.get("idx"): s for s in (layout or {}).get("slots") or [] if isinstance(s, dict)}2809        return [2810            f'slot "{v.get("idx")}" ({_quoted(v.get("hint"), 80)}) does not fit: it has {_sizes(v.get("size"))}; '2811            f'the limit is {_sizes(v.get("limit"))}. {_fix_advice(v, slots.get(v.get("idx")))}'2812            for v in p.get("violations") or []2813            if isinstance(v, dict)2814        ]2815    if error.code == "REQUIRED_SLOT_MISSING":2816        return [f'slot "{p.get("slot")}" ({_quoted(p.get("hint"), 80)}) is REQUIRED: fill it.']2817    if error.code == "UNPLACEABLE_ELEMENT":2818        if p.get("type") is None:  # no such slot in the layout2819            return [f'slot "{p.get("slot")}" is not in this layout: use only the listed slot ids.']2820        return [2821            f'slot "{p.get("slot")}" cannot take a {p.get("element")} value: use only the listed slot ids, '2822            "each with the kind listed for it."2823        ]2824    if error.code == "GROUP_COUNT_MISMATCH":2825        return [2826            f'group {_quoted(p.get("group"))}: fill exactly {p.get("expected")} groups '2827            f'(you filled {p.get("filled")}). Use only the items the content gives, '2828            "and never invent one to reach the count."2829        ]2830    if error.code == "GROUP_GAP":2831        missing = ", ".join(str(n) for n in p.get("missing") or [])2832        return [2833            f'group {_quoted(p.get("group"))}: fill the groups in order without gaps '2834            f"(group {missing} is empty while a later one is filled)."2835        ]2836    return [str(error)]283728382839def library_feedback(problems, layout=None):2840    lines = [line for problem in problems for line in describe_problem(problem, layout)]2841    return (2842        "Your slot JSON does not fit the layout:\n"2843        + "\n".join(f"- {line}" for line in lines)2844        + "\nFix every problem and answer with the complete corrected JSON object only."2845    )284628472848def generate_library_slide(handle, layout_id, *, content, instruction, llm_model_id, language, image_data_uri):2849    """The library <section> for `layout_id`, or raise the named LibraryError."""2850    slides = Gais.slides2851    catalog = handle["catalog"]2852    if handle.get("libraryName") and not catalog.get("libraryName"):2853        # gais.slides names the library in LAYOUT_REQUIRED by catalog.libraryName.2854        catalog = {**catalog, "libraryName": handle["libraryName"]}2855    library_id = handle["libraryId"]2856    layout_id = layout_id.strip() if isinstance(layout_id, str) else ""2857    has_image = bool(image_data_uri)28582859    # Checked on an empty slot map before any LLM call, because no retry can2860    # fix them: no layout picked, a layout the library does not offer, and a2861    # required picture slot with no image connected.2862    preflight = slides.slot_problems(catalog, layout_id, {}, has_image=has_image)2863    if preflight and preflight[0].code in _LAYOUT_ERRORS:2864        raise preflight[0]2865    layout = next(2866        (x for x in catalog["layouts"] if isinstance(x, dict) and x.get("layoutId") == layout_id),2867        None,2868    )2869    if layout is None:2870        raise slides.LibraryError("LAYOUT_NOT_IN_LIBRARY", layout=layout_id)2871    pictures = {s.get("idx") for s in layout.get("slots") or [] if s.get("content") == "picture"}2872    for problem in preflight:2873        if problem.code == "REQUIRED_SLOT_MISSING" and problem.params.get("slot") in pictures:2874            raise problem2875    if has_image and not pictures:2876        print(f"[library] layout {layout_id!r} has no picture slot: the image is not used", file=sys.stderr)28772878    base_messages = build_library_messages(2879        layout, content=content, instruction=instruction, language=language, has_image=has_image2880    )2881    messages = list(base_messages)2882    last_problem = None2883    last_reason = ""2884    for attempt in range(1, LIBRARY_MAX_ATTEMPTS + 1):2885        print(f"Library slide attempt {attempt}/{LIBRARY_MAX_ATTEMPTS}", file=sys.stderr)2886        raw_output = Gais.llm.chat(2887            messages, model_id=llm_model_id, temperature=0.3, thinking=False2888        ).text2889        slot_json, reason = parse_slot_json(raw_output)2890        if slot_json is None:2891            last_reason = reason2892            print(f"Attempt {attempt}: invalid slot JSON — {reason}", file=sys.stderr)2893            previous = (raw_output or "")[:4000]2894            feedback = (2895                f"Your previous response was not valid slot JSON: {reason}. Answer with exactly one "2896                'JSON object of the form {"slots": {...}, "notes": "..."} and nothing else.'2897            )2898        else:2899            # The full list, so one retry can fix every problem; validate_slots2900            # would raise only problems[0], which is what fails the node below.2901            problems = slides.slot_problems(catalog, layout_id, slot_json, has_image=has_image)2902            if not problems:2903                print(f"Attempt {attempt}: slot JSON fits {layout_id!r}", file=sys.stderr)2904                return slides.render_html(2905                    catalog,2906                    layout_id,2907                    slot_json,2908                    library_id=library_id,2909                    image_data_uri=image_data_uri or None,2910                    notes=slot_json["notes"] or None,2911                )2912            last_problem = problems[0]2913            print(2914                f"Attempt {attempt}: slot JSON rejected — {', '.join(p.code for p in problems)}",2915                file=sys.stderr,2916            )2917            previous = json.dumps(slot_json, ensure_ascii=False)2918            feedback = library_feedback(problems, layout)2919        messages = base_messages + [2920            {"role": "assistant", "content": previous},2921            {"role": "user", "content": feedback},2922        ]29232924    # The last validation failure names the slot, its limit and its size2925    # (SLOT_OVER_CAPACITY carries violations, filled and hasImage for the2926    # "roomier layout" filter). Only when no attempt parsed is it SLOT_JSON_INVALID.2927    if last_problem is not None:2928        raise last_problem2929    raise slides.LibraryError("SLOT_JSON_INVALID", layout=layout_id, reason=last_reason)293029312932def main():2933    try:2934        execution_input = json.loads(sys.stdin.read())2935        inputs = execution_input.get("inputs", {})29362937        # A fixed layout reads no model, content or instruction: it comes before2938        # their checks. Unless the node asks for one, nothing below changes.2939        fixed = fixed_layout_slide(inputs)2940        if fixed is not None:2941            print(json.dumps(fixed))2942            return29432944        content = inputs.get("content")2945        instruction = inputs.get("instruction")2946        image_filename = inputs.get("image", "")2947        template_name = inputs.get("template_name", "auto")2948        llm_model_id = inputs.get("llmModelId")2949        language = inputs.get("language", "auto")29502951        if not content:2952            raise ValueError("Required input 'content' not provided")2953        if not instruction:2954            raise ValueError("Required input 'instruction' not provided")2955        if not llm_model_id:2956            raise ValueError("Required input 'llmModelId' not provided")29572958        # Read image and convert to base64 data URI if provided.2959        # os/pathlib are lint-blocked in new-format nodes, so probe the input by2960        # attempting to open it (the executor mounts inputs under /data/input).2961        image_data_uri = ""2962        if image_filename:2963            image_path = f"/data/input/{image_filename}"2964            try:2965                with open(image_path, "rb") as f:2966                    b64 = base64.b64encode(f.read()).decode("ascii")2967                mime = mimetypes.guess_type(image_filename)[0] or "image/png"2968                image_data_uri = f"data:{mime};base64,{b64}"2969                print(f"Image loaded: {image_filename} ({len(b64)} base64 chars)", file=sys.stderr)2970            except FileNotFoundError:2971                print(f"Image not found: {image_path}", file=sys.stderr)29722973        # Library mode: a connected Layout Library replaces the built-in2974        # layouts; template_name and layoutName are ignored and `layout` names2975        # the slide's layout. With no library everything below runs exactly2976        # as before.2977        library = library_handle(inputs.get("library"))2978        # Layout design mode: a layoutName (and no library) designs one layout2979        # of a brand master. Without one, the prompts are exactly as before.2980        layout_name = clean_layout_name(inputs.get("layoutName"))2981        # A design applies in layout design mode only; without one the prompts2982        # and the slide are exactly as before.2983        design = clean_design(inputs.get("design"))2984        if design and library is not None:2985            print("Design ignored: a Layout Library is connected (its master sets the look)", file=sys.stderr)2986            design = ""2987        elif design and not layout_name:2988            print("Design ignored: it applies with a Master Layout Name", file=sys.stderr)2989            design = ""2990        if library is not None:2991            layout = inputs.get("layout")2992            print(2993                f"Generating library slide with model_id: {llm_model_id}, "2994                f"library {library.get('libraryName') or library['libraryId']}, layout {layout!r}",2995                file=sys.stderr,2996            )2997            if template_name and template_name != "auto":2998                print(f"Slide Layout {template_name!r} ignored: a Layout Library is connected", file=sys.stderr)2999            if layout_name:3000                print(f"Master Layout Name {layout_name!r} ignored: a Layout Library is connected", file=sys.stderr)3001            slide_html = generate_library_slide(3002                library,3003                layout,3004                content=content,3005                instruction=instruction,3006                llm_model_id=llm_model_id,3007                language=language,3008                image_data_uri=image_data_uri,3009            )3010            print(f"Generated library slide: {len(slide_html)} chars", file=sys.stderr)3011            print(json.dumps({"slide_html": slide_html}))3012            return30133014        print(f"Generating slide with model_id: {llm_model_id}", file=sys.stderr)3015        print(f"Content length: {len(content)} chars", file=sys.stderr)3016        print(f"Instruction: {instruction[:100]}...", file=sys.stderr)3017        print(f"Layout template: {template_name}", file=sys.stderr)3018        if layout_name:3019            print(f"Layout design mode: master layout {layout_name!r}", file=sys.stderr)3020        if design:3021            print(f"Design: {len(design)} chars, ranked above the instruction", file=sys.stderr)30223023        # Build system prompt with optional layout instruction; in layout3024        # design mode the design rules follow it (the chosen layout's structure3025        # still applies) and come before the output-language directive.3026        layout_instruction = build_layout_instruction(template_name)3027        system_prompt = SYSTEM_PROMPT + layout_instruction3028        if layout_name:3029            system_prompt += build_layout_design_instruction(3030                layout_name, template_name if layout_instruction else None3031            )3032            system_prompt += build_design_instruction(design, template_name if layout_instruction else None)3033        system_prompt += language_directive(language)30343035        # If image provided, add instruction to use {{IMAGE}} placeholder3036        if image_data_uri:3037            system_prompt += (3038                "\n\nIMAGE INPUT: The user has provided an image. "3039                "Use {{IMAGE}} as the exact src attribute value for the <img> tag. "3040                "CRITICAL: Always wrap the <img> inside a <div style='flex:1;min-width:0'> when using flex layout. "3041                "Never place <img> as a direct flex child — it will overflow. "3042                "Example: <div style='flex:1;min-width:0'><img src='{{IMAGE}}' alt='description' style='width:100%;border-radius:8px'></div>. "3043                "Do NOT use picsum.photos — use {{IMAGE}} instead."3044            )30453046        user_message = (3047            f"<instruction>{instruction}</instruction>\n\n"3048            f"<source_content>\n{content}\n</source_content>"3049        )30503051        base_messages = [3052            {"role": "system", "content": system_prompt},3053            {"role": "user", "content": user_message},3054        ]30553056        # Generate the slide, validating that the model returned a single3057        # well-formed <section>. Reasoning models sometimes leak scratch work or3058        # emit several draft sections, which produced decks that rendered blank3059        # (malformed HTML). Retry up to MAX_ATTEMPTS, nudging the model to fix3060        # the specific problem, then fail loudly rather than emit bad HTML.3061        MAX_ATTEMPTS = 33062        messages = list(base_messages)3063        slide_html = None3064        last_reason = ""3065        for attempt in range(1, MAX_ATTEMPTS + 1):3066            print(f"Slide generation attempt {attempt}/{MAX_ATTEMPTS}", file=sys.stderr)3067            # thinking=False: reasoning models leak <think> scratch work + draft3068            # sections into the HTML, which breaks the slide (blank decks) and3069            # forces the retry loop below. Suppressing reasoning removes the3070            # cause — cleaner single-<section> output, fewer/no retries.3071            raw_output = Gais.llm.chat(3072                messages, model_id=llm_model_id, temperature=0.3, thinking=False3073            ).text3074            candidate = _fix_flex_styles(extract_section(raw_output))3075            is_valid, reason = validate_slide_html(candidate)3076            if is_valid and layout_name:3077                # What ships is the slide as a browser reads it (a comment `<!-->`, a quote, a </style x>3078                # end where Python's parser reads another): its structure is checked again. The cleaning3079                # runs without a design too: a <style> the model wrote anyway stays in its layout.3080                candidate = apply_design_style(3081                    mark_layout_slots(candidate, layout_name), layout_name, design,3082                    template_name if layout_instruction else None,3083                )3084                is_valid, reason = validate_slide_html(candidate, placeholders=False)3085                if not is_valid:3086                    reason = (3087                        f"{reason} once read as a browser reads it (check its quotes, comments and <style> blocks; "3088                        "a layout is ONE <section>, and script, template and dialog elements are dropped)"3089                    )3090            if is_valid:3091                slide_html = candidate3092                print(f"Attempt {attempt}: valid slide HTML", file=sys.stderr)3093                break3094            last_reason = reason3095            print(f"Attempt {attempt}: invalid slide HTML — {reason}", file=sys.stderr)3096            # Add a corrective instruction for the next attempt.3097            messages = base_messages + [3098                {3099                    "role": "user",3100                    "content": (3101                        f"Your previous response was not valid slide HTML: {reason}. "3102                        "Respond with EXACTLY ONE well-formed <section>...</section> "3103                        "element and NOTHING else: no reasoning, no explanation, no "3104                        "markdown code fences, no extra <section> blocks, and every "3105                        "tag properly closed."3106                    ),3107                }3108            ]31093110        if slide_html is None:3111            raise ValueError(3112                f"Slide generator could not produce valid HTML after {MAX_ATTEMPTS} "3113                f"attempts. Last validation error: {last_reason}"3114            )31153116        # Replace {{IMAGE}} placeholder with actual base64 data URI3117        if image_data_uri and "{{IMAGE}}" in slide_html:3118            slide_html = slide_html.replace("{{IMAGE}}", image_data_uri)3119            print("Replaced {{IMAGE}} placeholder with base64 data URI", file=sys.stderr)3120        elif layout_name and "{{IMAGE}}" in slide_html:3121            slide_html = slide_html.replace("{{IMAGE}}", _BLANK_IMAGE)  # no image: a broken link otherwise31223123        print(f"Generated slide: {len(slide_html)} chars", file=sys.stderr)31243125        output = {"slide_html": slide_html}3126        if layout_name:3127            output["designDropped"] = design_dropped(design, layout_name, template_name if layout_instruction else None)3128        print(json.dumps(output))31293130    except Exception as e:3131        error_output = {3132            "error": str(e),3133            "errorType": type(e).__name__,3134            "traceback": traceback.format_exc(),3135        }3136        print(json.dumps(error_output), file=sys.stderr)3137        sys.exit(1)313831393140if __name__ == "__main__":3141    main()3142

$ git log --oneline

v1.16.0
HEAD
2026-11-01

Limits and fixed counts match the drawn layouts, from live brand library runs.

v1.15.02026-11-01

Optional fixedLayout input: on a built-in layout in layout design mode, the node draws a fixed structure with no model, and the same inputs give the same slide.

v1.14.02026-10-29

1.14.0: optional Design input for layout design mode (a brand's design.md), ranked above the instruction; the model's CSS kept to an allowlist and the layout slide written again as a browser reads it; layout design mode cleans without a design too (PR #1567).

v1.13.02026-10-26

New layoutName input (Master Layout Name) switches on layout design mode: writes a reusable layout with data-slot marks and a data-layout-name stamp instead of a finished slide; self-closed elements are closed. Measured node height 960.

v1.12.02026-10-22

1.12.0: built-in Polizia Postale brand removed; Brand Master review fixes (PR #1509)

v1.11.02026-10-21

1.11.0: library mode; Polizia Postale layouts gated by the per-org allowlist (PR #1499)

v1.10.02026-08-18