$ cat node-template.py
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.
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 | Unit | Date | 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 · DATE · 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'>— Source or speaker, ROLE</p>\n </div>\n </div>\n <div class='corner-index'>SECTION <span class='num'>NN</span> · 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 · MM.YYYY</span></div>\n </div>\n <div class='text-col'>\n <span class='eyebrow'>Customer story · 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> — Place</span>\n <span><strong>Words by</strong> — 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 — 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'>— 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'>— 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 · MM.YYYY</span></div>\n <p class='caption'>Italic caption text. <em>Photographed by NAME · 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 · Place · 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\">€</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'>↑ 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 ↓. 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'>→</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 — 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 — 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'>×</span></td><td class='featured'><span class='yes'>✓</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'>✓</span>, <span class='no'>×</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 — 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 · 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 · Company · 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'>€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'>€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'>/æIpa\u02c8notæSiΗ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 (, 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('"', """).replace("<", "<").replace(">", ">")883 value = _DROPPED_CHARREF_RE.sub(lambda m: "&" 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 `<`, 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("<")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("<")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("<")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 ©= 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"<\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">→</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
Limits and fixed counts match the drawn layouts, from live brand library runs.
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.
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).
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.
1.12.0: built-in Polizia Postale brand removed; Brand Master review fixes (PR #1509)
1.11.0: library mode; Polizia Postale layouts gated by the per-org allowlist (PR #1499)