diff --git a/kits/slides/app/package-lock.json b/kits/slides/app/package-lock.json index 9723a9b..08c5b9a 100644 --- a/kits/slides/app/package-lock.json +++ b/kits/slides/app/package-lock.json @@ -10,10 +10,11 @@ "highlight.js": "^11.10.0", "lucide-react": "^0.469.0", "mermaid": "^11.4.1", + "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", - "reifyui": "^0.4.1", + "reifyui": "^0.4.2", "remark-gfm": "^4.0.0" }, "devDependencies": { @@ -1572,6 +1573,15 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -1786,6 +1796,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -2593,6 +2609,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2605,6 +2627,27 @@ "node": ">=0.10.0" } }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -2615,6 +2658,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -2686,6 +2735,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2718,6 +2773,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -2754,6 +2821,15 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -3726,6 +3802,12 @@ "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3822,6 +3904,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pptxgenjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", + "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.8.1", + "https": "^1.0.0", + "image-size": "^1.2.1", + "jszip": "^3.10.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -3832,6 +3932,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3894,10 +4003,25 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/reifyui": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/reifyui/-/reifyui-0.4.1.tgz", - "integrity": "sha512-iAuaGpT/+GX1WcxYj8FDxxNv5qraVuAyXyHETiFArsIi88924MFPccXtKamerrOeFxQCWvZL/ptnuOJ3alzmVg==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/reifyui/-/reifyui-0.4.2.tgz", + "integrity": "sha512-FoOb7NTISYQaTK+b3DrnlGs9r3s4vtVY7XmOLbSHZbj963dIiRQPQp2Kp0KqXcga66HTDKO+gZzeNMQ0oBfN6w==", "license": "MIT", "engines": { "node": ">=18" @@ -4057,6 +4181,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4082,6 +4212,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4102,6 +4238,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4201,6 +4346,12 @@ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4319,6 +4470,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", diff --git a/kits/slides/app/package.json b/kits/slides/app/package.json index ad33c2d..23e22d1 100644 --- a/kits/slides/app/package.json +++ b/kits/slides/app/package.json @@ -9,13 +9,14 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "reifyui": "^0.4.1", + "reifyui": "^0.4.2", "echarts": "^5.5.1", "mermaid": "^11.4.1", "highlight.js": "^11.10.0", "lucide-react": "^0.469.0", "react-markdown": "^9.0.1", - "remark-gfm": "^4.0.0" + "remark-gfm": "^4.0.0", + "pptxgenjs": "^4.0.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.4", diff --git a/kits/slides/app/src/App.jsx b/kits/slides/app/src/App.jsx index 1203849..25ed33e 100644 --- a/kits/slides/app/src/App.jsx +++ b/kits/slides/app/src/App.jsx @@ -15,7 +15,9 @@ function parseHash() { const raw = window.location.hash.replace(/^#\/?/, ''); const [path, query = ''] = raw.split('?'); const params = new URLSearchParams(query); - if (path === 'print') return { page: 'print' }; + if (path === 'print') { + return { page: 'print', handoff: params.get('h') || '', id: params.get('deck') || '' }; + } if (path.startsWith('d/')) { return { page: 'deck', id: decodeURIComponent(path.slice(2)), seed: params.get('seed') || '', template: params.get('tpl') || '' }; @@ -32,7 +34,7 @@ export default function App() { return () => window.removeEventListener('hashchange', onHash); }, []); - if (route.page === 'print') return ; + if (route.page === 'print') return ; if (route.page === 'deck') { return ; } diff --git a/kits/slides/app/src/components/Dialog.jsx b/kits/slides/app/src/components/Dialog.jsx index bb99eff..76dfe15 100644 --- a/kits/slides/app/src/components/Dialog.jsx +++ b/kits/slides/app/src/components/Dialog.jsx @@ -59,3 +59,18 @@ export function NameDialog({ title, body, defaultValue = '', confirmLabel = 'Cre ); } + +/** A message the person must acknowledge. In-app, never window.alert — a browser popup is the + * clearest signal a product was not finished, and it cannot carry detail or styling. */ +export function AlertDialog({ title, body, detail, onClose }) { + return ( + +

{title}

+
+ {Array.isArray(body) ? body.map((line, i) =>

{line}

) : body} +
+ {detail &&
{detail}
} +
+
+ ); +} diff --git a/kits/slides/app/src/lib/exportPdf.js b/kits/slides/app/src/lib/exportPdf.js new file mode 100644 index 0000000..b3a2c15 --- /dev/null +++ b/kits/slides/app/src/lib/exportPdf.js @@ -0,0 +1,44 @@ +// PDF export: the browser's own print pipeline, pointed at a fixed print surface. +// +// There is no export service in this deployment, and this is not a compromise — printing #/print +// renders the deck through the SAME components the canvas uses, so text stays selectable vector +// and charts stay vector (the chart element already renders SVG). Any library that rasterises the +// slide would be larger, slower and worse. +// +// The honest cost, stated in the UI rather than hidden: this opens the system print dialog. We +// cannot name the file or choose the destination, so the menu says "Print to PDF…", not +// "download". +const KEY = 'slides.print.'; +const STALE_MS = 5 * 60 * 1000; + +/** Hand the LIVE deck to the print tab. + * + * Deliberately not "let the print tab load it by id": autosave is debounced and is refused with + * 409 while a turn is running, so the file on disk is not always what the person is looking at. + * Printing something other than what is on screen would be its own bug. + * + * Must run inside the click. Opening the tab after an await loses user activation and the + * browser blocks it as a popup. + */ +export function openPrintView(id, deck) { + for (const k of Object.keys(localStorage)) { + if (k.startsWith(KEY) && Date.now() - Number(k.slice(KEY.length)) > STALE_MS) { + localStorage.removeItem(k); // a handoff nobody collected (tab closed, print cancelled) + } + } + + const key = KEY + Date.now(); + try { + localStorage.setItem(key, JSON.stringify({ id, deck })); + } catch { + throw new Error('This deck is too large to hand to the print view.'); + } + + const url = `${window.location.pathname}#/print?h=${encodeURIComponent(key)}` + + `&deck=${encodeURIComponent(id || '')}`; + const tab = window.open(url, '_blank'); + if (!tab) { + localStorage.removeItem(key); + throw new Error('Your browser blocked the print tab. Allow pop-ups for this page, then try Export again.'); + } +} diff --git a/kits/slides/app/src/lib/exportPptx.js b/kits/slides/app/src/lib/exportPptx.js new file mode 100644 index 0000000..05429aa --- /dev/null +++ b/kits/slides/app/src/lib/exportPptx.js @@ -0,0 +1,285 @@ +// PowerPoint export: real, editable PowerPoint objects — not screenshots of slides. +// +// Text, shapes and tables become native text boxes, autoshapes and tables, so the recipient can +// edit them. Only the things PowerPoint genuinely cannot express (charts, diagrams, gradient +// backgrounds, SVG embeds) become images, and the caller is told exactly which and how many. +// +// The whole mapping hinges on one constant: the stage is 1920×1080 px and a PowerPoint 16:9 slide +// is 13.333×7.5 in, so 1 stage px = 1/144 in and, for type, exactly 0.5 pt. Get that wrong and +// every deck exports looking broken. +// +// pptxgenjs is imported by this module ONLY, so it lands in its own chunk and costs nothing until +// someone actually exports. +import { ROLE_STYLE } from 'reifyui/slides'; // one source of truth — never re-declare it here + +const PX_IN = 13.333 / 1920; +const inch = (v) => +((v || 0) * PX_IN).toFixed(4); +const pt = (v) => +((v || 0) / 2).toFixed(2); // 1920 px == 960 pt + +// Deck styles carry CSS colours AND theme variables (a shape defaults to var(--sl-brand), text to +// var(--sl-ink)). Resolve the variable, then let the browser normalise the rest — no colour parser +// to maintain, and named/hsl/rgba all work. +const _cx = document.createElement('canvas').getContext('2d'); +function colour(v, theme) { + if (!v) return null; + let s = String(v).trim(); + const m = /^var\(\s*--sl-([a-z]+)/.exec(s); + if (m) s = (theme?.palette || {})[m[1]] || ''; + if (!s || s === 'transparent') return null; + _cx.fillStyle = '#000'; + _cx.fillStyle = s; // invalid input leaves #000 + const n = _cx.fillStyle; + if (String(n).startsWith('#')) return { color: n.slice(1).toUpperCase() }; + const p = String(n).match(/[\d.]+/g) || []; + if (p.length < 3) return null; + return { + color: p.slice(0, 3).map((x) => Math.round(+x).toString(16).padStart(2, '0')).join('').toUpperCase(), + transparency: p[3] != null ? Math.round((1 - parseFloat(p[3])) * 100) : undefined, + }; +} + +const family = (stack) => String(stack || '').split(',')[0].replace(/["']/g, '').trim() || undefined; + +/** Rasterise SVG markup. data: URLs only — a blob:-sourced SVG containing taints + * the canvas and toDataURL throws; the identical data: URL does not. */ +function svgToPng(svgText, w, h, scale = 2) { + return new Promise((res, rej) => { + const img = new Image(); + img.onload = () => { + const c = Object.assign(document.createElement('canvas'), + { width: Math.max(1, w * scale), height: Math.max(1, h * scale) }); + c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); + try { res(c.toDataURL('image/png')); } catch (e) { rej(e); } + }; + img.onerror = () => rej(new Error('could not rasterise')); + img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`; + }); +} + +async function toDataUri(url) { + const r = await fetch(url, { cache: 'no-store' }); + if (!r.ok) throw new Error(`image ${r.status}`); + const b = await r.blob(); + return new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(fr.result); + fr.onerror = () => rej(new Error('could not read image')); + fr.readAsDataURL(b); + }); +} + +function frameOf(el) { + const f = el.frame || {}; + return { + x: inch(f.x), y: inch(f.y), w: inch(f.w), h: inch(f.h), + ...(f.rotation ? { rotate: f.rotation } : {}), + }; +} + +/** Runs → pptxgenjs TextProps, preserving marks. */ +function textProps(el, theme) { + const c = el.content || {}; + const role = c.role || 'body'; + const base = { ...(ROLE_STYLE[role] || {}), ...(el.style || {}) }; + const col = colour(base.color, theme); + const common = { + fontSize: pt(base.fontSize || 30), + fontFace: family(base.fontFamily), + ...(col ? { color: col.color, ...(col.transparency ? { transparency: col.transparency } : {}) } : {}), + // OOXML has a boolean bold, not a weight axis — 500/600/900 all collapse. Wrong weight, + // never broken layout. Reported to the caller as a note. + bold: Number(base.fontWeight || 400) >= 600, + align: base.align || 'left', + ...(base.letterSpacing ? { charSpacing: pt(parseFloat(base.letterSpacing)) } : {}), + ...(base.lineHeight ? { lineSpacingMultiple: Number(base.lineHeight) } : {}), + }; + const upper = String(base.textTransform || '') === 'uppercase'; + const runs = (c.runs || []).filter((r) => r && typeof r.text === 'string'); + return runs.map((r, i) => { + const marks = r.marks || []; + const link = marks.find((m) => m && typeof m === 'object' && m.link); + return { + text: upper ? r.text.toUpperCase() : r.text, // OOXML has no text-transform + options: { + ...common, + ...(marks.includes('bold') ? { bold: true } : {}), + ...(marks.includes('italic') ? { italic: true } : {}), + ...(marks.includes('underline') ? { underline: { style: 'sng' } } : {}), + ...(marks.includes('code') ? { fontFace: 'Consolas' } : {}), + ...(link ? { hyperlink: { url: link.link } } : {}), + ...(role === 'bullets' ? { bullet: { type: 'bullet' }, breakLine: true } : {}), + ...(i < runs.length - 1 && role !== 'bullets' ? { breakLine: false } : {}), + }, + }; + }); +} + +async function addChart(slide, el, notes) { + // The canvas renders charts as SVG; getDataURL on an SVG-renderer instance returns SVG, which + // PowerPoint outside 365 renders as a corrupt image. Spin a second, canvas-renderer instance + // offscreen purely to get a real PNG. + const spec = (el.content || {}).spec || (el.content || {}).option; + if (!spec) return false; + const echarts = await import('echarts'); + const host = document.createElement('div'); + const f = el.frame || {}; + host.style.cssText = `position:fixed;left:-10000px;width:${f.w || 800}px;height:${f.h || 450}px`; + document.body.appendChild(host); + try { + const inst = echarts.init(host, null, { renderer: 'canvas', devicePixelRatio: 2 }); + inst.setOption(spec, true); + const data = inst.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: 'transparent' }); + inst.dispose(); + slide.addImage({ data, ...frameOf(el) }); + notes.rasterised.chart += 1; + return true; + } finally { + host.remove(); + } +} + +async function addFlowchart(slide, el, notes) { + const src = (el.content || {}).code || (el.content || {}).text; + if (!src) return false; + const mermaid = (await import('mermaid')).default; + // htmlLabels:false matters — with it on, mermaid emits , the one shape that + // rasterises unreliably. + mermaid.initialize({ startOnLoad: false, theme: 'neutral', htmlLabels: false }); + const { svg } = await mermaid.render(`x${Math.abs(hash(src))}`, src); + const f = el.frame || {}; + slide.addImage({ data: await svgToPng(svg, f.w || 800, f.h || 450, 2), ...frameOf(el) }); + notes.rasterised.flowchart += 1; + return true; +} + +function hash(s) { + let h = 0; + for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0; + return h; +} + +/** + * Build and download a .pptx for this deck. + * @returns {{notes: string[]}} honest counts of anything rasterised or dropped — never a guess. + */ +export async function downloadPptx(deck, resolveSrc, onProgress) { + const PptxGenJS = (await import('pptxgenjs')).default; + const pptx = new PptxGenJS(); + pptx.layout = 'LAYOUT_16x9'; // 13.333 × 7.5 in + pptx.title = deck?.meta?.title || 'Slides'; + + const theme = deck?.theme || {}; + const notes = { rasterised: { chart: 0, flowchart: 0, embed: 0, background: 0 }, dropped: [], weight: 0 }; + const slides = deck?.slides || []; + + for (let i = 0; i < slides.length; i += 1) { + const s = slides[i]; + onProgress?.(i + 1, slides.length); + const slide = pptx.addSlide(); + + const bg = colour(s.background?.color, theme); + if (bg) slide.background = { color: bg.color }; + if (s.notes) slide.addNotes(String(s.notes)); + + for (const el of s.elements || []) { + try { + if (el.type === 'text') { + const props = textProps(el, theme); + if (props.length) { + const base = { ...(ROLE_STYLE[el.content?.role || 'body'] || {}), ...(el.style || {}) }; + if (Number(base.fontWeight || 400) > 400 && Number(base.fontWeight) !== 700) notes.weight += 1; + slide.addText(props, { + ...frameOf(el), valign: 'top', margin: 0, inset: 0, isTextBox: true, fit: 'none', + }); + } + } else if (el.type === 'shape') { + const kind = el.content?.kind || 'rect'; + const fill = colour(el.style?.fill || 'var(--sl-brand)', theme); + if (kind === 'line' || kind === 'arrow') { + slide.addShape('line', { + ...frameOf(el), h: 0, + line: { ...(fill ? { color: fill.color } : {}), width: 2, + endArrowType: kind === 'arrow' ? 'triangle' : 'none' }, + }); + } else { + const radius = Number(el.style?.radius || 0); + slide.addShape(kind === 'ellipse' ? 'ellipse' : (radius ? 'roundRect' : 'rect'), { + ...frameOf(el), + ...(fill ? { fill: { color: fill.color, ...(fill.transparency ? { transparency: fill.transparency } : {}) } } : {}), + ...(radius ? { rectRadius: Math.min(0.5, inch(radius)) } : {}), + }); + } + } else if (el.type === 'table') { + const rows = (el.content?.rows || []).map((r) => (r || []).map((cell) => String(cell ?? ''))); + if (rows.length) { + const head = el.content?.head || rows[0]; + const body = el.content?.head ? rows : rows.slice(1); + const brand = colour('var(--sl-brand)', theme); + slide.addTable( + [head.map((h) => ({ text: String(h), options: { bold: true } })), ...body], + { ...frameOf(el), fontSize: pt(24), border: { type: 'solid', pt: 1, + color: brand ? brand.color : 'DDDDDD' }, + // autoPage defaults to TRUE and will silently invent extra slides. + autoPage: false }, + ); + } + } else if (el.type === 'image') { + const src = resolveSrc ? resolveSrc(el.content?.src) : el.content?.src; + if (src) { + slide.addImage({ + data: await toDataUri(src), ...frameOf(el), + ...(el.content?.fit === 'cover' + ? { sizing: { type: 'cover', w: inch(el.frame?.w), h: inch(el.frame?.h) } } : {}), + altText: el.content?.alt || '', + }); + } + } else if (el.type === 'chart') { + await addChart(slide, el, notes); + } else if (el.type === 'flowchart') { + await addFlowchart(slide, el, notes); + } else if (el.type === 'embed') { + const html = el.content?.html || ''; + if (/ ({ ...p, options: { ...p.options, fontFace: 'Consolas' } })), + { ...frameOf(el), valign: 'top', margin: 0, inset: 0, isTextBox: true, fit: 'none' }); + } + } + } catch (e) { + // One bad element must not lose the whole deck — but it must be reported, not swallowed. + notes.dropped.push(`${el.type} on slide ${i + 1} (${e.message || 'failed'})`); + } + } + } + + const name = String(deck?.meta?.title || 'slides').replace(/[\\/:*?"<>|]+/g, ' ').trim() || 'slides'; + await pptx.writeFile({ fileName: `${name}.pptx` }); + + // Only real counts. Nothing here is estimated. + const out = []; + const r = notes.rasterised; + const rast = r.chart + r.flowchart + r.embed + r.background; + if (rast) { + const parts = []; + if (r.chart) parts.push(`${r.chart} chart${r.chart === 1 ? '' : 's'}`); + if (r.flowchart) parts.push(`${r.flowchart} diagram${r.flowchart === 1 ? '' : 's'}`); + if (r.embed) parts.push(`${r.embed} embedded graphic${r.embed === 1 ? '' : 's'}`); + out.push(`${parts.join(', ')} exported as images — PowerPoint cannot represent them as editable objects.`); + } + if (notes.weight) { + out.push(`${notes.weight} text element${notes.weight === 1 ? '' : 's'} use a font weight PowerPoint cannot express; they are bold or regular.`); + } + if (notes.dropped.length) out.push(`Could not include: ${notes.dropped.join('; ')}.`); + return { notes: out }; +} diff --git a/kits/slides/app/src/lib/srcResolver.js b/kits/slides/app/src/lib/srcResolver.js new file mode 100644 index 0000000..919031a --- /dev/null +++ b/kits/slides/app/src/lib/srcResolver.js @@ -0,0 +1,30 @@ +// Turning a deck's image paths into fetchable URLs. +// +// A deck references images by their path in the session workspace, not by URL, so anything that +// RENDERS a deck — the editor, the print surface, the PowerPoint exporter — needs the same +// mapping. It lives here rather than inside DeckPage so the print route and the exporter cannot +// drift from what the canvas shows. +import { useCallback, useEffect, useState } from 'react'; +import { workspaceFileIndex } from './sl'; + +/** Non-hook form: for code that already has the index (the print surface, the exporter). */ +export function buildSrcResolver(index) { + return (src) => { + if (!src) return src; + if (/^(https?:|data:|blob:)/.test(src)) return src; + return index?.[String(src).replace(/^\.?\//, '')] || src; + }; +} + +/** Hook form: resolves against the session's live file list. Until it arrives, paths are left + * untouched rather than pointed at a guessed URL that would render as a broken image. */ +export function useSrcResolver(id) { + const [index, setIndex] = useState(null); + useEffect(() => { + if (!id || String(id).startsWith('new:')) { setIndex(null); return undefined; } + let dead = false; + workspaceFileIndex(id).then((m) => { if (!dead) setIndex(m); }).catch(() => {}); + return () => { dead = true; }; + }, [id]); + return useCallback((src) => buildSrcResolver(index)(src), [index]); +} diff --git a/kits/slides/app/src/main.jsx b/kits/slides/app/src/main.jsx index ce56fa0..9ddb313 100644 --- a/kits/slides/app/src/main.jsx +++ b/kits/slides/app/src/main.jsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App.jsx'; +import 'highlight.js/styles/atom-one-dark.css'; import 'reifyui/styles/slides.css'; import 'reifyui/styles/chat.css'; import './styles/tokens.css'; diff --git a/kits/slides/app/src/pages/DeckPage.jsx b/kits/slides/app/src/pages/DeckPage.jsx index 8ca1b8b..0fbcc0a 100644 --- a/kits/slides/app/src/pages/DeckPage.jsx +++ b/kits/slides/app/src/pages/DeckPage.jsx @@ -10,46 +10,45 @@ import { HelpCircle, Home, Maximize2, Download } from 'lucide-react'; import { SlideView, EditorCanvas } from 'reifyui/slides'; import { Presentation } from 'reifyui/slides'; import { PaneResizer, useResizablePane } from 'reifyui'; -import { chatHistory, deckStatus, getDeck, getTemplateDetail, saveDeck, markViewed, - workspaceFileIndex } from '../lib/sl'; +import { chatHistory, deckStatus, getDeck, getTemplateDetail, saveDeck, markViewed } from '../lib/sl'; import { authFetch, getSession } from '../lib/auth'; import { useDeckCollab } from '../lib/collab'; import { ChatColumn } from '../components/ChatPanel'; import { AvatarMenu, LINKS, Wordmark } from '../components/Topbar'; +import { AlertDialog } from '../components/Dialog'; import { SkeletonDeck } from '../components/Skeleton'; +import { useSrcResolver } from '../lib/srcResolver'; -// Images the agent made live in the session workspace, so a deck's `src` is a path there rather -// than a URL. Resolve it against the session's file list; until that arrives, leave the path -// untouched so nothing renders a broken image with a guessed URL. -function useSrcResolver(id) { - const [index, setIndex] = useState(null); - useEffect(() => { - if (!id || String(id).startsWith('new:')) { setIndex(null); return undefined; } - let dead = false; - workspaceFileIndex(id).then((m) => { if (!dead) setIndex(m); }).catch(() => {}); - return () => { dead = true; }; - }, [id]); - return useCallback((src) => { - if (!src) return src; - if (/^(https?:|data:|blob:)/.test(src)) return src; - return index?.[src.replace(/^\.?\//, '')] || src; - }, [index]); -} - -async function downloadExport(id, title, kind, setBusy) { +// Export dispatch. Both formats are produced in the browser: there is no export service in this +// deployment, and the hosted product's endpoint this used to call went away with SL_API — which +// is why the button threw ReferenceError on its first line and the catch swallowed it into a +// silent no-op. +async function runExport(kind, { id, deck, resolveSrc, setBusy, setErr, setNote }) { setBusy(kind); + setErr(''); try { - const res = await authFetch(`${SL_API}/v1/sl/decks/${encodeURIComponent(id)}/export.${kind}`); - if (!res.ok) throw new Error(`export failed (${res.status})`); - const blob = await res.blob(); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `${(title || 'slides').replace(/[\\/:*?"<>|]+/g, ' ').trim()}.${kind}`; - a.click(); - URL.revokeObjectURL(a.href); + if (kind === 'pdf') { + const { openPrintView } = await import('../lib/exportPdf'); + openPrintView(id, deck); // opens a tab that prints itself once the deck has settled + } else { + const { downloadPptx } = await import('../lib/exportPptx'); + const report = await downloadPptx(deck, resolveSrc, + (n, total) => setBusy(`pptx:${n}/${total}`)); + if (report.notes.length) setNote(report.notes); + } } catch (e) { - console.error(e); - } finally { setBusy(''); } + setErr(e?.message || `Could not export ${kind === 'pdf' ? 'PDF' : 'PowerPoint'}.`); + } finally { + setBusy(''); + } +} + +// Honest states only: PDF opens a tab (instant), PPTX reports the slide it is actually on. +function exportLabel(busy) { + if (!busy) return 'Export'; + if (busy === 'pdf') return 'Opening print view…'; + const m = /^pptx:(\d+)\/(\d+)$/.exec(String(busy)); + return m ? `Building PowerPoint — slide ${m[1]} of ${m[2]}` : 'Building PowerPoint…'; } export function DeckPage({ id: routeId, seed, template }) { @@ -64,7 +63,9 @@ export function DeckPage({ id: routeId, seed, template }) { const [sel, setSel] = useState(0); const [selEl, setSelEl] = useState(null); const [presenting, setPresenting] = useState(false); - const [exporting, setExporting] = useState(false); + const [exporting, setExporting] = useState(''); + const [exportErr, setExportErr] = useState(''); + const [exportNote, setExportNote] = useState(null); const [saveState, setSaveState] = useState(''); // '' | 'saving' | 'saved' | 'error' const [copilotBusy, setCopilotBusy] = useState(false); const chatPane = useResizablePane({ initial: 380, min: 300, maxFraction: 0.6, fromRight: true, storageKey: 'slides.chat.w' }); @@ -256,7 +257,7 @@ export function DeckPage({ id: routeId, seed, template }) { if (err) { return (
- {}} onExport={() => {}} peers={[]} /> +
{err}
); @@ -270,7 +271,10 @@ export function DeckPage({ id: routeId, seed, template }) {
slides.length && setPresenting(true)} - onExport={(kind) => deck && !exporting && downloadExport(id, deck.meta?.title, kind, setExporting)} + onExport={(kind) => deck && !exporting && runExport(kind, { + id, deck, resolveSrc, setBusy: setExporting, + setErr: setExportErr, setNote: setExportNote, + })} exporting={exporting} peers={collab.peers} live={collab.live} saveState={saveState} copilotBusy={copilotBusy} />
@@ -335,6 +339,12 @@ export function DeckPage({ id: routeId, seed, template }) { width={chatPane.width} /> + {exportErr && ( + setExportErr('')} /> + )} + {exportNote && ( + setExportNote(null)} /> + )} {presenting && ( setPresenting(false)} /> )} @@ -355,22 +365,26 @@ function DeckBanner({ deck, onPresent, onExport, exporting, peers = [], live, sa
- + {onPresent && ( + + )} + {onExport && (
{menu && !exporting && (
e.stopPropagation()}> - +
)}
+ )} {saveState && ( {saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : 'Save failed — retrying on next edit'} diff --git a/kits/slides/app/src/pages/PrintPage.jsx b/kits/slides/app/src/pages/PrintPage.jsx index 6b39219..57db3f7 100644 --- a/kits/slides/app/src/pages/PrintPage.jsx +++ b/kits/slides/app/src/pages/PrintPage.jsx @@ -1,24 +1,146 @@ -// Print surface — every slide as a full 1920×1080 stage, stacked, no chrome. -// The export endpoint injects the deck as window.__DECK__ and prints this -// page headlessly; sl-print-ready on signals charts/diagrams settled. -import { useEffect, useState } from 'react'; +// The print surface: every slide at true 1920×1080, one per page, no chrome. +// +// Printed by the browser, so the PDF comes out of the same components the canvas uses — text +// stays selectable, charts and diagrams stay vector. The only real engineering here is knowing +// WHEN to print: charts and diagrams mount lazily, and a fixed timer either prints empty boxes or +// makes everyone wait for the slowest imaginable deck. +import { useEffect, useMemo, useRef, useState } from 'react'; import { SlideStage } from 'reifyui/slides'; +import { getDeck, workspaceFileIndex } from '../lib/sl'; +import { buildSrcResolver } from '../lib/srcResolver'; + +/** How many SVG roots and highlighted code blocks this deck MUST produce before printing is safe. + * + * Derived from the deck itself, so "nothing to wait for" and "hasn't started yet" stop looking + * identical — which is exactly why a fixed timer prints empty chart boxes. */ +function expectations(deck) { + const els = (deck?.slides || []).flatMap((s) => s.elements || []); + return { + svg: els.filter((e) => e.type === 'chart' || e.type === 'flowchart' + || (e.type === 'shape' && ['line', 'arrow'].includes(e.content?.kind)) + || (e.type === 'embed' && / e.type === 'code').length, + }; +} + +async function settle(root, want, onProgress, ceiling = 20000) { + const deadline = Date.now() + ceiling; + try { await document.fonts?.ready; } catch { /* no font faces */ } + await Promise.all([...root.querySelectorAll('img')].map((im) => (im.complete ? null + : new Promise((r) => { + im.addEventListener('load', r, { once: true }); + im.addEventListener('error', r, { once: true }); + })))); + + let last = -1; + let stable = Date.now(); + for (;;) { + const svg = root.querySelectorAll('.sl-stage svg').length; + const code = root.querySelectorAll('.sl-stage code.hljs').length; + onProgress({ svg, code }); + const n = svg + code; + if (n !== last) { last = n; stable = Date.now(); } + // The count AND ~600ms of quiet: echarts mounts its before it has finished drawing into + // it, so "the element exists" is not "the element is done". + else if (svg >= want.svg && code >= want.code && Date.now() - stable > 600) return true; + if (Date.now() > deadline) return false; + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 100)); + } +} + +export function PrintPage({ handoff, id }) { + const [deck, setDeck] = useState(null); + const [resolveSrc, setResolveSrc] = useState(() => (s) => s); + const [state, setState] = useState('loading'); // loading | waiting | ready | incomplete | error + const [seen, setSeen] = useState({ svg: 0, code: 0 }); + const [msg, setMsg] = useState(''); + const rootRef = useRef(null); + const want = useMemo(() => expectations(deck), [deck]); -export function PrintPage() { - const deck = window.__DECK__; - const [ready, setReady] = useState(false); useEffect(() => { - const t = setTimeout(() => { setReady(true); document.body.classList.add('sl-print-ready'); }, 2500); - return () => clearTimeout(t); - }, []); - if (!deck) return
No deck loaded.
; + let dead = false; + (async () => { + // window.__DECK__ keeps this route drivable by a headless renderer, should one ever exist. + let d = window.__DECK__ || null; + let sid = id; + if (!d && handoff) { + const raw = window.localStorage.getItem(handoff); + window.localStorage.removeItem(handoff); + if (raw) { const p = JSON.parse(raw); d = p.deck; sid = p.id || sid; } + } + if (!d && sid) d = (await getDeck(sid)).deck; + if (dead) return; + if (!d?.slides?.length) { setState('error'); setMsg('There is no deck to print.'); return; } + if (sid && !String(sid).startsWith('new:')) { + const idx = await workspaceFileIndex(sid).catch(() => null); + if (!dead && idx) setResolveSrc(() => buildSrcResolver(idx)); + } + if (!dead) { setDeck(d); setState('waiting'); } + })().catch((e) => { + if (!dead) { setState('error'); setMsg(e.message || 'Could not load this deck.'); } + }); + return () => { dead = true; }; + }, [handoff, id]); + + useEffect(() => { + if (state !== 'waiting' || !rootRef.current) return undefined; + let dead = false; + settle(rootRef.current, want, (s) => { if (!dead) setSeen(s); }).then((ok) => { + if (dead) return; + if (ok) { setState('ready'); window.print(); } else setState('incomplete'); + // Not settled means DO NOT print. Quietly printing empty chart boxes is the failure this + // detector exists to prevent. + }); + return () => { dead = true; }; + }, [state, want]); + + // Fit the 1920px page to the window on screen; inert in print. + useEffect(() => { + const fit = () => { + const el = rootRef.current; + if (el) el.style.setProperty('--sl-print-scale', String(Math.min(1, (window.innerWidth - 48) / 1920))); + }; + fit(); + window.addEventListener('resize', fit); + return () => window.removeEventListener('resize', fit); + }, [deck]); + + const total = want.svg + want.code; + const pending = Math.max(0, total - (seen.svg + seen.code)); + return ( -
- {(deck.slides || []).map((s) => ( -
- + <> +
+ {state === 'loading' && Loading deck…} + {state === 'waiting' && ( + {pending ? `Rendering — ${pending} of ${total} charts and diagrams left` : 'Preparing…'} + )} + {state === 'ready' && ( + <> + Print dialog open — choose “Save as PDF” as the destination. + Keep Margins on Default; changing it rescales the slides to paper size. + + + )} + {state === 'incomplete' && ( + <> + {pending} chart{pending === 1 ? '' : 's'} or diagram{pending === 1 ? '' : 's'} didn’t + finish rendering, so this was not sent to the printer. + + + )} + {state === 'error' && {msg}} +
+ {deck && ( +
+ {deck.slides.map((s, i) => ( +
+ +
+ ))}
- ))} -
+ )} + ); } diff --git a/kits/slides/app/src/styles/app.css b/kits/slides/app/src/styles/app.css index e0060cd..30a9a4d 100644 --- a/kits/slides/app/src/styles/app.css +++ b/kits/slides/app/src/styles/app.css @@ -855,3 +855,52 @@ } .sk-deck-note { margin: 0; font-size: 13px; color: var(--mute); } @media (max-width: 820px) { .sk-deck-rail { display: none; } } + + +/* ── print surface ──────────────────────────────────────────────────────────── + @page fixes the geometry: without it Chrome prints Letter, scales to ~0.67 and + clips the right third of every slide. print-color-adjust overrides the print + dialog's "Background graphics" checkbox, which is OFF by default — without it + every slide background disappears and a dark deck prints as white on white. */ +@page { size: 1920px 1080px; margin: 0; } + +@media screen { + .sl-print-root { + background: #0b0e14; padding: 24px 0; + display: flex; flex-direction: column; align-items: center; gap: 24px; + } + .sl-print-page { + width: 1920px; height: 1080px; position: relative; overflow: hidden; flex: 0 0 auto; + transform: scale(var(--sl-print-scale, 1)); transform-origin: top center; + box-shadow: 0 8px 30px rgba(0, 0, 0, .45); + } + .sl-print-bar { + position: sticky; top: 0; z-index: 10; display: flex; gap: 12px; align-items: center; + padding: 10px 16px; background: #11151f; color: #e7ebf3; + font: 500 13px/1.4 system-ui, -apple-system, sans-serif; + } + .sl-print-bar .btn { background: #232a3a; color: #e7ebf3; border-color: #2f3850; } +} + +@media print { + html, body { margin: 0; padding: 0; background: #fff; } + .sl-print-bar { display: none !important; } + .sl-print-root { display: block; padding: 0; background: none; } + .sl-print-page { + width: 1920px; height: 1080px; position: relative; overflow: hidden; + transform: none !important; box-shadow: none !important; + break-after: page; page-break-after: always; + break-inside: avoid; page-break-inside: avoid; + } + .sl-print-page:last-child { break-after: auto; page-break-after: auto; } + .sl-print-root, .sl-print-root * { + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + } +} + +.dlg-detail { + margin: 10px 0 0; padding: 10px; max-height: 180px; overflow: auto; + background: var(--surface-2); border-radius: 8px; + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; +}