diff --git a/.github/workflows/publish-decks.yml b/.github/workflows/publish-decks.yml index 916157b..c9d7ed1 100644 --- a/.github/workflows/publish-decks.yml +++ b/.github/workflows/publish-decks.yml @@ -12,6 +12,11 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + export_pdfs: + description: Also export a PDF per deck (dormant by default) + type: boolean + default: false permissions: contents: read @@ -46,13 +51,17 @@ jobs: # Serving dist/ (not the source) means the PDFs are rendered from the exact # bytes we ship, so a broken vendored asset fails the build here. + # PDF export is dormant: it only runs from a manual "Run workflow" with + # export_pdfs ticked. Push and pull_request runs skip these three steps. - name: Serve built site + if: inputs.export_pdfs working-directory: dist run: | npx --yes http-server . -p "$DECK_PORT" --silent & npx --yes wait-on -t 60000 "http://127.0.0.1:$DECK_PORT/index.html" - name: Export a PDF per deck + if: inputs.export_pdfs working-directory: dist run: | slugs=$(node -e "console.log(require('./decks.json').decks.map(d => d.slug).join('\n'))") @@ -78,6 +87,7 @@ jobs: if-no-files-found: error - name: Upload PDFs + if: inputs.export_pdfs uses: actions/upload-artifact@v4 with: name: deck-pdfs diff --git a/README.md b/README.md index 19cc96a..93d0fcd 100644 --- a/README.md +++ b/README.md @@ -38,24 +38,33 @@ built `standalone/` files are exempt, since everything is inlined into them. decks-as-code/ ├── index.html landing page, lists every deck ├── deck.html the player: deck.html?d= +├── convert.html .pptx to localizable HTML, in the browser ├── decks.json generated listing, don't hand-edit -├── assets/theme.css shared brand styling for all decks +├── assets/ theme.css (brand), pptx-slide.css (converted slides), +│ deck-i18n.js (language swap, fit-to-box) +├── docs/string-table.md the localization contract ├── tools/ │ ├── build-manifest.mjs regenerates decks.json │ ├── build-site.mjs builds dist/: offline site plus single-file decks -│ └── import-pptx.mjs converts a .pptx into a deck folder +│ ├── import-pptx.mjs converts a .pptx into a Markdown deck folder +│ ├── pptx-to-html.mjs converts a .pptx into an HTML deck folder (pptx-html.mjs) +│ ├── translate.mjs translates a deck's strings.json with an LLM +│ ├── translate-core.mjs hashing, validation, batching, retry (used by translate) +│ └── llm-provider.mjs provider interface: copilot, openai-compatible, mock └── decks/ └── / ├── slides.md the deck. The only file you edit day to day. ├── deck.json title, description, author, date, tags, theme └── images/ optional, referenced as ![](images/x.png) + (decks converted to HTML have slides.html and strings.json instead of slides.md) ``` | URL | Serves | | --- | --- | | `/` | Landing page listing all decks | | `/deck.html?d=` | That deck, full screen | -| `/decks//deck.pdf` | PDF export, once built | +| `/deck.html?d=&lang=de` | An HTML deck in a translation (needs `strings.de.json`) | +| `/decks//deck.pdf` | PDF export, once built (off by default) | --- @@ -142,6 +151,85 @@ Options: --- +## Convert a PowerPoint to localizable HTML + +A second path, for when the slides must look like the original and every word has to +be translatable. Instead of Markdown, a `.pptx` becomes positioned HTML: one fixed +canvas per slide, shapes placed where PowerPoint put them, and all text kept as real +text. + +Open `convert.html` on the served site (or `node tools/pptx-to-html.mjs talk.pptx +--slug my-talk`). It reads the file in your browser, never uploads it, and lets you: + +- **Preview** the slides, with speaker notes. +- **Look through** the generated `slides.html` and the string table, and see what the + converter could not handle. +- **Download** a single HTML file you can double-click, or the deck folder as a ZIP. + +The folder holds `slides.html`, `strings.json` and `images/`. `strings.json` lists +every paragraph, speaker note and alt text under a stable ID, in the format described +in [`docs/string-table.md`](docs/string-table.md); translations are `strings..json` +files in the same folder. A language picker appears in the corner of the deck (and the +single-file version) when translations exist; `?lang=` opens one directly. +Longer translated text shrinks to fit its box (down to 60%); add `&review` to the +address to outline boxes that still overflow. + +What is kept: every paragraph (including hidden-slide text and notes), bold, italic, +underline, links, bullets and numbering, fonts and colors, positions, rotation, groups, +pictures with cropping, tables, gradients, and the common shapes and connectors with +arrowheads. SmartArt is rendered from its stored drawing when the file has one. + +What is not: text inside pictures (it cannot be translated), charts (shown as a +placeholder), EMF/WMF pictures (browsers cannot draw them), animations, transitions, +and shadows or other effects. Shapes with an unusual preset are drawn as rectangles. +Everything the converter skipped or approximated is listed under "Needs a look". + +Code paragraphs (monospace text) stay on the slide but are left out of the string +table, so nothing translates them. + +--- + +## Translate a deck + +`tools/translate.mjs` translates a deck's string table (`decks//strings.json`, +see [`docs/string-table.md`](docs/string-table.md)) into `strings..json` files. +Node 18+, no dependencies. + +```powershell +node tools\translate.mjs my-talk --to de,pt-BR,ar # translate +node tools\translate.mjs my-talk --to de --dry-run # show what would change +node tools\translate.mjs my-talk --to de --force # retranslate everything +node tools\translate.mjs my-talk --to de --provider mock # offline fake, no LLM +node --test # run the tests (no network) +``` + +- **Incremental.** Only strings that are missing or whose source text changed (the + `src` hash no longer matches) are sent to the model. `--force` redoes all of them. +- **Validated.** Every translation must keep the same inline tags (in matching pairs), + keep `href` values, and be non-empty. A failing batch is retried once with the error + fed back; strings that still fail are skipped and listed, the file stays valid, and + the viewer falls back to the source text for them. Exit code `2` means "done, but + some strings were skipped"; re-run to retry them. +- **Provider.** `--provider copilot` (default) runs the GitHub Copilot CLI in + non-interactive mode, so it uses the models in your Copilot subscription: no API key, + nothing to configure beyond signing in once by running `copilot`. Tools, MCP servers and + custom instructions are switched off, so the model can only answer. Optional: + `--model ` (otherwise the CLI's default), `LLM_REASONING_EFFORT` (default `low`), + `COPILOT_BIN` if `copilot` is not on your PATH. The five-slide sample takes under a + minute per language; a 50-slide deck takes a few minutes. + `--provider openai` talks to any OpenAI-compatible + `/chat/completions` endpoint. Configure it with environment variables (the key is + never a flag, never logged, never written): `LLM_API_KEY` (or `OPENAI_API_KEY`), + `LLM_MODEL` (or `--model`), and optionally `LLM_BASE_URL` (or `--base-url`; default + `https://api.openai.com/v1`). For Azure OpenAI / Foundry use the + `https://.openai.azure.com/openai/v1` base URL, the deployment name as the + model, and `LLM_AUTH_HEADER=api-key`. Set `LLM_JSON_MODE=off` if a host rejects + `response_format`. 429s honour `retry-after`. Providers live in + `tools/llm-provider.mjs`; adding one means returning `{ name, complete({messages}) }`. + GitHub Models was retired on 2026-07-30, so it is not supported. + +--- + ## Writing slides `slides.md` is ordinary Markdown with three separator conventions: @@ -181,8 +269,12 @@ from your file system and they now say so rather than failing silently. To run t whole site locally, serve `dist/` on a port as shown under "Run it locally". `.github/workflows/publish-decks.yml` runs on every push and pull request. It builds -`dist/`, exports a PDF per deck, and uploads two artifacts: `deck-standalone` and -`deck-pdfs`. On `main` it also deploys `dist/` to GitHub Pages. +`dist/` and uploads the `deck-standalone` artifact. On `main` it also deploys `dist/` +to GitHub Pages. + +**PDF export is dormant.** The workflow no longer exports PDFs on push or pull +request. The steps are still there: run the workflow manually (Actions → Publish +decks → Run workflow) and tick `export_pdfs` to get a `deck-pdfs` artifact. **GitHub Pages.** This repo is public, so Pages is enabled and every push to `main` republishes . Settings → Pages → Source is set diff --git a/assets/deck-i18n.js b/assets/deck-i18n.js new file mode 100644 index 0000000..574ae88 --- /dev/null +++ b/assets/deck-i18n.js @@ -0,0 +1,175 @@ +/* + * Swaps localized text into a deck converted from PowerPoint. + * + * Every localizable node carries data-i18n="" (or data-i18n-alt for image + * alt text). A translation is a table { strings: { "": { text } } }. Anything + * missing keeps its source text, so a deck never renders blank. + * See docs/string-table.md. + * + * A classic script, shared by deck.html and the standalone single-file build. + */ +(function () { + var RTL = { ar: 1, he: 1, fa: 1, ur: 1 }; + var ALLOWED = { B: 1, I: 1, U: 1, SUP: 1, SUB: 1, CODE: 1, BR: 1, A: 1 }; + + /** Rebuild a translation from the contract's inline tags only; drop everything else. */ + function safeFragment(html) { + var tpl = document.createElement('template'); + tpl.innerHTML = html; + var frag = document.createDocumentFragment(); + (function clean(src, dest) { + for (var i = 0; i < src.childNodes.length; i++) { + var n = src.childNodes[i]; + if (n.nodeType === 3) dest.appendChild(document.createTextNode(n.textContent)); + else if (n.nodeType === 1 && ALLOWED[n.tagName]) { + var el = document.createElement(n.tagName); + if (n.tagName === 'A') { + var href = n.getAttribute('href') || ''; + if (/^(https?:|mailto:)/i.test(href)) el.setAttribute('href', href); + } + clean(n, el); + dest.appendChild(el); + } else if (n.nodeType === 1) clean(n, dest); + } + })(tpl.content, frag); + return frag; + } + + function plainText(html) { + var tpl = document.createElement('template'); + tpl.innerHTML = html; + return tpl.content.textContent; + } + + /** Apply a translation table. `source` restores the original text first. */ + function apply(table, lang) { + document.querySelectorAll('[data-i18n]').forEach(function (el) { + if (el.dataset.i18nSrc === undefined) el.dataset.i18nSrc = el.innerHTML; + var hit = table && table.strings && table.strings[el.dataset.i18n]; + if (hit && hit.text) el.replaceChildren(safeFragment(hit.text)); + else el.innerHTML = el.dataset.i18nSrc; + }); + document.querySelectorAll('[data-i18n-alt]').forEach(function (el) { + if (el.dataset.i18nSrc === undefined) el.dataset.i18nSrc = el.alt; + var hit = table && table.strings && table.strings[el.dataset.i18nAlt]; + el.alt = hit && hit.text ? plainText(hit.text) : el.dataset.i18nSrc; + }); + if (lang) { + document.documentElement.lang = lang; + document.documentElement.dir = RTL[lang.split('-')[0]] ? 'rtl' : 'ltr'; + } + } + + function langFromUrl() { + var lang = new URLSearchParams(window.location.search).get('lang'); + return lang && /^[a-z]{2,3}(-[A-Za-z0-9]+)*$/.test(lang) ? lang : null; + } + + var FIT_FLOOR = 0.6; + var FIT_STEP = 0.05; + + /** + * Longer text (a translation, or a fallback font that runs wide) must not spill + * out of its box and over its neighbours. Paragraph sizes are written as + * calc(var(--fit, 1) * Npx), so shrinking a box's text is one custom property. + * Boxes that still overflow at the floor are flagged with data-overflow. + * Only visible slides can be measured, so this runs per slide on demand. + */ + function fitSlide(slide) { + if (!slide) return; + slide.querySelectorAll('.sh').forEach(function (sh) { + var tx = sh.querySelector(':scope > .tx'); + if (!tx) return; + var limit = parseFloat(sh.style.minHeight); + if (!limit) return; + tx.style.removeProperty('--fit'); + sh.removeAttribute('data-overflow'); + var scale = 1; + while (tx.offsetHeight > limit + 1 && scale > FIT_FLOOR) { + scale = Math.round((scale - FIT_STEP) * 100) / 100; + tx.style.setProperty('--fit', String(scale)); + } + if (tx.offsetHeight > limit + 1) sh.setAttribute('data-overflow', 'true'); + }); + slide.dataset.fitted = '1'; + } + + /** Fit the current slide now and on every navigation; call once after Reveal starts. */ + function autoFit(reveal) { + // ?review outlines boxes whose text still overflows after shrinking. + if (new URLSearchParams(window.location.search).has('review')) document.documentElement.classList.add('deck-review'); + var run = function () { fitSlide(reveal.getCurrentSlide()); }; + reveal.on('ready', run); + reveal.on('slidechanged', run); + reveal.on('resize', function () { + var slide = reveal.getCurrentSlide(); + if (slide) delete slide.dataset.fitted; + }); + if (reveal.isReady && reveal.isReady()) run(); + } + + /** Forget measurements, e.g. after the language changed. */ + function refit(reveal) { + document.querySelectorAll('[data-fitted]').forEach(function (s) { delete s.dataset.fitted; }); + if (reveal) fitSlide(reveal.getCurrentSlide()); + } + + function languageName(code) { + try { + var label = new Intl.DisplayNames([code], { type: 'language' }).of(code); + return label ? label.charAt(0).toUpperCase() + label.slice(1) : code; + } catch (e) { + return code; + } + } + + /** + * A language + + +
+ + + + + + + + + + + + + + + + + + + diff --git a/deck.html b/deck.html index b9a1895..a04b6e6 100644 --- a/deck.html +++ b/deck.html @@ -131,6 +131,11 @@

To run the whole site

document.title = meta.title || slug; await loadStyles(meta.theme || 'night'); + if (meta.format === 'html') { + await bootHtmlDeck(base, meta, rebase); + return; + } + // Confirm slides.md exists before handing off to the Markdown plugin, // which fails silently on a 404 and leaves a blank deck. const slidesUrl = `${base}/slides.md`; @@ -215,6 +220,91 @@

To run the whole site

window.DECK_READY = true; } + /* + * Decks imported from PowerPoint ship ready-made HTML (slides.html): fixed + * canvases with shapes already placed, plus strings.json for localization. + * Reveal only handles navigation and scaling for them. + */ + async function bootHtmlDeck(base, meta, rebase) { + const slidesUrl = `${base}/slides.html`; + let html = null; + try { + const res = await fetch(slidesUrl, { cache: 'no-cache' }); + if (res.ok) html = await res.text(); + } catch { + /* handled below */ + } + await loadScripts(false); + if (html === null) { + fail('Deck not found', `No slides at ${slidesUrl}`); + return; + } + + await Promise.all([loadCss('assets/pptx-slide.css'), loadScript('assets/deck-i18n.js')]); + document.documentElement.lang = meta.lang || 'en'; + const canvas = meta.canvas || { width: 1280, height: 720 }; + document.querySelector('.slides').innerHTML = html.replace( + /(\bsrc="|url\()(?!data:|https?:|\/)([^")]+)/g, + (m, head, ref) => head + rebase(ref), + ); + + await applyLanguage(base, meta); + + await Reveal.initialize({ + plugins: [RevealNotes], + hash: true, + slideNumber: 'c/t', + transition: 'none', + width: canvas.width, + height: canvas.height, + margin: 0, + minScale: 0.2, + maxScale: 3, + center: false, + pdfSeparateFragments: false, + }); + DeckI18n.autoFit(Reveal); + await mountLanguagePicker(base, meta); + window.DECK_READY = true; + } + + /** Offer a language switch when decks.json lists translations for this deck. */ + async function mountLanguagePicker(base, meta) { + let languages = []; + try { + const res = await fetch('decks.json', { cache: 'no-cache' }); + const slug = new URLSearchParams(window.location.search).get('d'); + if (res.ok) languages = ((await res.json()).decks || []).find((d) => d.slug === slug)?.languages || []; + } catch { + /* no picker without a listing */ + } + DeckI18n.mountPicker({ + source: meta.lang || 'en', + languages, + reveal: Reveal, + load: async (tag) => { + const res = await fetch(`${base}/strings.${encodeURIComponent(tag)}.json`, { cache: 'no-cache' }); + if (!res.ok) throw new Error(`No translation for ${tag}`); + return res.json(); + }, + }); + } + + /* + * ?lang=de swaps every data-i18n node for its entry in strings.de.json + * (see assets/deck-i18n.js and docs/string-table.md). + */ + async function applyLanguage(base, meta) { + const lang = DeckI18n.langFromUrl(); + if (!lang || lang === meta.lang) return; + try { + const res = await fetch(`${base}/strings.${encodeURIComponent(lang)}.json`, { cache: 'no-cache' }); + if (res.ok) DeckI18n.apply(await res.json(), lang); + } catch { + /* keep the source language */ + } + } + /* * Slides converted from PowerPoint routinely hold more than fits a 720 * unit canvas, and Reveal does not shrink content to fit. Step the slide's diff --git a/decks.json b/decks.json index 275fa9d..e887313 100644 --- a/decks.json +++ b/decks.json @@ -1,7 +1,22 @@ { "generatedBy": "tools/build-manifest.mjs", - "count": 1, + "count": 2, "decks": [ + { + "slug": "sample-ai-apps", + "title": "Develop generative AI apps in Azure (5-slide sample)", + "description": "First five slides of a converted PowerPoint course deck, to show the PPTX-to-HTML converter.", + "author": "", + "date": "2026-11-04", + "tags": [], + "theme": "white", + "format": "html", + "languages": [ + "de", + "es", + "fr" + ] + }, { "slug": "decks-as-code", "title": "Decks as Code", diff --git a/decks/sample-ai-apps/deck.json b/decks/sample-ai-apps/deck.json new file mode 100644 index 0000000..12450f9 --- /dev/null +++ b/decks/sample-ai-apps/deck.json @@ -0,0 +1,14 @@ +{ + "title": "Develop generative AI apps in Azure (5-slide sample)", + "description": "First five slides of a converted PowerPoint course deck, to show the PPTX-to-HTML converter.", + "author": "", + "date": "2026-11-04", + "tags": [], + "theme": "white", + "format": "html", + "lang": "en-US", + "canvas": { + "width": 1280, + "height": 720 + } +} diff --git a/decks/sample-ai-apps/images/image12.png b/decks/sample-ai-apps/images/image12.png new file mode 100644 index 0000000..e63b067 Binary files /dev/null and b/decks/sample-ai-apps/images/image12.png differ diff --git a/decks/sample-ai-apps/images/image25.svg b/decks/sample-ai-apps/images/image25.svg new file mode 100644 index 0000000..49640b7 --- /dev/null +++ b/decks/sample-ai-apps/images/image25.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image26.svg b/decks/sample-ai-apps/images/image26.svg new file mode 100644 index 0000000..57e423d --- /dev/null +++ b/decks/sample-ai-apps/images/image26.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image27.svg b/decks/sample-ai-apps/images/image27.svg new file mode 100644 index 0000000..3c78b1b --- /dev/null +++ b/decks/sample-ai-apps/images/image27.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image28.svg b/decks/sample-ai-apps/images/image28.svg new file mode 100644 index 0000000..08184a2 --- /dev/null +++ b/decks/sample-ai-apps/images/image28.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image29.svg b/decks/sample-ai-apps/images/image29.svg new file mode 100644 index 0000000..352f132 --- /dev/null +++ b/decks/sample-ai-apps/images/image29.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image3.png b/decks/sample-ai-apps/images/image3.png new file mode 100644 index 0000000..d88c72b Binary files /dev/null and b/decks/sample-ai-apps/images/image3.png differ diff --git a/decks/sample-ai-apps/images/image30.svg b/decks/sample-ai-apps/images/image30.svg new file mode 100644 index 0000000..fc74530 --- /dev/null +++ b/decks/sample-ai-apps/images/image30.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image31.svg b/decks/sample-ai-apps/images/image31.svg new file mode 100644 index 0000000..3d60682 --- /dev/null +++ b/decks/sample-ai-apps/images/image31.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image32.svg b/decks/sample-ai-apps/images/image32.svg new file mode 100644 index 0000000..0f480c4 --- /dev/null +++ b/decks/sample-ai-apps/images/image32.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image33.png b/decks/sample-ai-apps/images/image33.png new file mode 100644 index 0000000..61ed3ae Binary files /dev/null and b/decks/sample-ai-apps/images/image33.png differ diff --git a/decks/sample-ai-apps/images/image33.svg b/decks/sample-ai-apps/images/image33.svg new file mode 100644 index 0000000..99b2c61 --- /dev/null +++ b/decks/sample-ai-apps/images/image33.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image34.png b/decks/sample-ai-apps/images/image34.png new file mode 100644 index 0000000..e4f853e Binary files /dev/null and b/decks/sample-ai-apps/images/image34.png differ diff --git a/decks/sample-ai-apps/images/image35.png b/decks/sample-ai-apps/images/image35.png new file mode 100644 index 0000000..3ce137c Binary files /dev/null and b/decks/sample-ai-apps/images/image35.png differ diff --git a/decks/sample-ai-apps/images/image36.png b/decks/sample-ai-apps/images/image36.png new file mode 100644 index 0000000..7fea8bc Binary files /dev/null and b/decks/sample-ai-apps/images/image36.png differ diff --git a/decks/sample-ai-apps/images/image37.png b/decks/sample-ai-apps/images/image37.png new file mode 100644 index 0000000..a919384 Binary files /dev/null and b/decks/sample-ai-apps/images/image37.png differ diff --git a/decks/sample-ai-apps/images/image38.svg b/decks/sample-ai-apps/images/image38.svg new file mode 100644 index 0000000..eea2477 --- /dev/null +++ b/decks/sample-ai-apps/images/image38.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image39.svg b/decks/sample-ai-apps/images/image39.svg new file mode 100644 index 0000000..6c1fdd5 --- /dev/null +++ b/decks/sample-ai-apps/images/image39.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image40.svg b/decks/sample-ai-apps/images/image40.svg new file mode 100644 index 0000000..4c266c7 --- /dev/null +++ b/decks/sample-ai-apps/images/image40.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image41.png b/decks/sample-ai-apps/images/image41.png new file mode 100644 index 0000000..e48d7ca Binary files /dev/null and b/decks/sample-ai-apps/images/image41.png differ diff --git a/decks/sample-ai-apps/images/image42.svg b/decks/sample-ai-apps/images/image42.svg new file mode 100644 index 0000000..07a0fca --- /dev/null +++ b/decks/sample-ai-apps/images/image42.svg @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image43.svg b/decks/sample-ai-apps/images/image43.svg new file mode 100644 index 0000000..bf42a2b --- /dev/null +++ b/decks/sample-ai-apps/images/image43.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image44.svg b/decks/sample-ai-apps/images/image44.svg new file mode 100644 index 0000000..5ec9fe2 --- /dev/null +++ b/decks/sample-ai-apps/images/image44.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image45.svg b/decks/sample-ai-apps/images/image45.svg new file mode 100644 index 0000000..df1a5ee --- /dev/null +++ b/decks/sample-ai-apps/images/image45.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image46.svg b/decks/sample-ai-apps/images/image46.svg new file mode 100644 index 0000000..0a9872e --- /dev/null +++ b/decks/sample-ai-apps/images/image46.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image47.png b/decks/sample-ai-apps/images/image47.png new file mode 100644 index 0000000..3a67210 Binary files /dev/null and b/decks/sample-ai-apps/images/image47.png differ diff --git a/decks/sample-ai-apps/images/image48.svg b/decks/sample-ai-apps/images/image48.svg new file mode 100644 index 0000000..e356e6d --- /dev/null +++ b/decks/sample-ai-apps/images/image48.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image49.svg b/decks/sample-ai-apps/images/image49.svg new file mode 100644 index 0000000..6deb682 --- /dev/null +++ b/decks/sample-ai-apps/images/image49.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image50.svg b/decks/sample-ai-apps/images/image50.svg new file mode 100644 index 0000000..2fcbca8 --- /dev/null +++ b/decks/sample-ai-apps/images/image50.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/decks/sample-ai-apps/images/image51.png b/decks/sample-ai-apps/images/image51.png new file mode 100644 index 0000000..04c5058 Binary files /dev/null and b/decks/sample-ai-apps/images/image51.png differ diff --git a/decks/sample-ai-apps/slides.html b/decks/sample-ai-apps/slides.html new file mode 100644 index 0000000..c124462 --- /dev/null +++ b/decks/sample-ai-apps/slides.html @@ -0,0 +1,159 @@ +
+

Develop generative AI apps in Azure​

+

June 2025

+

https://aka.ms/mslearn-gen-ai-app-dev

+
+

Artificial Intelligence workloads

+

© Copyright Microsoft Corporation. All rights reserved.

+

+
Chat with solid fill
+
Robot with solid fill
+

Generative AI and Agents

+

+
Document outline
+

Text and Language

+

+
Call center outline
+

Computer Speech

+

+
Eye Scan with solid fill
+

Computer Vision

+

+
Magnifying glass with solid fill
+
Contract outline
+

Information Extraction

+

+
Gears with solid fill
+

+

Machine Learning

+
+

Our focus today

+

+
Document outline
+

Text and Language

+

+
Call center outline
+

Computer Speech

+

+
Eye Scan with solid fill
+

Computer Vision

+

+
Magnifying glass with solid fill
+
Contract outline
+

Information Extraction

+

+
Gears with solid fill
+

+

Machine Learning

+

+
Chat with solid fill
+
Robot with solid fill
+

Generative AI and Agents

+

© Copyright Microsoft Corporation. All rights reserved.

+
+
+

Plan and prepare to develop AI solutions on Azure

+

https://aka.ms/mslearn-prepare-ai-dev

+
+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

+

A

+

A

+

73262F

+

A

+

A

+

FFB3BB

+

FFFFFF

+

A

+

A

+

FF5C39

+

A

+

A

+

73391D

+

A

+

A

+

FFA38B

+

F4F3F5

+

A

+

A

+

0078D4

+

A

+

A

+

2A446F

+

A

+

A

+

8DC8E8

+

+

+

+

+

Blue Black

+

091F2C

+

A

+

A

+

702573

+

A

+

A

+

CD9BCF

+

+

+

000000

+

+

+

Neutral Gray

+

E8E6DF

+

A

+

F4364C

+

A

+

A

+

C73ECC

+

A

+

© Copyright Microsoft Corporation. All rights reserved.

+

Microsoft Foundry

+

A unified platform for AI apps and agents

+

Foundry resources in Azure provide compute, storage, model delivery, and other services

Foundry projects are isolated work environments for app and agent development

In a project, you work with:

Models – choose and deploy models from an extensive model catalog

Agents – create and manage agents that use models for language and reasoning

Tools – connect agents to tools so they can perform actions and access information

Knowledge – manage data source access for agents so they have the information they need to act

+

+

+

+

+
+

+
+
+

Models

+
+

Agents

+
Robot with solid fill
+
Robot with solid fill
+
Robot with solid fill
+
+

Tools

+
Wrench with solid fill
+
Screwdriver with solid fill
+
Screwdriver with solid fill
+
Hammer with solid fill
+
Hammer with solid fill
+
+

Knowledge

+
Document outline
+
Magnifying glass with solid fill
+
Magnifying glass with solid fill
+

Microsoft Foundry resource

+

Microsoft Foundry project

+
+

© Copyright Microsoft Corporation. All rights reserved.

+
diff --git a/decks/sample-ai-apps/strings.de.json b/decks/sample-ai-apps/strings.de.json new file mode 100644 index 0000000..6d3a7b2 --- /dev/null +++ b/decks/sample-ai-apps/strings.de.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "lang": "de", + "sourceLang": "en-US", + "strings": { + "s1.sh2.p1": { "text": "Generative KI-Apps in Azure entwickeln", "src": "7e7c01f9" }, + "s1.sh3.p1": { "text": "Juni 2025", "src": "67047020" }, + "s1.sh5.p1": { "text": "https://aka.ms/mslearn-gen-ai-app-dev", "src": "124da598" }, + "s1.notes.p1": { "text": "Über den Link auf der Folie gelangen Sie zum Microsoft Learn-Lernpfad, auf dem diese Präsentation basiert.", "src": "a9b54f42" }, + "s2.sh5.p1": { "text": "Workloads für künstliche Intelligenz", "src": "783619a6" }, + "s2.sh3.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s2.sh32.alt": { "text": "Ausgefülltes Chatsymbol", "src": "ec9a46d3" }, + "s2.sh29.alt": { "text": "Ausgefülltes Robotersymbol", "src": "28889807" }, + "s2.sh25.p1": { "text": "Generative KI und Agenten", "src": "4e78ef44" }, + "s2.sh44.alt": { "text": "Dokumentsymbol als Umriss", "src": "27286e9a" }, + "s2.sh40.p1": { "text": "Text und Sprache", "src": "a9eadd37" }, + "s2.sh49.alt": { "text": "Callcenter-Symbol als Umriss", "src": "a781af24" }, + "s2.sh47.p1": { "text": "Sprachverarbeitung", "src": "771f42be" }, + "s2.sh54.alt": { "text": "Ausgefülltes Augenscan-Symbol", "src": "06bb4243" }, + "s2.sh52.p1": { "text": "Computer Vision", "src": "23c85bd0" }, + "s2.sh60.alt": { "text": "Ausgefülltes Lupensymbol", "src": "d243ec84" }, + "s2.sh61.alt": { "text": "Vertragssymbol als Umriss", "src": "31cc5d99" }, + "s2.sh57.p1": { "text": "Informationsextraktion", "src": "7c60c39a" }, + "s2.sh67.alt": { "text": "Ausgefülltes Zahnradsymbol", "src": "8e4ad78f" }, + "s2.sh64.p1": { "text": "Maschinelles Lernen", "src": "9d25e72c" }, + "s2.notes.p1": { "text": "Geben Sie den Lernenden eine Orientierung: „Azure AI umfasst mehrere Workload-Kategorien – heute konzentrieren wir uns auf generative KI und Agenten.“", "src": "7c63c722" }, + "s2.notes.p2": { "text": "Kurzer Vergleich: klassisches maschinelles Lernen (Vorhersagen, f(x)=y) gegenüber generativer KI (Sprache + Schlussfolgern + Inhaltserstellung).", "src": "c70d5d0b" }, + "s3.sh5.p1": { "text": "Unser heutiger Schwerpunkt", "src": "ebfd8520" }, + "s3.sh44.alt": { "text": "Dokumentsymbol als Umriss", "src": "27286e9a" }, + "s3.sh40.p1": { "text": "Text und Sprache", "src": "a9eadd37" }, + "s3.sh49.alt": { "text": "Callcenter-Symbol als Umriss", "src": "a781af24" }, + "s3.sh47.p1": { "text": "Sprachverarbeitung", "src": "771f42be" }, + "s3.sh54.alt": { "text": "Ausgefülltes Augenscan-Symbol", "src": "06bb4243" }, + "s3.sh52.p1": { "text": "Computer Vision", "src": "23c85bd0" }, + "s3.sh60.alt": { "text": "Ausgefülltes Lupensymbol", "src": "d243ec84" }, + "s3.sh61.alt": { "text": "Vertrag als Umrisssymbol", "src": "31cc5d99" }, + "s3.sh57.p1": { "text": "Informationsextraktion", "src": "7c60c39a" }, + "s3.sh67.alt": { "text": "Zahnräder mit Volltonfüllung", "src": "8e4ad78f" }, + "s3.sh64.p1": { "text": "Maschinelles Lernen", "src": "9d25e72c" }, + "s3.sh8.alt": { "text": "Chat mit Volltonfüllung", "src": "ec9a46d3" }, + "s3.sh6.alt": { "text": "Roboter mit Volltonfüllung", "src": "28889807" }, + "s3.sh9.p1": { "text": "Generative KI und Agenten", "src": "4e78ef44" }, + "s3.sh3.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s3.notes.p1": { "text": "Betonen Sie die thematische Eingrenzung: „Wenn Sie wegen Bildverarbeitung, Sprachverarbeitung oder Übersetzung hier sind: Diese Möglichkeiten gibt es auch – aber heute konzentrieren wir uns auf generative KI-Modelle und die Entwicklung von Chat-Anwendungen.“", "src": "3774d615" }, + "s3.notes.p2": { "text": "Geben Sie einen Überblick über den Tagesablauf: Plattformgrundlagen → Modelle → Endpunkte/SDKs → Entwicklung von Chat-Anwendungen → Optimierung → verantwortungsvolle KI.", "src": "aa2c688c" }, + "s4.sh2.p1": { "text": "Die Entwicklung von KI-Lösungen auf Azure planen und vorbereiten", "src": "b7da702c" }, + "s4.sh4.p1": { "text": "https://aka.ms/mslearn-prepare-ai-dev", "src": "71f607d1" }, + "s4.notes.p1": { "text": "Über den Link auf der Folie gelangen Sie zum Microsoft Learn-Modul, auf dem diese Lektion basiert.", "src": "073c9933" }, + "s5.M.sh37.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh38.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh48.p1": { "text": "73262F", "src": "0182554f" }, + "s5.M.sh50.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh51.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh52.p1": { "text": "FFB3BB", "src": "4155e0ae" }, + "s5.M.sh55.p1": { "text": "FFFFFF", "src": "4414127d" }, + "s5.M.sh57.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh58.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh59.p1": { "text": "FF5C39", "src": "64f573ad" }, + "s5.M.sh61.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh62.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh63.p1": { "text": "73391D", "src": "26a09c72" }, + "s5.M.sh65.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh66.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh67.p1": { "text": "FFA38B", "src": "be00c8cf" }, + "s5.M.sh70.p1": { "text": "F4F3F5", "src": "4f0282d5" }, + "s5.M.sh72.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh73.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh74.p1": { "text": "0078D4", "src": "3d9ce272" }, + "s5.M.sh76.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh77.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh78.p1": { "text": "2A446F", "src": "87444a50" }, + "s5.M.sh80.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh81.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh82.p1": { "text": "8DC8E8", "src": "93ca13c7" }, + "s5.M.sh87.p1": { "text": "Blauschwarz", "src": "921a7b2e" }, + "s5.M.sh88.p1": { "text": "091F2C", "src": "e2f1513a" }, + "s5.M.sh93.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh94.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh95.p1": { "text": "702573", "src": "c11cc7e1" }, + "s5.M.sh97.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh98.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh99.p1": { "text": "CD9BCF", "src": "55b6be38" }, + "s5.M.sh102.p1": { "text": "000000", "src": "68d6565d" }, + "s5.M.sh105.p1": { "text": "Neutralgrau", "src": "84317097" }, + "s5.M.sh106.p1": { "text": "E8E6DF", "src": "573f15a9" }, + "s5.M.sh34.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh35.p1": { "text": "F4364C", "src": "689e5837" }, + "s5.M.sh107.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh90.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh91.p1": { "text": "C73ECC", "src": "685cf649" }, + "s5.M.sh108.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh149.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s5.sh5.p1": { "text": "Microsoft Foundry", "src": "ccb80b4a" }, + "s5.sh131.p1": { "text": "Eine einheitliche Plattform für KI-Apps und -Agents", "src": "04c0dca1" }, + "s5.sh130.p1": { "text": "Foundry-Ressourcen in Azure bieten Rechenleistung, Speicher, Modellbereitstellung und weitere Dienste", "src": "def6826b" }, + "s5.sh130.p2": { "text": "Foundry-Projekte sind isolierte Arbeitsumgebungen für die App- und Agentenentwicklung", "src": "7d9928ca" }, + "s5.sh130.p3": { "text": "In einem Projekt arbeiten Sie mit:", "src": "63a267fe" }, + "s5.sh130.p4": { "text": "Modellen – wählen Sie Modelle aus einem umfangreichen Modellkatalog aus und stellen Sie sie bereit", "src": "4c53e53a" }, + "s5.sh130.p5": { "text": "Agenten – erstellen und verwalten Sie Agenten, die Modelle für Sprachverarbeitung und logisches Denken nutzen", "src": "0ff8d083" }, + "s5.sh130.p6": { "text": "Tools – verbinden Sie Agenten mit Tools, damit sie Aktionen ausführen und auf Informationen zugreifen können", "src": "7eac46a1" }, + "s5.sh130.p7": { "text": "Wissen – verwalten Sie den Zugriff von Agenten auf Datenquellen, damit sie die Informationen erhalten, die sie zum Handeln benötigen", "src": "f1e79bfc" }, + "s5.sh83.p1": { "text": "", "src": "d642d845" }, + "s5.sh92.p1": { "text": "Modelle", "src": "01cd0fab" }, + "s5.sh95.p1": { "text": "Agenten", "src": "e7af956f" }, + "s5.sh97.alt": { "text": "Roboter mit einfarbiger Füllung", "src": "28889807" }, + "s5.sh98.alt": { "text": "Roboter mit einfarbiger Füllung", "src": "28889807" }, + "s5.sh99.alt": { "text": "Roboter mit einfarbiger Füllung", "src": "28889807" }, + "s5.sh102.p1": { "text": "Tools", "src": "950196fc" }, + "s5.sh104.alt": { "text": "Schraubenschlüssel mit einfarbiger Füllung", "src": "2fef1b14" }, + "s5.sh109.alt": { "text": "Schraubendreher mit einfarbiger Füllung", "src": "85a1be65" }, + "s5.sh110.alt": { "text": "Schraubendreher mit einfarbiger Füllung", "src": "85a1be65" }, + "s5.sh107.alt": { "text": "Hammer mit einfarbiger Füllung", "src": "9b482cef" }, + "s5.sh108.alt": { "text": "Hammer mit einfarbiger Füllung", "src": "9b482cef" }, + "s5.sh113.p1": { "text": "Wissen", "src": "ab55c08b" }, + "s5.sh115.alt": { "text": "Dokumentumriss", "src": "27286e9a" }, + "s5.sh117.alt": { "text": "Lupe mit einfarbiger Füllung", "src": "d243ec84" }, + "s5.sh118.alt": { "text": "Lupe mit einfarbiger Füllung", "src": "d243ec84" }, + "s5.sh126.p1": { "text": "Microsoft Foundry-Ressource", "src": "cb67db2f" }, + "s5.sh127.p1": { "text": "Microsoft Foundry-Projekt", "src": "c5276194" }, + "s5.sh3.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s5.notes.p1": { "text": "Definieren Sie Foundry in einem Satz: „Foundry bietet Ihnen eine projektbasierte Arbeitsumgebung, in der Sie KI-Apps und -Agenten mit Modellen, Tools und Wissen an einem zentralen Ort entwickeln können.“", "src": "d82646c3" }, + "s5.notes.p2": { "text": "Betonen Sie das zugrunde liegende Konzept: Ressourcen stellen Dienste für Rechenleistung, Speicher und die Bereitstellung von Modellen bereit.", "src": "55564b5c" }, + "s5.notes.p3": { "text": "Projekte sind isolierte Arbeitsumgebungen für die App- & Agentenentwicklung.", "src": "8e58c919" }, + "s5.notes.p4": { "text": "Innerhalb von Projekten: Modelle, Agenten, Tools, Wissen.", "src": "4d6ec164" }, + "s5.notes.p5": { "text": "Praktischer Hinweis: „Wenn die Lernenden den Überblick verlieren, verweisen Sie wieder auf diese Abbildung – die meisten Aufgaben lassen sich einem dieser vier Bereiche zuordnen.“", "src": "54027b8d" } + } +} diff --git a/decks/sample-ai-apps/strings.es.json b/decks/sample-ai-apps/strings.es.json new file mode 100644 index 0000000..6218010 --- /dev/null +++ b/decks/sample-ai-apps/strings.es.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "lang": "es", + "sourceLang": "en-US", + "strings": { + "s1.sh2.p1": { "text": "Desarrolle aplicaciones de IA generativa en Azure", "src": "7e7c01f9" }, + "s1.sh3.p1": { "text": "Junio de 2025", "src": "67047020" }, + "s1.sh5.p1": { "text": "https://aka.ms/mslearn-gen-ai-app-dev", "src": "124da598" }, + "s1.notes.p1": { "text": "Utilice el enlace de la diapositiva para consultar la ruta de aprendizaje de Microsoft Learn en la que se basa esta presentación.", "src": "a9b54f42" }, + "s2.sh5.p1": { "text": "Cargas de trabajo de inteligencia artificial", "src": "783619a6" }, + "s2.sh3.p1": { "text": "© Copyright Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s2.sh32.alt": { "text": "Chat con relleno sólido", "src": "ec9a46d3" }, + "s2.sh29.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s2.sh25.p1": { "text": "IA generativa y agentes", "src": "4e78ef44" }, + "s2.sh44.alt": { "text": "Contorno de documento", "src": "27286e9a" }, + "s2.sh40.p1": { "text": "Texto y lenguaje", "src": "a9eadd37" }, + "s2.sh49.alt": { "text": "Contorno de centro de llamadas", "src": "a781af24" }, + "s2.sh47.p1": { "text": "Tecnologías del habla", "src": "771f42be" }, + "s2.sh54.alt": { "text": "Escaneo ocular con relleno sólido", "src": "06bb4243" }, + "s2.sh52.p1": { "text": "Visión artificial", "src": "23c85bd0" }, + "s2.sh60.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s2.sh61.alt": { "text": "Contorno de contrato", "src": "31cc5d99" }, + "s2.sh57.p1": { "text": "Extracción de información", "src": "7c60c39a" }, + "s2.sh67.alt": { "text": "Engranajes con relleno sólido", "src": "8e4ad78f" }, + "s2.sh64.p1": { "text": "Aprendizaje automático", "src": "9d25e72c" }, + "s2.notes.p1": { "text": "Oriente a los participantes: «Azure AI abarca varias familias de cargas de trabajo; hoy nos centraremos en la IA generativa y los agentes».", "src": "7c63c722" }, + "s2.notes.p2": { "text": "Comparación rápida: aprendizaje automático clásico (predictivo, f(x)=y) frente a IA generativa (lenguaje + razonamiento + generación de contenido).", "src": "c70d5d0b" }, + "s3.sh5.p1": { "text": "Nuestro enfoque de hoy", "src": "ebfd8520" }, + "s3.sh44.alt": { "text": "Contorno de documento", "src": "27286e9a" }, + "s3.sh40.p1": { "text": "Texto y lenguaje", "src": "a9eadd37" }, + "s3.sh49.alt": { "text": "Contorno de centro de llamadas", "src": "a781af24" }, + "s3.sh47.p1": { "text": "Tecnologías del habla", "src": "771f42be" }, + "s3.sh54.alt": { "text": "Escaneo ocular con relleno sólido", "src": "06bb4243" }, + "s3.sh52.p1": { "text": "Visión artificial", "src": "23c85bd0" }, + "s3.sh60.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s3.sh61.alt": { "text": "Contorno de un contrato", "src": "31cc5d99" }, + "s3.sh57.p1": { "text": "Extracción de información", "src": "7c60c39a" }, + "s3.sh67.alt": { "text": "Engranajes con relleno sólido", "src": "8e4ad78f" }, + "s3.sh64.p1": { "text": "Aprendizaje automático", "src": "9d25e72c" }, + "s3.sh8.alt": { "text": "Burbuja de chat con relleno sólido", "src": "ec9a46d3" }, + "s3.sh6.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s3.sh9.p1": { "text": "IA generativa y agentes", "src": "4e78ef44" }, + "s3.sh3.p1": { "text": "© Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s3.notes.p1": { "text": "Recalque los límites del contenido: «Si han venido por la visión, el habla o la traducción, esas capacidades existen, pero hoy nos centraremos en los modelos de IA generativa y en el desarrollo de aplicaciones de chat».", "src": "3774d615" }, + "s3.notes.p2": { "text": "Anticipe la estructura de la jornada: conceptos básicos de la plataforma → modelos → puntos de conexión y SDK → desarrollo de aplicaciones de chat → optimización → IA responsable.", "src": "aa2c688c" }, + "s4.sh2.p1": { "text": "Planificar y preparar el desarrollo de soluciones de IA en Azure", "src": "b7da702c" }, + "s4.sh4.p1": { "text": "https://aka.ms/mslearn-prepare-ai-dev", "src": "71f607d1" }, + "s4.notes.p1": { "text": "Utilice el enlace de la diapositiva para consultar el módulo de Microsoft Learn en el que se basa esta lección.", "src": "073c9933" }, + "s5.M.sh37.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh38.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh48.p1": { "text": "73262F", "src": "0182554f" }, + "s5.M.sh50.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh51.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh52.p1": { "text": "FFB3BB", "src": "4155e0ae" }, + "s5.M.sh55.p1": { "text": "FFFFFF", "src": "4414127d" }, + "s5.M.sh57.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh58.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh59.p1": { "text": "FF5C39", "src": "64f573ad" }, + "s5.M.sh61.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh62.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh63.p1": { "text": "73391D", "src": "26a09c72" }, + "s5.M.sh65.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh66.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh67.p1": { "text": "FFA38B", "src": "be00c8cf" }, + "s5.M.sh70.p1": { "text": "F4F3F5", "src": "4f0282d5" }, + "s5.M.sh72.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh73.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh74.p1": { "text": "0078D4", "src": "3d9ce272" }, + "s5.M.sh76.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh77.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh78.p1": { "text": "2A446F", "src": "87444a50" }, + "s5.M.sh80.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh81.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh82.p1": { "text": "8DC8E8", "src": "93ca13c7" }, + "s5.M.sh87.p1": { "text": "Negro azulado", "src": "921a7b2e" }, + "s5.M.sh88.p1": { "text": "091F2C", "src": "e2f1513a" }, + "s5.M.sh93.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh94.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh95.p1": { "text": "702573", "src": "c11cc7e1" }, + "s5.M.sh97.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh98.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh99.p1": { "text": "CD9BCF", "src": "55b6be38" }, + "s5.M.sh102.p1": { "text": "000000", "src": "68d6565d" }, + "s5.M.sh105.p1": { "text": "Gris neutro", "src": "84317097" }, + "s5.M.sh106.p1": { "text": "E8E6DF", "src": "573f15a9" }, + "s5.M.sh34.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh35.p1": { "text": "F4364C", "src": "689e5837" }, + "s5.M.sh107.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh90.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh91.p1": { "text": "C73ECC", "src": "685cf649" }, + "s5.M.sh108.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh149.p1": { "text": "© Copyright Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s5.sh5.p1": { "text": "Microsoft Foundry", "src": "ccb80b4a" }, + "s5.sh131.p1": { "text": "Una plataforma unificada para aplicaciones y agentes de IA", "src": "04c0dca1" }, + "s5.sh130.p1": { "text": "Los recursos de Foundry en Azure proporcionan capacidad de procesamiento, almacenamiento, distribución de modelos y otros servicios", "src": "def6826b" }, + "s5.sh130.p2": { "text": "Los proyectos de Foundry son entornos de trabajo aislados para desarrollar aplicaciones y agentes", "src": "7d9928ca" }, + "s5.sh130.p3": { "text": "En un proyecto, trabajas con:", "src": "63a267fe" }, + "s5.sh130.p4": { "text": "Modelos – elige e implementa modelos de un amplio catálogo", "src": "4c53e53a" }, + "s5.sh130.p5": { "text": "Agentes – crea y administra agentes que utilizan modelos para tareas de lenguaje y razonamiento", "src": "0ff8d083" }, + "s5.sh130.p6": { "text": "Herramientas – conecta los agentes a herramientas para que puedan realizar acciones y acceder a información", "src": "7eac46a1" }, + "s5.sh130.p7": { "text": "Conocimiento – administra el acceso de los agentes a las fuentes de datos para que dispongan de la información que necesitan para actuar", "src": "f1e79bfc" }, + "s5.sh83.p1": { "text": "", "src": "d642d845" }, + "s5.sh92.p1": { "text": "Modelos", "src": "01cd0fab" }, + "s5.sh95.p1": { "text": "Agentes", "src": "e7af956f" }, + "s5.sh97.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s5.sh98.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s5.sh99.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s5.sh102.p1": { "text": "Herramientas", "src": "950196fc" }, + "s5.sh104.alt": { "text": "Llave inglesa con relleno sólido", "src": "2fef1b14" }, + "s5.sh109.alt": { "text": "Destornillador con relleno sólido", "src": "85a1be65" }, + "s5.sh110.alt": { "text": "Destornillador con relleno sólido", "src": "85a1be65" }, + "s5.sh107.alt": { "text": "Martillo con relleno sólido", "src": "9b482cef" }, + "s5.sh108.alt": { "text": "Martillo con relleno sólido", "src": "9b482cef" }, + "s5.sh113.p1": { "text": "Conocimiento", "src": "ab55c08b" }, + "s5.sh115.alt": { "text": "Contorno de un documento", "src": "27286e9a" }, + "s5.sh117.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s5.sh118.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s5.sh126.p1": { "text": "Recurso de Microsoft Foundry", "src": "cb67db2f" }, + "s5.sh127.p1": { "text": "Proyecto de Microsoft Foundry", "src": "c5276194" }, + "s5.sh3.p1": { "text": "© Copyright Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s5.notes.p1": { "text": "Define Foundry en una frase: «Foundry te ofrece un espacio de trabajo basado en proyectos para crear aplicaciones y agentes de IA con modelos, herramientas y conocimiento en un solo lugar».", "src": "d82646c3" }, + "s5.notes.p2": { "text": "Destaca el modelo conceptual: los recursos proporcionan servicios de cómputo, almacenamiento y suministro de modelos.", "src": "55564b5c" }, + "s5.notes.p3": { "text": "Los proyectos son entornos de trabajo aislados para desarrollar aplicaciones y agentes.", "src": "8e58c919" }, + "s5.notes.p4": { "text": "Dentro de los proyectos: Modelos, Agentes, Herramientas, Conocimiento.", "src": "4d6ec164" }, + "s5.notes.p5": { "text": "Enfoque práctico: «Cuando los participantes se sientan perdidos, vuelve a mostrarles esta imagen: la mayoría de las tareas corresponden a una de estas cuatro categorías».", "src": "54027b8d" } + } +} diff --git a/decks/sample-ai-apps/strings.fr.json b/decks/sample-ai-apps/strings.fr.json new file mode 100644 index 0000000..ce2e25d --- /dev/null +++ b/decks/sample-ai-apps/strings.fr.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "lang": "fr", + "sourceLang": "en-US", + "strings": { + "s1.sh2.p1": { "text": "Développer des applications d’IA générative dans Azure", "src": "7e7c01f9" }, + "s1.sh3.p1": { "text": "Juin 2025", "src": "67047020" }, + "s1.sh5.p1": { "text": "https://aka.ms/mslearn-gen-ai-app-dev", "src": "124da598" }, + "s1.notes.p1": { "text": "Utilisez le lien sur la diapositive pour consulter le parcours de formation Microsoft Learn dont cette présentation est issue.", "src": "a9b54f42" }, + "s2.sh5.p1": { "text": "Charges de travail d’intelligence artificielle", "src": "783619a6" }, + "s2.sh3.p1": { "text": "© Copyright Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s2.sh32.alt": { "text": "Bulle de discussion pleine", "src": "ec9a46d3" }, + "s2.sh29.alt": { "text": "Robot plein", "src": "28889807" }, + "s2.sh25.p1": { "text": "IA générative et agents", "src": "4e78ef44" }, + "s2.sh44.alt": { "text": "Contour de document", "src": "27286e9a" }, + "s2.sh40.p1": { "text": "Texte et langage", "src": "a9eadd37" }, + "s2.sh49.alt": { "text": "Contour de centre d’appels", "src": "a781af24" }, + "s2.sh47.p1": { "text": "Traitement de la parole", "src": "771f42be" }, + "s2.sh54.alt": { "text": "Icône pleine de balayage oculaire", "src": "06bb4243" }, + "s2.sh52.p1": { "text": "Vision par ordinateur", "src": "23c85bd0" }, + "s2.sh60.alt": { "text": "Loupe pleine", "src": "d243ec84" }, + "s2.sh61.alt": { "text": "Contour de contrat", "src": "31cc5d99" }, + "s2.sh57.p1": { "text": "Extraction d’informations", "src": "7c60c39a" }, + "s2.sh67.alt": { "text": "Engrenages pleins", "src": "8e4ad78f" }, + "s2.sh64.p1": { "text": "Apprentissage automatique", "src": "9d25e72c" }, + "s2.notes.p1": { "text": "Situez le sujet pour les apprenants : « Azure AI couvre plusieurs familles de charges de travail. Aujourd’hui, nous nous concentrons sur l’IA générative et les agents. »", "src": "7c63c722" }, + "s2.notes.p2": { "text": "Comparaison rapide : apprentissage automatique classique (prédictif, f(x)=y) et IA générative (langage + raisonnement + génération de contenu).", "src": "c70d5d0b" }, + "s3.sh5.p1": { "text": "Notre sujet aujourd’hui", "src": "ebfd8520" }, + "s3.sh44.alt": { "text": "Contour de document", "src": "27286e9a" }, + "s3.sh40.p1": { "text": "Texte et langage", "src": "a9eadd37" }, + "s3.sh49.alt": { "text": "Contour de centre d’appels", "src": "a781af24" }, + "s3.sh47.p1": { "text": "Traitement de la parole", "src": "771f42be" }, + "s3.sh54.alt": { "text": "Icône pleine de balayage oculaire", "src": "06bb4243" }, + "s3.sh52.p1": { "text": "Vision par ordinateur", "src": "23c85bd0" }, + "s3.sh60.alt": { "text": "Loupe pleine", "src": "d243ec84" }, + "s3.sh61.alt": { "text": "Icône de contrat en contour", "src": "31cc5d99" }, + "s3.sh57.p1": { "text": "Extraction d’informations", "src": "7c60c39a" }, + "s3.sh67.alt": { "text": "Engrenages en aplat", "src": "8e4ad78f" }, + "s3.sh64.p1": { "text": "Apprentissage automatique", "src": "9d25e72c" }, + "s3.sh8.alt": { "text": "Bulle de conversation en aplat", "src": "ec9a46d3" }, + "s3.sh6.alt": { "text": "Robot en aplat", "src": "28889807" }, + "s3.sh9.p1": { "text": "IA générative et agents", "src": "4e78ef44" }, + "s3.sh3.p1": { "text": "© Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s3.notes.p1": { "text": "Rappelez les limites du périmètre : « Si vous êtes venus pour la vision, la reconnaissance vocale ou la traduction, ces fonctionnalités existent, mais aujourd’hui, nous nous concentrons sur les modèles d’IA générative et le développement d’applications de conversation. »", "src": "3774d615" }, + "s3.notes.p2": { "text": "Présentez le déroulement de la journée : bases de la plateforme → modèles → points de terminaison/SDK → développement d’applications de conversation → optimisation → IA responsable.", "src": "aa2c688c" }, + "s4.sh2.p1": { "text": "Planifier et préparer le développement de solutions d’IA sur Azure", "src": "b7da702c" }, + "s4.sh4.p1": { "text": "https://aka.ms/mslearn-prepare-ai-dev", "src": "71f607d1" }, + "s4.notes.p1": { "text": "Utilisez le lien sur la diapositive pour consulter le module Microsoft Learn dont cette leçon est issue.", "src": "073c9933" }, + "s5.M.sh37.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh38.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh48.p1": { "text": "73262F", "src": "0182554f" }, + "s5.M.sh50.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh51.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh52.p1": { "text": "FFB3BB", "src": "4155e0ae" }, + "s5.M.sh55.p1": { "text": "FFFFFF", "src": "4414127d" }, + "s5.M.sh57.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh58.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh59.p1": { "text": "FF5C39", "src": "64f573ad" }, + "s5.M.sh61.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh62.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh63.p1": { "text": "73391D", "src": "26a09c72" }, + "s5.M.sh65.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh66.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh67.p1": { "text": "FFA38B", "src": "be00c8cf" }, + "s5.M.sh70.p1": { "text": "F4F3F5", "src": "4f0282d5" }, + "s5.M.sh72.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh73.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh74.p1": { "text": "0078D4", "src": "3d9ce272" }, + "s5.M.sh76.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh77.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh78.p1": { "text": "2A446F", "src": "87444a50" }, + "s5.M.sh80.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh81.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh82.p1": { "text": "8DC8E8", "src": "93ca13c7" }, + "s5.M.sh87.p1": { "text": "Bleu-noir", "src": "921a7b2e" }, + "s5.M.sh88.p1": { "text": "091F2C", "src": "e2f1513a" }, + "s5.M.sh93.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh94.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh95.p1": { "text": "702573", "src": "c11cc7e1" }, + "s5.M.sh97.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh98.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh99.p1": { "text": "CD9BCF", "src": "55b6be38" }, + "s5.M.sh102.p1": { "text": "000000", "src": "68d6565d" }, + "s5.M.sh105.p1": { "text": "Gris neutre", "src": "84317097" }, + "s5.M.sh106.p1": { "text": "E8E6DF", "src": "573f15a9" }, + "s5.M.sh34.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh35.p1": { "text": "F4364C", "src": "689e5837" }, + "s5.M.sh107.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh90.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh91.p1": { "text": "C73ECC", "src": "685cf649" }, + "s5.M.sh108.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh149.p1": { "text": "© Copyright Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s5.sh5.p1": { "text": "Microsoft Foundry", "src": "ccb80b4a" }, + "s5.sh131.p1": { "text": "Une plateforme unifiée pour les applications et les agents d’IA", "src": "04c0dca1" }, + "s5.sh130.p1": { "text": "Les ressources Foundry dans Azure fournissent des services de calcul, de stockage, de mise à disposition de modèles et d’autres services", "src": "def6826b" }, + "s5.sh130.p2": { "text": "Les projets Foundry sont des environnements de travail isolés pour le développement d’applications et d’agents", "src": "7d9928ca" }, + "s5.sh130.p3": { "text": "Dans un projet, vous travaillez avec :", "src": "63a267fe" }, + "s5.sh130.p4": { "text": "Modèles – choisissez et déployez des modèles à partir d’un vaste catalogue", "src": "4c53e53a" }, + "s5.sh130.p5": { "text": "Agents – créez et gérez des agents qui utilisent des modèles pour le traitement du langage et le raisonnement", "src": "0ff8d083" }, + "s5.sh130.p6": { "text": "Outils – connectez les agents à des outils pour leur permettre d’effectuer des actions et d’accéder à des informations", "src": "7eac46a1" }, + "s5.sh130.p7": { "text": "Connaissances – gérez l’accès des agents aux sources de données afin qu’ils disposent des informations nécessaires pour agir", "src": "f1e79bfc" }, + "s5.sh83.p1": { "text": "", "src": "d642d845" }, + "s5.sh92.p1": { "text": "Modèles", "src": "01cd0fab" }, + "s5.sh95.p1": { "text": "Agents", "src": "e7af956f" }, + "s5.sh97.alt": { "text": "Robot en silhouette pleine", "src": "28889807" }, + "s5.sh98.alt": { "text": "Robot en silhouette pleine", "src": "28889807" }, + "s5.sh99.alt": { "text": "Robot en silhouette pleine", "src": "28889807" }, + "s5.sh102.p1": { "text": "Outils", "src": "950196fc" }, + "s5.sh104.alt": { "text": "Clé plate en silhouette pleine", "src": "2fef1b14" }, + "s5.sh109.alt": { "text": "Tournevis en silhouette pleine", "src": "85a1be65" }, + "s5.sh110.alt": { "text": "Tournevis en silhouette pleine", "src": "85a1be65" }, + "s5.sh107.alt": { "text": "Marteau en silhouette pleine", "src": "9b482cef" }, + "s5.sh108.alt": { "text": "Marteau en silhouette pleine", "src": "9b482cef" }, + "s5.sh113.p1": { "text": "Connaissances", "src": "ab55c08b" }, + "s5.sh115.alt": { "text": "Contour d’un document", "src": "27286e9a" }, + "s5.sh117.alt": { "text": "Loupe en silhouette pleine", "src": "d243ec84" }, + "s5.sh118.alt": { "text": "Loupe en silhouette pleine", "src": "d243ec84" }, + "s5.sh126.p1": { "text": "Ressource Microsoft Foundry", "src": "cb67db2f" }, + "s5.sh127.p1": { "text": "Projet Microsoft Foundry", "src": "c5276194" }, + "s5.sh3.p1": { "text": "© Copyright Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s5.notes.p1": { "text": "Définissez Foundry en une phrase : « Foundry vous offre un espace de travail organisé en projets pour créer des applications et des agents d’IA, avec des modèles, des outils et des connaissances réunis au même endroit. »", "src": "d82646c3" }, + "s5.notes.p2": { "text": "Insistez sur le modèle conceptuel : les ressources fournissent des services de calcul, de stockage et de mise à disposition de modèles.", "src": "55564b5c" }, + "s5.notes.p3": { "text": "Les projets sont des environnements de travail isolés pour le développement d’applications & d’agents.", "src": "8e58c919" }, + "s5.notes.p4": { "text": "Au sein des projets : modèles, agents, outils, connaissances.", "src": "4d6ec164" }, + "s5.notes.p5": { "text": "Repère pratique : « Lorsque les apprenants se sentent perdus, revenez à ce schéma : la plupart des tâches se rattachent à l’une de ces quatre catégories. »", "src": "54027b8d" } + } +} diff --git a/decks/sample-ai-apps/strings.json b/decks/sample-ai-apps/strings.json new file mode 100644 index 0000000..29ee483 --- /dev/null +++ b/decks/sample-ai-apps/strings.json @@ -0,0 +1,841 @@ +{ + "version": 1, + "sourceLang": "en-US", + "strings": [ + { + "id": "s1.sh2.p1", + "slide": 1, + "kind": "text", + "text": "Develop generative AI apps in Azure​" + }, + { + "id": "s1.sh3.p1", + "slide": 1, + "kind": "text", + "text": "June 2025", + "context": "Develop generative AI apps in Azure​" + }, + { + "id": "s1.sh5.p1", + "slide": 1, + "kind": "text", + "text": "https://aka.ms/mslearn-gen-ai-app-dev", + "context": "Develop generative AI apps in Azure​" + }, + { + "id": "s1.notes.p1", + "slide": 1, + "kind": "notes", + "text": "Use the link on the slide to see the Microsoft Learn learning path from which this presentation is derived.", + "context": "Develop generative AI apps in Azure​" + }, + { + "id": "s2.sh5.p1", + "slide": 2, + "kind": "text", + "text": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh3.p1", + "slide": 2, + "kind": "text", + "text": "© Copyright Microsoft Corporation. All rights reserved.", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh32.alt", + "slide": 2, + "kind": "alt", + "text": "Chat with solid fill", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh29.alt", + "slide": 2, + "kind": "alt", + "text": "Robot with solid fill", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh25.p1", + "slide": 2, + "kind": "text", + "text": "Generative AI and Agents", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh44.alt", + "slide": 2, + "kind": "alt", + "text": "Document outline", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh40.p1", + "slide": 2, + "kind": "text", + "text": "Text and Language", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh49.alt", + "slide": 2, + "kind": "alt", + "text": "Call center outline", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh47.p1", + "slide": 2, + "kind": "text", + "text": "Computer Speech", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh54.alt", + "slide": 2, + "kind": "alt", + "text": "Eye Scan with solid fill", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh52.p1", + "slide": 2, + "kind": "text", + "text": "Computer Vision", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh60.alt", + "slide": 2, + "kind": "alt", + "text": "Magnifying glass with solid fill", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh61.alt", + "slide": 2, + "kind": "alt", + "text": "Contract outline", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh57.p1", + "slide": 2, + "kind": "text", + "text": "Information Extraction", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh67.alt", + "slide": 2, + "kind": "alt", + "text": "Gears with solid fill", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.sh64.p1", + "slide": 2, + "kind": "text", + "text": "Machine Learning", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.notes.p1", + "slide": 2, + "kind": "notes", + "text": "Orient learners: “Azure AI spans multiple workload families—today we’re zooming in on Generative AI and Agents.”", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s2.notes.p2", + "slide": 2, + "kind": "notes", + "text": "Quick contrast: classic ML (predictive, f(x)=y) vs. generative AI (language + reasoning + content generation).", + "context": "Artificial Intelligence workloads" + }, + { + "id": "s3.sh5.p1", + "slide": 3, + "kind": "text", + "text": "Our focus today" + }, + { + "id": "s3.sh44.alt", + "slide": 3, + "kind": "alt", + "text": "Document outline", + "context": "Our focus today" + }, + { + "id": "s3.sh40.p1", + "slide": 3, + "kind": "text", + "text": "Text and Language", + "context": "Our focus today" + }, + { + "id": "s3.sh49.alt", + "slide": 3, + "kind": "alt", + "text": "Call center outline", + "context": "Our focus today" + }, + { + "id": "s3.sh47.p1", + "slide": 3, + "kind": "text", + "text": "Computer Speech", + "context": "Our focus today" + }, + { + "id": "s3.sh54.alt", + "slide": 3, + "kind": "alt", + "text": "Eye Scan with solid fill", + "context": "Our focus today" + }, + { + "id": "s3.sh52.p1", + "slide": 3, + "kind": "text", + "text": "Computer Vision", + "context": "Our focus today" + }, + { + "id": "s3.sh60.alt", + "slide": 3, + "kind": "alt", + "text": "Magnifying glass with solid fill", + "context": "Our focus today" + }, + { + "id": "s3.sh61.alt", + "slide": 3, + "kind": "alt", + "text": "Contract outline", + "context": "Our focus today" + }, + { + "id": "s3.sh57.p1", + "slide": 3, + "kind": "text", + "text": "Information Extraction", + "context": "Our focus today" + }, + { + "id": "s3.sh67.alt", + "slide": 3, + "kind": "alt", + "text": "Gears with solid fill", + "context": "Our focus today" + }, + { + "id": "s3.sh64.p1", + "slide": 3, + "kind": "text", + "text": "Machine Learning", + "context": "Our focus today" + }, + { + "id": "s3.sh8.alt", + "slide": 3, + "kind": "alt", + "text": "Chat with solid fill", + "context": "Our focus today" + }, + { + "id": "s3.sh6.alt", + "slide": 3, + "kind": "alt", + "text": "Robot with solid fill", + "context": "Our focus today" + }, + { + "id": "s3.sh9.p1", + "slide": 3, + "kind": "text", + "text": "Generative AI and Agents", + "context": "Our focus today" + }, + { + "id": "s3.sh3.p1", + "slide": 3, + "kind": "text", + "text": "© Copyright Microsoft Corporation. All rights reserved.", + "context": "Our focus today" + }, + { + "id": "s3.notes.p1", + "slide": 3, + "kind": "notes", + "text": "Reinforce scope control: “If you came for vision/speech/translation, those exist—but today’s focus is on generative AI models and chat application development.”", + "context": "Our focus today" + }, + { + "id": "s3.notes.p2", + "slide": 3, + "kind": "notes", + "text": "Preview the shape of the day: platform basics → models → endpoints/SDKs → chat app development→ optimization → responsible AI.", + "context": "Our focus today" + }, + { + "id": "s4.sh2.p1", + "slide": 4, + "kind": "text", + "text": "Plan and prepare to develop AI solutions on Azure" + }, + { + "id": "s4.sh4.p1", + "slide": 4, + "kind": "text", + "text": "https://aka.ms/mslearn-prepare-ai-dev", + "context": "Plan and prepare to develop AI solutions on Azure" + }, + { + "id": "s4.notes.p1", + "slide": 4, + "kind": "notes", + "text": "Use the link on the slide to see the Microsoft Learn module from which this lesson is derived.", + "context": "Plan and prepare to develop AI solutions on Azure" + }, + { + "id": "s5.M.sh37.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh38.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh48.p1", + "slide": 5, + "kind": "text", + "text": "73262F", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh50.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh51.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh52.p1", + "slide": 5, + "kind": "text", + "text": "FFB3BB", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh55.p1", + "slide": 5, + "kind": "text", + "text": "FFFFFF", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh57.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh58.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh59.p1", + "slide": 5, + "kind": "text", + "text": "FF5C39", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh61.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh62.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh63.p1", + "slide": 5, + "kind": "text", + "text": "73391D", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh65.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh66.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh67.p1", + "slide": 5, + "kind": "text", + "text": "FFA38B", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh70.p1", + "slide": 5, + "kind": "text", + "text": "F4F3F5", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh72.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh73.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh74.p1", + "slide": 5, + "kind": "text", + "text": "0078D4", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh76.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh77.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh78.p1", + "slide": 5, + "kind": "text", + "text": "2A446F", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh80.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh81.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh82.p1", + "slide": 5, + "kind": "text", + "text": "8DC8E8", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh87.p1", + "slide": 5, + "kind": "text", + "text": "Blue Black", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh88.p1", + "slide": 5, + "kind": "text", + "text": "091F2C", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh93.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh94.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh95.p1", + "slide": 5, + "kind": "text", + "text": "702573", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh97.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh98.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh99.p1", + "slide": 5, + "kind": "text", + "text": "CD9BCF", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh102.p1", + "slide": 5, + "kind": "text", + "text": "000000", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh105.p1", + "slide": 5, + "kind": "text", + "text": "Neutral Gray", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh106.p1", + "slide": 5, + "kind": "text", + "text": "E8E6DF", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh34.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh35.p1", + "slide": 5, + "kind": "text", + "text": "F4364C", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh107.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh90.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh91.p1", + "slide": 5, + "kind": "text", + "text": "C73ECC", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh108.p1", + "slide": 5, + "kind": "text", + "text": "A", + "context": "Microsoft Foundry" + }, + { + "id": "s5.M.sh149.p1", + "slide": 5, + "kind": "text", + "text": "© Copyright Microsoft Corporation. All rights reserved.", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh5.p1", + "slide": 5, + "kind": "text", + "text": "Microsoft Foundry" + }, + { + "id": "s5.sh131.p1", + "slide": 5, + "kind": "text", + "text": "A unified platform for AI apps and agents", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh130.p1", + "slide": 5, + "kind": "text", + "text": "Foundry resources in Azure provide compute, storage, model delivery, and other services", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh130.p2", + "slide": 5, + "kind": "text", + "text": "Foundry projects are isolated work environments for app and agent development", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh130.p3", + "slide": 5, + "kind": "text", + "text": "In a project, you work with:", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh130.p4", + "slide": 5, + "kind": "text", + "text": "Models – choose and deploy models from an extensive model catalog", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh130.p5", + "slide": 5, + "kind": "text", + "text": "Agents – create and manage agents that use models for language and reasoning", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh130.p6", + "slide": 5, + "kind": "text", + "text": "Tools – connect agents to tools so they can perform actions and access information", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh130.p7", + "slide": 5, + "kind": "text", + "text": "Knowledge – manage data source access for agents so they have the information they need to act ", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh83.p1", + "slide": 5, + "kind": "text", + "text": "", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh92.p1", + "slide": 5, + "kind": "text", + "text": "Models", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh95.p1", + "slide": 5, + "kind": "text", + "text": "Agents", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh97.alt", + "slide": 5, + "kind": "alt", + "text": "Robot with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh98.alt", + "slide": 5, + "kind": "alt", + "text": "Robot with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh99.alt", + "slide": 5, + "kind": "alt", + "text": "Robot with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh102.p1", + "slide": 5, + "kind": "text", + "text": "Tools", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh104.alt", + "slide": 5, + "kind": "alt", + "text": "Wrench with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh109.alt", + "slide": 5, + "kind": "alt", + "text": "Screwdriver with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh110.alt", + "slide": 5, + "kind": "alt", + "text": "Screwdriver with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh107.alt", + "slide": 5, + "kind": "alt", + "text": "Hammer with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh108.alt", + "slide": 5, + "kind": "alt", + "text": "Hammer with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh113.p1", + "slide": 5, + "kind": "text", + "text": "Knowledge", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh115.alt", + "slide": 5, + "kind": "alt", + "text": "Document outline", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh117.alt", + "slide": 5, + "kind": "alt", + "text": "Magnifying glass with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh118.alt", + "slide": 5, + "kind": "alt", + "text": "Magnifying glass with solid fill", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh126.p1", + "slide": 5, + "kind": "text", + "text": "Microsoft Foundry resource", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh127.p1", + "slide": 5, + "kind": "text", + "text": "Microsoft Foundry project", + "context": "Microsoft Foundry" + }, + { + "id": "s5.sh3.p1", + "slide": 5, + "kind": "text", + "text": "© Copyright Microsoft Corporation. All rights reserved.", + "context": "Microsoft Foundry" + }, + { + "id": "s5.notes.p1", + "slide": 5, + "kind": "notes", + "text": "Define Foundry in one sentence: “Foundry gives you a project‑based workspace to build AI apps/agents with models, tools, and knowledge in one place.”", + "context": "Microsoft Foundry" + }, + { + "id": "s5.notes.p2", + "slide": 5, + "kind": "notes", + "text": "Emphasize the mental model: Resources provide compute/storage/model delivery services.", + "context": "Microsoft Foundry" + }, + { + "id": "s5.notes.p3", + "slide": 5, + "kind": "notes", + "text": "Projects are isolated work environments for app & agent development.", + "context": "Microsoft Foundry" + }, + { + "id": "s5.notes.p4", + "slide": 5, + "kind": "notes", + "text": "Inside projects: Models, Agents, Tools, Knowledge.", + "context": "Microsoft Foundry" + }, + { + "id": "s5.notes.p5", + "slide": 5, + "kind": "notes", + "text": "Practical framing: “When learners feel lost, bring them back to this picture—most tasks map to one of these four.”", + "context": "Microsoft Foundry" + } + ] +} diff --git a/docs/examples/strings.de.json b/docs/examples/strings.de.json new file mode 100644 index 0000000..7b35ab6 --- /dev/null +++ b/docs/examples/strings.de.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "lang": "de", + "sourceLang": "en", + "strings": { + "s1.sh2.p1": { "text": "Decks als Code", "src": "2218a577" }, + "s1.sh3.p1": { "text": "PPTX durch Klartext-Präsentationen ersetzen", "src": "2463d0d5" } + } +} diff --git a/docs/examples/strings.json b/docs/examples/strings.json new file mode 100644 index 0000000..1948b57 --- /dev/null +++ b/docs/examples/strings.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "sourceLang": "en", + "strings": [ + { "id": "s1.sh2.p1", "slide": 1, "kind": "text", "text": "Decks as Code" }, + { "id": "s1.sh3.p1", "slide": 1, "kind": "text", "text": "Replacing PPTX with plain-text presentations" }, + { "id": "s2.sh2.p1", "slide": 2, "kind": "text", "text": "Why bother" }, + { "id": "s2.sh3.p1", "slide": 2, "kind": "text", "context": "Why bother", "text": "Changes are a readable diff in a pull request" }, + { "id": "s2.sh3.p2", "slide": 2, "kind": "text", "context": "Why bother", "text": "Read the docs first.
Then try it." }, + { "id": "s2.sh4.r1c1.p1", "slide": 2, "kind": "table", "context": "Why bother", "text": "Tool" }, + { "id": "s2.sh5.alt", "slide": 2, "kind": "alt", "context": "Why bother", "text": "Diagram of the build pipeline" }, + { "id": "s2.notes.p1", "slide": 2, "kind": "notes", "context": "Why bother", "text": "Pause here and ask who has lost a deck to an email thread." } + ] +} diff --git a/docs/string-table.md b/docs/string-table.md new file mode 100644 index 0000000..c15c055 --- /dev/null +++ b/docs/string-table.md @@ -0,0 +1,119 @@ +# String table contract + +The interface between the PPTX converter and everything that localizes a deck +(translate CLI, language switcher, review view). Version 1. + +The converter emits one **source table** per deck. A translation is a separate +file keyed by string ID. The slide HTML never changes per language; the viewer +swaps text by ID. + +## Files + +``` +decks// +├── slides.html generated; every localizable node carries data-i18n="" +├── strings.json source table (written by the converter) +└── strings..json one per target language (written by the translate CLI) +``` + +## Source table: `strings.json` + +```json +{ + "version": 1, + "sourceLang": "en", + "strings": [ + { "id": "s1.sh2.p1", "slide": 1, "kind": "text", "text": "Decks as Code" } + ] +} +``` + +| Field | Notes | +| --- | --- | +| `id` | Unique, stable key, see below. | +| `slide` | 1-based position in presentation order. | +| `kind` | `text`, `table`, `notes`, `alt`. Lets translators treat alt text and notes differently. | +| `text` | One whole paragraph as an HTML fragment. Never a single run. | +| `context` | Optional. Short hint for translators, e.g. the slide title. Never rendered. | + +Strings are listed in document order: slide by slide, shapes in z-order, then +notes. Empty paragraphs are not emitted. + +### IDs + +| Pattern | Source | +| --- | --- | +| `s.sh.p` | Paragraph `n` (1-based) of the shape whose `cNvPr id` is `shapeId` | +| `s.sh.rc.p` | Paragraph in a table cell | +| `s.sh.alt` | Alt text of a shape or picture | +| `s.L.sh.p` | Text from a layout shape drawn on that slide (logos, labels) | +| `s.M.sh.p` | Same, from the slide master | +| `s.notes.p` | Speaker note paragraph | + +Shape IDs come from the PPTX, so they survive reordering shapes within a slide. +Slide numbers do not survive reordering slides; re-running the converter after +reordering means re-translating. + +Paragraphs set entirely in a monospace font are code. They stay in the HTML but +are left out of the table, so nothing translates them. + +### Inline markup in `text` + +The only tags allowed are ``, ``, ``, ``, ``, ``, `
` +and ``. Everything else is escaped (`<`, `>`, `&`). + +Translators must keep every tag, in matching pairs, and must not change `href` +values. Tag order may change when word order does. A translation that fails this +check is invalid. + +## Translation: `strings..json` + +```json +{ + "version": 1, + "lang": "de", + "sourceLang": "en", + "strings": { + "s1.sh2.p1": { "text": "Decks als Code", "src": "9f3a1c2e" } + } +} +``` + +- `lang` is a BCP 47 tag (`de`, `pt-BR`, `ar`). +- `src` is the hash of the source `text` the translation was made from: FNV-1a + 32-bit over the UTF-8 bytes, lowercase hex, 8 characters. It is not + cryptographic. When the source text changes, `src` no longer matches and that + one string is stale and gets re-translated; the rest are kept. +- A missing or stale string falls back to the source text in the viewer. The + deck never renders blank. +- Right-to-left languages (`ar`, `he`, `fa`, `ur`) set `dir="rtl"` on the deck + root, along with `lang`. + +## In the HTML + +`slides.html` marks every translatable node so a viewer can swap text without +knowing anything about PowerPoint: + +| Marker | Meaning | +| --- | --- | +| `data-i18n=""` | Replace this element's contents with the string's `text` | +| `data-i18n-alt=""` | Replace this image's `alt` with the string's text, tags removed | +| `data-nt` | Code or other text that is deliberately not translated | + +`assets/deck-i18n.js` does the swap (`DeckI18n.apply(table, lang)`) and rebuilds +each translation from the allowed tags only, so a table cannot inject markup. +`deck.html?d=&lang=de` and the standalone file's `?lang=de` both use it. + +Longer text is expected: after a swap, any box whose text no longer fits is +shrunk (down to 60%) so it does not run over its neighbours. Add `&review` to the +address to outline boxes that still overflow. + +## Rules for consumers + +1. Never edit `strings.json` by hand. Fix the PPTX and re-run the converter. +2. Validate before accepting a translation: same ID set (extras are errors), + matching tags and `href`s, non-empty `text`. +3. Text inside images, and charts rendered as images, is not in the table and is + not translated. + +See `docs/examples/` for a small matching pair. diff --git a/index.html b/index.html index b521e6e..7008654 100644 --- a/index.html +++ b/index.html @@ -33,6 +33,31 @@ font-size: 1.05rem; } + header.top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; + } + + .convert-btn { + display: inline-block; + padding: 10px 18px; + border-radius: 8px; + background: var(--brand-accent); + color: #10131a; + font-weight: 600; + font-size: 0.95rem; + text-decoration: none; + white-space: nowrap; + margin-top: 6px; + } + + .convert-btn:hover { + filter: brightness(1.1); + } + .grid { display: grid; gap: 16px; @@ -150,14 +175,17 @@ diff --git a/tests/pptx-html.test.mjs b/tests/pptx-html.test.mjs new file mode 100644 index 0000000..70bd257 --- /dev/null +++ b/tests/pptx-html.test.mjs @@ -0,0 +1,110 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseXml, child, kids, path } from '../tools/xml-lite.mjs'; +import { listZipEntries } from '../tools/pptx-core.mjs'; +import { makeZip } from '../tools/zip-tools.mjs'; +import { pptxToHtml } from '../tools/pptx-html.mjs'; +import { presetGeometry } from '../tools/pptx-geom.mjs'; + +const NS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"'; +const enc = new TextEncoder(); + +/** Builds a minimal but valid .pptx package as the Map pptxToHtml expects. */ +async function buildPptx(parts) { + const blob = makeZip(Object.entries(parts).map(([name, text]) => ({ name, bytes: enc.encode(text) }))); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const files = new Map(); + for (const entry of listZipEntries(bytes)) { + files.set(entry.name, bytes.subarray(entry.dataStart, entry.dataStart + entry.compSize)); + } + return files; +} + +const rels = (items) => `${items.map(([id, type, target, ext]) => ``).join('')}`; + +const slide = (shapes) => `${shapes}`; + +const textBox = (id, x, y, cx, cy, paragraphs, hidden = false) => `${paragraphs}`; + +const para = (...runs) => `${runs.join('')}`; +const run = (text, attrs = '', inner = '') => `${inner}${text}`; + +async function sample() { + const shapes = [ + textBox(2, 914400, 914400, 4572000, 914400, para(run('Hello '), run('bold & <tags>', 'b="1"'), run(' end'))), + textBox(3, 914400, 2286000, 4572000, 914400, para(run('x = 1', '', ''))), + textBox(4, 914400, 3200400, 4572000, 457200, para(run('See '), run('docs', '', ''))), + `${textBox(6, 0, 0, 1000000, 500000, para(run('Rotated')))}`, + textBox(7, 0, 0, 100, 100, para(run('Hidden text')), true), + ].join(''); + return buildPptx({ + 'ppt/presentation.xml': ``, + 'ppt/_rels/presentation.xml.rels': rels([['rId1', 'slide', 'slides/slide1.xml']]), + 'ppt/slides/slide1.xml': slide(shapes), + 'ppt/slides/_rels/slide1.xml.rels': rels([['rId9', 'hyperlink', 'https://example.com/docs', true], ['rId8', 'notesSlide', '../notesSlides/notesSlide1.xml']]), + 'ppt/notesSlides/notesSlide1.xml': `${para(run('Say this first.'))}`, + }); +} + +test('xml-lite keeps nesting, attributes and entities', () => { + const root = parseXml(' hi <there> '); + assert.equal(root.name, 'a:p'); + assert.equal(root.attrs.x, '1 & 2'); + assert.equal(path(root, 'a:r', 'a:t').text, ' hi '); + assert.equal(kids(root, 'a:br').length, 1); + assert.equal(child(root, 'a:missing'), null); +}); + +test('every paragraph becomes a string with a stable id, in document order', async () => { + const { strings, warnings } = pptxToHtml(await sample()); + assert.deepEqual(strings.strings.map((s) => s.id), ['s1.sh2.p1', 's1.sh4.p1', 's1.sh6.p1', 's1.notes.p1']); + assert.equal(strings.version, 1); + assert.equal(strings.sourceLang, 'en-US'); + assert.deepEqual(warnings, []); +}); + +test('text is kept verbatim and escaped, with only contract tags inline', async () => { + const { strings } = pptxToHtml(await sample()); + const byId = Object.fromEntries(strings.strings.map((s) => [s.id, s])); + assert.equal(byId['s1.sh2.p1'].text, 'Hello bold & <tags> end'); + assert.equal(byId['s1.sh4.p1'].text, 'See docs'); + assert.equal(byId['s1.notes.p1'].text, 'Say this first.'); + assert.equal(byId['s1.notes.p1'].kind, 'notes'); +}); + +test('monospace paragraphs and hidden shapes stay out of the table', async () => { + const { html, strings, stats } = pptxToHtml(await sample()); + assert.ok(!strings.strings.some((s) => s.text.includes('x = 1'))); + assert.ok(html.includes('x = 1'), 'code is still in the slide'); + assert.ok(html.includes('data-nt'), 'code paragraphs are marked no-translate'); + assert.ok(!html.includes('Hidden text')); + assert.equal(stats.codeParagraphs, 1); +}); + +test('every translatable paragraph carries data-i18n matching the table', async () => { + const { html, strings } = pptxToHtml(await sample()); + for (const s of strings.strings) assert.ok(html.includes(`data-i18n="${s.id}"`), s.id); +}); + +test('group rotation reaches the shapes inside it', async () => { + const { html } = pptxToHtml(await sample()); + assert.match(html, /data-id="6"[^>]*transform:rotate\(90deg\)/); +}); + +test('slide canvas follows the deck size', async () => { + const { html, deck } = pptxToHtml(await sample()); + assert.deepEqual(deck.canvas, { width: 1280, height: 720 }); + assert.match(html, /class="pptx-canvas" style="[^"]*width:1280px;height:720px/); +}); + +test('preset geometry returns unit paths and falls back to null when unknown', () => { + const arrow = presetGeometry('rightArrow', {}, 200, 100); + assert.equal(arrow.kind, 'path'); + assert.ok(arrow.d.startsWith('M')); + assert.equal(presetGeometry('roundRect', {}, 200, 100).kind, 'css'); + assert.equal(presetGeometry('noSuchShape', {}, 200, 100), null); +}); + +test('rejects files that are not a pptx', () => { + assert.throws(() => pptxToHtml(new Map()), /Is this really a \.pptx/); +}); diff --git a/tools/build-manifest.mjs b/tools/build-manifest.mjs index 20d6019..5d218e1 100644 --- a/tools/build-manifest.mjs +++ b/tools/build-manifest.mjs @@ -64,23 +64,31 @@ async function buildManifest() { } const deckDir = join(DECKS_DIR, slug); + const hasHtml = await exists(join(deckDir, 'slides.html')); const slidesPath = join(deckDir, 'slides.md'); - if (!(await exists(slidesPath))) { - warnings.push(`skipped "${slug}": no slides.md`); + if (!hasHtml && !(await exists(slidesPath))) { + warnings.push(`skipped "${slug}": no slides.md or slides.html`); continue; } - const slides = await readFile(slidesPath, 'utf8'); const meta = await readMeta(deckDir, slug); + const title = meta.title || (hasHtml ? slug : titleFromSlides(await readFile(slidesPath, 'utf8'), slug)); + + // Translations sit next to slides.html as strings..json; the deck page offers them in a picker. + const languages = hasHtml + ? (await readdir(deckDir)).map((f) => /^strings\.([A-Za-z0-9-]+)\.json$/.exec(f)?.[1]).filter(Boolean).sort() + : []; decks.push({ slug, - title: meta.title || titleFromSlides(slides, slug), + title, description: meta.description || '', author: meta.author || '', date: meta.date || '', tags: Array.isArray(meta.tags) ? meta.tags : [], theme: meta.theme || 'night', + ...(hasHtml ? { format: 'html' } : {}), + ...(languages.length ? { languages } : {}), }); } diff --git a/tools/build-site.mjs b/tools/build-site.mjs index 5517b21..acc6019 100644 --- a/tools/build-site.mjs +++ b/tools/build-site.mjs @@ -19,6 +19,7 @@ import { readFile, writeFile, mkdir, rm, readdir, stat } from 'node:fs/promises'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { assembleStandaloneHtml } from './standalone-html.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const DIST = join(ROOT, 'dist'); @@ -277,6 +278,32 @@ ${usesMermaid ? ` mermaid.initialize({ startOnLoad: false, theme: 'dark' `; } +/** + * Standalone build for decks imported from PowerPoint (format "html"): the + * slides are already HTML, so they go in as-is with pictures inlined. Any + * strings..json files ride along, and ?lang= picks one. + */ +async function buildStandaloneHtml(deck, meta, slidesHtml, tables, images, sharedTheme) { + const [reveal, theme, revealJs, pptx, i18n] = await Promise.all([ + fetchAsset(asset.revealCss), + fetchAsset(asset.theme(deck.theme || 'white')), + fetchAsset(asset.revealJs), + readFile(join(ROOT, 'assets/pptx-slide.css'), 'utf8'), + readFile(join(ROOT, 'assets/deck-i18n.js'), 'utf8'), + ]); + return assembleStandaloneHtml({ + title: deck.title || deck.slug, + source: `decks/${deck.slug}/slides.html by tools/build-site.mjs`, + lang: meta.lang, + canvas: meta.canvas, + slidesHtml, + images, + tables, + css: { reveal, theme, shared: sharedTheme, pptx }, + js: { reveal: revealJs, i18n }, + }); +} + async function main() { const manifest = JSON.parse(await readFile(join(ROOT, 'decks.json'), 'utf8')); const decks = manifest.decks ?? []; @@ -315,7 +342,8 @@ async function main() { await copyInto('index.html'); await copyInto('decks.json'); await copyInto('assets/theme.css'); - + await copyInto('assets/pptx-slide.css'); + await copyInto('assets/deck-i18n.js'); const deckHtml = await readFile(join(ROOT, 'deck.html'), 'utf8'); const rewritten = deckHtml.replace( /window\.DECK_ASSET_BASE = '[^']*';/, @@ -333,6 +361,16 @@ async function main() { if (importRewritten === importHtml) throw new Error('Could not rewrite DECK_ASSET_BASE in import.html'); await writeFile(join(DIST, 'import.html'), importRewritten, 'utf8'); await copyInto('tools/pptx-core.mjs'); + for (const file of ['xml-lite', 'pptx-style', 'pptx-geom', 'pptx-html', 'zip-tools', 'standalone-html']) { + await copyInto(`tools/${file}.mjs`); + } + const convertHtml = await readFile(join(ROOT, 'convert.html'), 'utf8'); + const convertRewritten = convertHtml.replace( + /window\.DECK_ASSET_BASE = '[^']*';/, + "window.DECK_ASSET_BASE = './vendor';", + ); + if (convertRewritten === convertHtml) throw new Error('Could not rewrite DECK_ASSET_BASE in convert.html'); + await writeFile(join(DIST, 'convert.html'), convertRewritten, 'utf8'); // 3. Copy each deck's sources, and build its standalone single file. const sharedTheme = await readFile(join(ROOT, 'assets/theme.css'), 'utf8'); @@ -340,6 +378,36 @@ async function main() { for (const deck of decks) { const deckDir = join('decks', deck.slug); + const posixDir = deckDir.replace(/\\/g, '/'); + + if (deck.format === 'html') { + const slidesHtml = await readFile(join(ROOT, deckDir, 'slides.html'), 'utf8'); + const meta = JSON.parse(await readFile(join(ROOT, deckDir, 'deck.json'), 'utf8')); + + // Everything in the folder ships: slides.html, string tables, images. + const assets = await deckAssetFiles(deckDir); + for (const file of assets) await copyBinaryInto(`${posixDir}/${file}`); + await copyInto(`${posixDir}/deck.json`); + + const tables = {}; + for (const file of assets) { + const m = /^strings\.([A-Za-z0-9-]+)\.json$/.exec(file); + if (m) tables[m[1]] = JSON.parse(await readFile(join(ROOT, deckDir, file), 'utf8')); + } + + const images = {}; + for (const file of assets) { + const m = /^images\/([^/]+)$/.exec(file); + const mime = m && MIME_BY_EXT[m[1].slice(m[1].lastIndexOf('.')).toLowerCase()]; + if (mime) images[m[1]] = `data:${mime};base64,${(await readFile(join(ROOT, deckDir, file))).toString('base64')}`; + } + + const html = await buildStandaloneHtml(deck, meta, slidesHtml, tables, images, sharedTheme); + await writeFile(join(DIST, 'standalone', `${deck.slug}.html`), html, 'utf8'); + console.log(`standalone/${deck.slug}.html ${(Buffer.byteLength(html) / 1024 / 1024).toFixed(1)} MB (html deck, ${Object.keys(tables).length} translation(s))`); + continue; + } + const slides = await readFile(join(ROOT, deckDir, 'slides.md'), 'utf8'); await copyInto(join(deckDir, 'slides.md').replace(/\\/g, '/')); @@ -385,9 +453,13 @@ async function main() { ' Everything is vendored, so it needs no internet access.', '', 'import.html', - ' Convert a PowerPoint file into a deck in the browser. Also needs to be', + ' Convert a PowerPoint file into a Markdown deck in the browser. Also needs to be', ' served rather than double-clicked. The file never leaves your machine.', '', + 'convert.html', + ' Convert a PowerPoint file into localizable HTML (positioned slides plus a', + ' string table), preview it, and download a single HTML file. Also needs a server.', + '', 'Arrow keys navigate, S opens speaker notes (served site only),', 'Esc shows the slide overview, ? lists every shortcut.', '', diff --git a/tools/llm-provider.mjs b/tools/llm-provider.mjs new file mode 100644 index 0000000..ae84d9a --- /dev/null +++ b/tools/llm-provider.mjs @@ -0,0 +1,249 @@ +// LLM provider interface for tools/translate.mjs. +// +// A provider is any object shaped like: +// { name: string, complete({ messages, signal? }) => Promise } +// where messages are OpenAI-style { role: 'system' | 'user' | 'assistant', content }. +// Providers throw ProviderError; `fatal` means retrying or continuing is pointless +// (bad credentials, unknown model, ...), so the CLI aborts instead of skipping strings. + +import { spawn } from 'node:child_process'; + +export class ProviderError extends Error { + constructor(message, { status, fatal = false } = {}) { + super(message); + this.name = 'ProviderError'; + this.status = status; + this.fatal = fatal; + } +} + +export const PROVIDERS = ['copilot', 'openai', 'mock']; + +const DEFAULT_BASE_URL = 'https://api.openai.com/v1'; +const REQUEST_TIMEOUT_MS = 120_000; +const COPILOT_TIMEOUT_MS = 300_000; +const MAX_RATE_LIMIT_RETRIES = 5; +const MAX_TRANSIENT_RETRIES = 3; +const MAX_WAIT_MS = 120_000; + +const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function createProvider(name, options = {}) { + switch (name) { + case 'copilot': + return createCopilotProvider(options); + case 'openai': + return createOpenAICompatibleProvider(options); + case 'mock': + return createMockProvider(); + default: + throw new Error(`Unknown provider "${name}". Available: ${PROVIDERS.join(', ')}`); + } +} + +// Uses the GitHub Copilot CLI in non-interactive mode, so translation runs on the +// models included with your Copilot subscription: no API key, nothing to configure +// beyond being signed in to `copilot`. Tools, MCP servers and custom instructions are +// all turned off, so the model can only answer; it cannot touch files or the shell. +// The prompt goes in on stdin (no command-line length limit, nothing in the process list). +// --model optional; otherwise the CLI's default model +// LLM_REASONING_EFFORT low (default) | medium | high ... +// COPILOT_BIN path to the copilot executable if it is not on PATH +export function flattenMessages(messages) { + const parts = messages.map(({ role, content }) => `[${role}]\n${content}`); + parts.push('Reply with only the JSON object, no commentary.'); + return parts.join('\n\n'); +} + +function createCopilotProvider(options) { + const env = options.env ?? process.env; + const spawnImpl = options.spawn ?? spawn; + const command = env.COPILOT_BIN || 'copilot'; + const model = options.model || env.LLM_MODEL || ''; + const effort = env.LLM_REASONING_EFFORT || 'low'; + + const args = [ + '-s', '--no-ask-user', '--no-custom-instructions', '--disable-builtin-mcps', '--no-color', + '--available-tools', '', '--reasoning-effort', effort, + ]; + if (model) args.push('--model', model); + + return { + name: 'copilot', + complete({ messages, signal }) { + return new Promise((resolve, reject) => { + let child; + try { + child = spawnImpl(command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + } catch (err) { + reject(new ProviderError(`Could not start "${command}": ${err.message}`, { fatal: true })); + return; + } + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill(), COPILOT_TIMEOUT_MS); + signal?.addEventListener('abort', () => child.kill(), { once: true }); + + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (err) => { + clearTimeout(timer); + const missing = err.code === 'ENOENT'; + reject(new ProviderError( + missing ? `GitHub Copilot CLI not found ("${command}"). Install it and run "copilot" once to sign in, or set COPILOT_BIN.` : `Copilot CLI failed: ${err.message}`, + { fatal: missing }, + )); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0 && stdout.trim()) { + resolve(stdout.trim()); + return; + } + const detail = (stderr || stdout).trim().slice(0, 500); + const auth = /log ?in|auth|token|sign ?in|subscription|not (?:available|enabled)|unknown model|invalid model/i.test(detail); + reject(new ProviderError(`Copilot CLI exited with code ${code}: ${detail || 'no output'}`, { fatal: auth })); + }); + child.stdin.on('error', () => {}); + child.stdin.end(flattenMessages(messages)); + }); + }, + }; +} + +// Parses a Retry-After header (delta-seconds or HTTP-date) into milliseconds. +export function parseRetryAfter(value, now = Date.now()) { + if (value == null || value === '') return null; + const seconds = Number(value); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(value); + return Number.isNaN(date) ? null : Math.max(0, date - now); +} + +function isLocalhost(hostname) { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; +} + +// Any OpenAI-compatible /chat/completions endpoint: OpenAI, Azure OpenAI / Foundry +// (…/openai/v1 with LLM_AUTH_HEADER=api-key), Ollama, vLLM, LiteLLM, ... +// Config comes from options first, then environment: +// baseUrl LLM_BASE_URL | OPENAI_BASE_URL (default https://api.openai.com/v1) +// apiKey LLM_API_KEY | OPENAI_API_KEY (environment only, never a CLI flag) +// model LLM_MODEL (required) +// LLM_AUTH_HEADER "bearer" (default) or "api-key" (Azure) +// LLM_JSON_MODE "off" to omit response_format (for hosts that reject it) +function createOpenAICompatibleProvider(options) { + const env = options.env ?? process.env; + const fetchImpl = options.fetch ?? globalThis.fetch; + const sleep = options.sleep ?? defaultSleep; + + const baseUrl = (options.baseUrl || env.LLM_BASE_URL || env.OPENAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); + const apiKey = env.LLM_API_KEY || env.OPENAI_API_KEY || ''; + const model = options.model || env.LLM_MODEL || ''; + const authHeader = (env.LLM_AUTH_HEADER || 'bearer').toLowerCase(); + const jsonMode = (env.LLM_JSON_MODE || 'on').toLowerCase() !== 'off'; + + if (!apiKey) { + throw new ProviderError('No API key. Set LLM_API_KEY (or OPENAI_API_KEY) in the environment.', { fatal: true }); + } + if (!model) { + throw new ProviderError('No model. Pass --model or set LLM_MODEL.', { fatal: true }); + } + if (!['bearer', 'api-key'].includes(authHeader)) { + throw new ProviderError('LLM_AUTH_HEADER must be "bearer" or "api-key".', { fatal: true }); + } + let url; + try { + url = new URL(`${baseUrl}/chat/completions`); + } catch { + throw new ProviderError(`Invalid base URL: ${baseUrl}`, { fatal: true }); + } + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLocalhost(url.hostname))) { + throw new ProviderError('Base URL must be https (http is only allowed for localhost); refusing to send the API key in clear text.', { fatal: true }); + } + + const redact = (text) => (apiKey ? String(text).split(apiKey).join('[redacted]') : String(text)); + + const headers = { 'content-type': 'application/json' }; + if (authHeader === 'api-key') headers['api-key'] = apiKey; + else headers.authorization = `Bearer ${apiKey}`; + + async function post(body, signal) { + let rateLimited = 0; + let transient = 0; + for (;;) { + let res; + try { + res = await fetchImpl(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + if (signal?.aborted) throw err; + if (++transient > MAX_TRANSIENT_RETRIES) { + throw new ProviderError(`Network error: ${redact(err.message)}`); + } + await sleep(Math.min(1000 * 2 ** transient, MAX_WAIT_MS)); + continue; + } + + if (res.ok) return res.json(); + + const detail = redact((await res.text().catch(() => '')).slice(0, 500)); + if (res.status === 429) { + if (++rateLimited > MAX_RATE_LIMIT_RETRIES) { + throw new ProviderError(`Rate limited (429) after ${MAX_RATE_LIMIT_RETRIES} retries`, { status: 429 }); + } + const wait = parseRetryAfter(res.headers.get('retry-after')) ?? 1000 * 2 ** rateLimited; + await sleep(Math.min(wait, MAX_WAIT_MS)); + continue; + } + if (res.status >= 500) { + if (++transient > MAX_TRANSIENT_RETRIES) { + throw new ProviderError(`HTTP ${res.status}: ${detail}`, { status: res.status }); + } + await sleep(Math.min(1000 * 2 ** transient, MAX_WAIT_MS)); + continue; + } + const fatal = [401, 403, 404].includes(res.status); + throw new ProviderError(`HTTP ${res.status}: ${detail}`, { status: res.status, fatal }); + } + } + + return { + name: 'openai', + async complete({ messages, signal }) { + const body = { model, messages }; + if (jsonMode) body.response_format = { type: 'json_object' }; + const data = await post(body, signal); + const choice = data?.choices?.[0]; + if (!choice || typeof choice.message?.content !== 'string') { + throw new ProviderError('Unexpected response shape from provider (no choices[0].message.content)'); + } + if (choice.finish_reason === 'length') { + throw new ProviderError('Response was truncated (finish_reason=length); try a smaller --batch-size'); + } + return choice.message.content; + }, + }; +} + +// Deterministic offline fake: wraps each string as "[: ]", which keeps +// every tag and href intact. Understands the payload built by translate-core.mjs. +function createMockProvider() { + return { + name: 'mock', + async complete({ messages }) { + const last = messages[messages.length - 1]; + // The payload is always the last line of the last message, as compact JSON. + const payload = JSON.parse(last.content.slice(last.content.lastIndexOf('\n') + 1)); + const translations = {}; + for (const item of payload.strings) { + translations[item.id] = `[${payload.targetLang}: ${item.text}]`; + } + return JSON.stringify({ translations }); + }, + }; +} diff --git a/tools/pptx-geom.mjs b/tools/pptx-geom.mjs new file mode 100644 index 0000000..c7f8b41 --- /dev/null +++ b/tools/pptx-geom.mjs @@ -0,0 +1,269 @@ +/** + * Preset geometry for the PPTX to HTML converter. + * + * Shapes come back in one of two forms: + * { kind: 'css', radius } a rectangle or ellipse the browser can draw itself + * { kind: 'path', d, open } a path in unit coordinates (0..1), used as a + * clip-path for the fill and as the outline + * + * Presets are approximations of the ECMA-376 definitions. They cover what real + * decks use most; anything else returns null and the caller falls back to a + * plain rectangle and says so in the import report. + */ + +const KAPPA = 0.5522847498; + +/** Collects path commands in pixel space, then normalizes to the unit square. */ +class Path { + constructor() { this.cmds = []; } + + M(x, y) { this.cmds.push(['M', x, y]); return this; } + + L(x, y) { this.cmds.push(['L', x, y]); return this; } + + Q(cx, cy, x, y) { this.cmds.push(['Q', cx, cy, x, y]); return this; } + + C(a, b, c, d, x, y) { this.cmds.push(['C', a, b, c, d, x, y]); return this; } + + Z() { this.cmds.push(['Z']); return this; } + + poly(points, close = true) { + points.forEach(([x, y], i) => (i ? this.L(x, y) : this.M(x, y))); + return close ? this.Z() : this; + } + + /** Normalized to 0..1; `w`/`h` are the shape size in px. */ + toUnit(w, h) { + const f = (n) => Math.round(n * 1e4) / 1e4; + return this.cmds.map(([c, ...n]) => c + n.map((v, i) => f(v / (i % 2 === 0 ? w : h))).join(' ')).join(' '); + } + + /** Absolute pixel path string. */ + toPx() { + const f = (n) => Math.round(n * 100) / 100; + return this.cmds.map(([c, ...n]) => c + n.map(f).join(' ')).join(' '); + } +} + +const gd = (adj, name, fallback) => (adj[name] != null ? adj[name] : fallback); + +/** Read guides into { adj: 50000, ... }. */ +export function readAdjust(prstGeom) { + const out = {}; + const list = prstGeom?.children.find((c) => c.name === 'a:avLst'); + for (const g of list?.children ?? []) { + const m = /val\s+(-?\d+)/.exec(g.attrs.fmla ?? ''); + if (m && g.attrs.name) out[g.attrs.name] = Number(m[1]); + } + return out; +} + +export function presetGeometry(prst, adj, w, h) { + const ss = Math.min(w, h); + const hw = w / 2; + const hh = h / 2; + const P = () => new Path(); + const unit = (p, open = false) => ({ kind: 'path', d: p.toUnit(w, h), open }); + + switch (prst) { + case 'rect': + case 'flowChartProcess': + case 'flowChartPredefinedProcess': + case 'flowChartInternalStorage': + case 'flowChartDocument': + case 'flowChartMultidocument': + case 'flowChartMagneticDisk': + case 'flowChartPunchedCard': + return { kind: 'css', radius: null }; + case 'roundRect': + return { kind: 'css', radius: (ss * gd(adj, 'adj', 16667)) / 100000 }; + case 'flowChartAlternateProcess': + return { kind: 'css', radius: ss / 6 }; + case 'flowChartTerminator': + return { kind: 'css', radius: ss / 2 }; + case 'ellipse': + case 'flowChartConnector': + return { kind: 'css', radius: '50%' }; + + case 'triangle': { + const x = (w * gd(adj, 'adj', 50000)) / 100000; + return unit(P().poly([[x, 0], [w, h], [0, h]])); + } + case 'rtTriangle': return unit(P().poly([[0, 0], [0, h], [w, h]])); + case 'diamond': + case 'flowChartDecision': + return unit(P().poly([[hw, 0], [w, hh], [hw, h], [0, hh]])); + case 'parallelogram': + case 'flowChartInputOutput': { + const x = (ss * gd(adj, 'adj', 25000)) / 100000; + return unit(P().poly([[0, h], [x, 0], [w, 0], [w - x, h]])); + } + case 'trapezoid': { + const x = (ss * gd(adj, 'adj', 25000)) / 100000; + return unit(P().poly([[0, h], [x, 0], [w - x, 0], [w, h]])); + } + case 'homePlate': { + const dx = (ss * gd(adj, 'adj', 50000)) / 100000; + return unit(P().poly([[0, 0], [w - dx, 0], [w, hh], [w - dx, h], [0, h]])); + } + case 'chevron': { + const dx = (ss * gd(adj, 'adj', 50000)) / 100000; + return unit(P().poly([[0, 0], [w - dx, 0], [w, hh], [w - dx, h], [0, h], [dx, hh]])); + } + case 'hexagon': { + const dx = (ss * gd(adj, 'adj', 25000)) / 100000; + return unit(P().poly([[0, hh], [dx, 0], [w - dx, 0], [w, hh], [w - dx, h], [dx, h]])); + } + case 'octagon': { + const d = (ss * gd(adj, 'adj', 29289)) / 100000; + return unit(P().poly([[d, 0], [w - d, 0], [w, d], [w, h - d], [w - d, h], [d, h], [0, h - d], [0, d]])); + } + case 'plus': { + const d = (ss * gd(adj, 'adj', 25000)) / 100000; + return unit(P().poly([[d, 0], [w - d, 0], [w - d, d], [w, d], [w, h - d], [w - d, h - d], [w - d, h], [d, h], [d, h - d], [0, h - d], [0, d], [d, d]])); + } + + case 'rightArrow': { + const dx = (ss * gd(adj, 'adj2', 50000)) / 100000; + const dy = (h * gd(adj, 'adj1', 50000)) / 200000; + const x1 = w - dx; + return unit(P().poly([[0, hh - dy], [x1, hh - dy], [x1, 0], [w, hh], [x1, h], [x1, hh + dy], [0, hh + dy]])); + } + case 'leftArrow': { + const dx = (ss * gd(adj, 'adj2', 50000)) / 100000; + const dy = (h * gd(adj, 'adj1', 50000)) / 200000; + return unit(P().poly([[w, hh - dy], [dx, hh - dy], [dx, 0], [0, hh], [dx, h], [dx, hh + dy], [w, hh + dy]])); + } + case 'upArrow': { + const dy = (ss * gd(adj, 'adj2', 50000)) / 100000; + const dx = (w * gd(adj, 'adj1', 50000)) / 200000; + return unit(P().poly([[hw, 0], [w, dy], [hw + dx, dy], [hw + dx, h], [hw - dx, h], [hw - dx, dy], [0, dy]])); + } + case 'downArrow': { + const dy = (ss * gd(adj, 'adj2', 50000)) / 100000; + const dx = (w * gd(adj, 'adj1', 50000)) / 200000; + return unit(P().poly([[hw - dx, 0], [hw + dx, 0], [hw + dx, h - dy], [w, h - dy], [hw, h], [0, h - dy], [hw - dx, h - dy]])); + } + case 'leftRightArrow': { + const dx = (ss * gd(adj, 'adj2', 50000)) / 100000; + const dy = (h * gd(adj, 'adj1', 50000)) / 200000; + return unit(P().poly([[0, hh], [dx, 0], [dx, hh - dy], [w - dx, hh - dy], [w - dx, 0], [w, hh], [w - dx, h], [w - dx, hh + dy], [dx, hh + dy], [dx, h]])); + } + + case 'flowChartOffpageConnector': return unit(P().poly([[0, 0], [w, 0], [w, h * 0.8], [hw, h], [0, h * 0.8]])); + case 'flowChartDelay': + return unit(P().M(0, 0).L(hw, 0).C(hw + hw * KAPPA, 0, w, hh - hh * KAPPA, w, hh) + .C(w, hh + hh * KAPPA, hw + hw * KAPPA, h, hw, h).L(0, h).Z()); + + case 'round1Rect': { + const r = (ss * gd(adj, 'adj', 16667)) / 100000; + return unit(P().M(0, 0).L(w - r, 0).Q(w, 0, w, r).L(w, h).L(0, h).Z()); + } + case 'round2SameRect': { + const r1 = (ss * gd(adj, 'adj1', 16667)) / 100000; + const r2 = (ss * gd(adj, 'adj2', 0)) / 100000; + return unit(P().M(r1, 0).L(w - r1, 0).Q(w, 0, w, r1).L(w, h - r2).Q(w, h, w - r2, h) + .L(r2, h).Q(0, h, 0, h - r2).L(0, r1).Q(0, 0, r1, 0).Z()); + } + case 'snip1Rect': { + const d = (ss * gd(adj, 'adj', 16667)) / 100000; + return unit(P().poly([[0, 0], [w - d, 0], [w, d], [w, h], [0, h]])); + } + case 'snip2SameRect': { + const d1 = (ss * gd(adj, 'adj1', 16667)) / 100000; + const d2 = (ss * gd(adj, 'adj2', 0)) / 100000; + return unit(P().poly([[d1, 0], [w - d1, 0], [w, d1], [w, h - d2], [w - d2, h], [d2, h], [0, h - d2], [0, d1]])); + } + case 'cube': { + const d = (ss * gd(adj, 'adj', 25000)) / 100000; + return unit(P().poly([[0, d], [d, 0], [w, 0], [w, h - d], [w - d, h], [0, h]])); + } + case 'can': { + const d = (h * gd(adj, 'adj', 25000)) / 200000; + return unit(P().M(0, d).Q(0, 0, hw, 0).Q(w, 0, w, d).L(w, h - d).Q(w, h, hw, h).Q(0, h, 0, h - d).Z()); + } + case 'star4': { + const k = (gd(adj, 'adj', 12500) / 50000) * 0.7071; + const ix = hw * k; + const iy = hh * k; + return unit(P().poly([[0, hh], [hw - ix, hh - iy], [hw, 0], [hw + ix, hh - iy], [w, hh], [hw + ix, hh + iy], [hw, h], [hw - ix, hh + iy]])); + } + + case 'rightBrace': + case 'leftBrace': { + const r = Math.min((ss * gd(adj, 'adj1', 8333)) / 100000, hh); + const ym = (h * gd(adj, 'adj2', 50000)) / 100000; + const mx = (x) => (prst === 'leftBrace' ? w - x : x); + return unit(P().M(mx(0), 0).Q(mx(hw), 0, mx(hw), r).L(mx(hw), ym - r).Q(mx(hw), ym, mx(w), ym) + .Q(mx(hw), ym, mx(hw), ym + r).L(mx(hw), h - r).Q(mx(hw), h, mx(0), h), true); + } + case 'leftBracket': return unit(P().poly([[w, 0], [0, 0], [0, h], [w, h]], false), true); + case 'rightBracket': return unit(P().poly([[0, 0], [w, 0], [w, h], [0, h]], false), true); + + case 'wedgeRectCallout': + case 'wedgeRoundRectCallout': { + const xt = hw + (w * gd(adj, 'adj1', -20833)) / 100000; + const yt = hh + (h * gd(adj, 'adj2', 62500)) / 100000; + const p = P().M(0, 0); + const vertical = Math.abs((yt - hh) / h) >= Math.abs((xt - hw) / w); + const left = xt < hw; + const above = yt < hh; + const x1 = left ? w / 6 : (w * 7) / 12; + const x2 = left ? (w * 5) / 12 : (w * 5) / 6; + const y1 = above ? h / 6 : (h * 7) / 12; + const y2 = above ? (h * 5) / 12 : (h * 5) / 6; + if (vertical && above) p.L(x1, 0).L(xt, yt).L(x2, 0); + p.L(w, 0); + if (!vertical && !left) p.L(w, y1).L(xt, yt).L(w, y2); + p.L(w, h); + if (vertical && !above) p.L(x2, h).L(xt, yt).L(x1, h); + p.L(0, h); + if (!vertical && left) p.L(0, y2).L(xt, yt).L(0, y1); + return unit(p.Z()); + } + default: + return null; + } +} + +/** + * Connector (and plain line) path in pixel space for a w x h box, drawn from the + * top-left to the bottom-right. The caller mirrors it for flipH/flipV. + */ +export function connectorPath(prst, adj, w, h) { + const a1 = gd(adj, 'adj1', 50000) / 100000; + const a2 = gd(adj, 'adj2', 50000) / 100000; + const a3 = gd(adj, 'adj3', 50000) / 100000; + const p = new Path(); + switch (prst) { + case 'bentConnector2': return p.M(0, 0).L(w, 0).L(w, h).toPx(); + case 'bentConnector3': return p.M(0, 0).L(w * a1, 0).L(w * a1, h).L(w, h).toPx(); + case 'bentConnector4': return p.M(0, 0).L(w * a1, 0).L(w * a1, h * a2).L(w, h * a2).L(w, h).toPx(); + case 'bentConnector5': return p.M(0, 0).L(w * a1, 0).L(w * a1, h * a2).L(w * a3, h * a2).L(w * a3, h).L(w, h).toPx(); + case 'curvedConnector2': return p.M(0, 0).C(w / 2, 0, w, h / 2, w, h).toPx(); + case 'curvedConnector3': return p.M(0, 0).C(w * a1, 0, w * a1, h / 2, w * a1, h / 2).C(w * a1, h / 2, w * a1, h, w, h).toPx(); + default: return p.M(0, 0).L(w, h).toPx(); + } +} + +/** Unit-square path for a:custGeom, built from its first drawable path(s). */ +export function customGeometry(custGeom, w, h) { + const list = custGeom.children.find((c) => c.name === 'a:pathLst'); + const parts = []; + let open = true; + for (const path of list?.children ?? []) { + if (path.name !== 'a:path' || path.attrs.fill === 'none' && path.attrs.stroke === '0') continue; + const pw = Number(path.attrs.w) || w; + const ph = Number(path.attrs.h) || h; + const pt = (n) => `${Math.round((Number(n.attrs.x) / pw) * 1e4) / 1e4} ${Math.round((Number(n.attrs.y) / ph) * 1e4) / 1e4}`; + for (const cmd of path.children) { + const pts = cmd.children.filter((c) => c.name === 'a:pt'); + if (cmd.name === 'a:moveTo') parts.push(`M${pt(pts[0])}`); + else if (cmd.name === 'a:lnTo') parts.push(`L${pt(pts[0])}`); + else if (cmd.name === 'a:cubicBezTo') parts.push(`C${pts.map(pt).join(' ')}`); + else if (cmd.name === 'a:quadBezTo') parts.push(`Q${pts.map(pt).join(' ')}`); + else if (cmd.name === 'a:close') { parts.push('Z'); open = false; } + } + } + return parts.length ? { kind: 'path', d: parts.join(' '), open } : null; +} diff --git a/tools/pptx-html.mjs b/tools/pptx-html.mjs new file mode 100644 index 0000000..5b5e00c --- /dev/null +++ b/tools/pptx-html.mjs @@ -0,0 +1,935 @@ +/** + * Converts an inflated .pptx into positioned HTML plus a string table. + * + * pptxToHtml(files, options) -> { html, strings, images, deck, warnings, stats } + * + * Every slide becomes a fixed-size canvas (the deck's own size, 1 CSS px per + * 9525 EMU) with shapes placed absolutely. All text stays live HTML, and each + * paragraph is tagged with data-i18n so it can be swapped for a translation. + * See docs/string-table.md for the contract this module writes. + * + * Like pptx-core.mjs this uses no Node APIs and no dependencies; callers do + * their own decompression and pass in the entries as a Map. + */ +import { parseXml, child, kids, path, descendants, attrOf } from './xml-lite.mjs'; +import { + emuToPx, ptToPx, px, readColor, colorToCss, readTheme, readFill, styleFill, readLine, + readRunProps, readParaProps, mergeProps, levelProps, fontStack, fillFromElement, MONO_FONT, +} from './pptx-style.mjs'; +import { + presetGeometry, connectorPath, customGeometry, readAdjust, +} from './pptx-geom.mjs'; + +const decoder = new TextDecoder('utf-8'); +const num = (v, d = 0) => (v == null || v === '' || Number.isNaN(Number(v)) ? d : Number(v)); +const truthy = (v) => v === '1' || v === 'true'; +const esc = (s) => s.replace(/&/g, '&').replace(//g, '>'); +const escAttr = (s) => esc(s).replace(/"/g, '"'); +const round = (n) => Math.round(n * 100) / 100; + +/** Inline style string from an object, skipping empty values. */ +const style = (o) => Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k}:${v}`).join(';'); + +/* ----------------------------------------------------------- package ---- */ + +function resolvePath(baseDir, target) { + if (target.startsWith('/')) return target.slice(1); + const stack = []; + for (const part of `${baseDir}/${target}`.split('/')) { + if (part === '..') stack.pop(); + else if (part && part !== '.') stack.push(part); + } + return stack.join('/'); +} + +function openPackage(files) { + const xmlCache = new Map(); + const relCache = new Map(); + const text = (name) => (files.has(name) ? decoder.decode(files.get(name)) : ''); + const xml = (name) => { + if (!files.has(name)) return null; + if (!xmlCache.has(name)) xmlCache.set(name, parseXml(text(name))); + return xmlCache.get(name); + }; + const rels = (part) => { + if (relCache.has(part)) return relCache.get(part); + const dir = part.split('/').slice(0, -1).join('/'); + const map = new Map(); + const root = xml(`${dir}/_rels/${part.split('/').pop()}.rels`); + for (const r of kids(root, 'Relationship')) { + const external = r.attrs.TargetMode === 'External'; + map.set(r.attrs.Id, { + type: r.attrs.Type ?? '', + external, + target: external ? r.attrs.Target : resolvePath(dir, r.attrs.Target ?? ''), + }); + } + relCache.set(part, map); + return map; + }; + return { files, text, xml, rels }; +} + +const relOfType = (rels, suffix) => [...rels.values()].find((r) => r.type.endsWith(suffix))?.target ?? null; + +/* ------------------------------------------------------ master/layout ---- */ + +const phType = (t) => (t === 'ctrTitle' ? 'title' : ['subTitle', 'obj', 'body', 'chart', 'tbl', 'media', 'pic'].includes(t) ? 'body' : t); + +function readPh(node) { + const nv = child(node, node.name === 'p:pic' ? 'p:nvPicPr' : 'p:nvSpPr'); + const ph = path(nv, 'p:nvPr', 'p:ph'); + return ph ? { type: ph.attrs.type ?? 'body', idx: ph.attrs.idx ?? null } : null; +} + +function placeholdersOf(tree) { + const out = []; + for (const n of tree?.children ?? []) { + if (n.name !== 'p:sp' && n.name !== 'p:pic') continue; + const ph = readPh(n); + if (ph) out.push({ ...ph, node: n }); + } + return out; +} + +function buildChain(pkg, slidePath, cache) { + const slideRels = pkg.rels(slidePath); + const layoutPath = relOfType(slideRels, '/slideLayout'); + if (cache.has(layoutPath)) return cache.get(layoutPath); + + const layoutXml = layoutPath ? pkg.xml(layoutPath) : null; + const layoutRels = layoutPath ? pkg.rels(layoutPath) : new Map(); + const masterPath = relOfType(layoutRels, '/slideMaster'); + const masterXml = masterPath ? pkg.xml(masterPath) : null; + const masterRels = masterPath ? pkg.rels(masterPath) : new Map(); + const themePath = relOfType(masterRels, '/theme'); + const theme = readTheme(themePath ? pkg.xml(themePath) : null); + + const clrMap = { ...(child(masterXml, 'p:clrMap')?.attrs ?? {}) }; + const override = child(child(layoutXml, 'p:clrMapOvr'), 'a:overrideClrMapping'); + if (override) Object.assign(clrMap, override.attrs); + + const layoutTree = path(layoutXml, 'p:cSld', 'p:spTree'); + const masterTree = path(masterXml, 'p:cSld', 'p:spTree'); + const chain = { + layoutPath, masterPath, layoutXml, masterXml, layoutRels, masterRels, theme, clrMap, + env: { theme, clrMap }, + layoutTree, + masterTree, + layoutPhs: placeholdersOf(layoutTree), + masterPhs: placeholdersOf(masterTree), + txStyles: child(masterXml, 'p:txStyles'), + }; + cache.set(layoutPath, chain); + return chain; +} + +/** The layout and master placeholder a slide placeholder inherits from. */ +function inheritFor(chain, ph) { + if (!ph) return []; + const sameType = (m) => phType(m.type) === phType(ph.type); + const layout = (ph.idx != null ? chain.layoutPhs.find((m) => m.idx === ph.idx) : null) + ?? chain.layoutPhs.find(sameType) ?? null; + const master = chain.masterPhs.find((m) => phType(m.type) === phType(layout?.type ?? ph.type)) ?? null; + return [layout?.node, master?.node].filter(Boolean); +} + +/* ------------------------------------------------------------ helpers ---- */ + +function readXfrm(node) { + const x = node && (node.name === 'a:xfrm' || node.name === 'p:xfrm' ? node : child(node, 'a:xfrm')); + const off = child(x, 'a:off'); + const ext = child(x, 'a:ext'); + if (!x || !off || !ext) return null; + const chOff = child(x, 'a:chOff'); + const chExt = child(x, 'a:chExt'); + return { + x: num(off.attrs.x), y: num(off.attrs.y), cx: num(ext.attrs.cx), cy: num(ext.attrs.cy), + rot: num(x.attrs.rot) / 60000, flipH: truthy(x.attrs.flipH), flipV: truthy(x.attrs.flipV), + chOff: chOff ? { x: num(chOff.attrs.x), y: num(chOff.attrs.y) } : null, + chExt: chExt ? { cx: num(chExt.attrs.cx), cy: num(chExt.attrs.cy) } : null, + }; +} + +/* + * Transforms are 2D affine matrices in EMU: X = a*x + c*y + e, Y = b*x + d*y + f. + * Groups compose one over the other, so nested groups, scaling, flips and + * rotation all fall out of the same arithmetic. + */ +const IDENTITY = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +const mul = (P, Q) => ({ + a: P.a * Q.a + P.c * Q.b, b: P.b * Q.a + P.d * Q.b, + c: P.a * Q.c + P.c * Q.d, d: P.b * Q.c + P.d * Q.d, + e: P.a * Q.e + P.c * Q.f + P.e, f: P.b * Q.e + P.d * Q.f + P.f, +}); + +const scaleX = (T) => Math.hypot(T.a, T.b); +const scaleY = (T) => Math.hypot(T.c, T.d); +const translate = (T, x, y) => mul(T, { a: 1, b: 0, c: 0, d: 1, e: x, f: y }); + +/** Where a shape's box lands on the slide, in px, with its net rotation and flips. */ +function place(xf, T) { + const cx = xf.x + xf.cx / 2; + const cy = xf.y + xf.cy / 2; + const centerX = T.a * cx + T.c * cy + T.e; + const centerY = T.b * cx + T.d * cy + T.f; + const w = emuToPx(xf.cx * scaleX(T)); + const h = emuToPx(xf.cy * scaleY(T)); + const mirrored = T.a * T.d - T.b * T.c < 0; + const turn = mirrored ? Math.atan2(-T.b, -T.a) : Math.atan2(T.b, T.a); + return { + x: emuToPx(centerX) - w / 2, + y: emuToPx(centerY) - h / 2, + w, + h, + rot: xf.rot + (turn * 180) / Math.PI, + flipH: xf.flipH !== mirrored, + flipV: xf.flipV, + }; +} + +function composeGroup(T, xf) { + const sx = xf.chExt?.cx ? xf.cx / xf.chExt.cx : 1; + const sy = xf.chExt?.cy ? xf.cy / xf.chExt.cy : 1; + const into = { a: sx, b: 0, c: 0, d: sy, e: xf.x - sx * (xf.chOff?.x ?? 0), f: xf.y - sy * (xf.chOff?.y ?? 0) }; + const cx = xf.x + xf.cx / 2; + const cy = xf.y + xf.cy / 2; + const fx = xf.flipH ? -1 : 1; + const fy = xf.flipV ? -1 : 1; + const flip = { a: fx, b: 0, c: 0, d: fy, e: cx * (1 - fx), f: cy * (1 - fy) }; + const th = (xf.rot * Math.PI) / 180; + const cos = Math.cos(th); + const sin = Math.sin(th); + const turn = { a: cos, b: sin, c: -sin, d: cos, e: cx - cos * cx + sin * cy, f: cy - sin * cx - cos * cy }; + return mul(T, mul(turn, mul(flip, into))); +} + +const firstOf = (list, fn) => { for (const item of list) { const v = fn(item); if (v != null) return v; } return null; }; + +function rotFlip(box, flip = false) { + const parts = []; + if (box.rot) parts.push(`rotate(${round(box.rot)}deg)`); + if (flip && (box.flipH || box.flipV)) parts.push(`scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})`); + return parts.length ? parts.join(' ') : null; +} + +function warn(S, message) { + const text = `slide ${S.slideNo}: ${message}`; + if (!S.warnings.includes(text)) S.warnings.push(text); +} + +function imageRef(S, zipPath) { + if (!S.images.has(zipPath)) { + let name = zipPath.split('/').pop(); + const taken = new Set(S.images.values()); + let n = 1; + const base = name; + while (taken.has(name)) name = `${++n}-${base}`; + S.images.set(zipPath, name); + } + return `images/${S.images.get(zipPath)}`; +} + +function bgCss(fill, S, part) { + if (!fill) return null; + if (fill.type === 'solid' || fill.type === 'grad') return { background: fill.css }; + if (fill.type === 'pic') { + const target = part.rels.get(fill.relId)?.target; + if (!target || !S.pkg.files.has(target)) return null; + if (/\.(emf|wmf)$/i.test(target)) { warn(S, 'EMF/WMF picture fill not rendered'); return null; } + return { 'background-image': `url(${imageRef(S, target)})`, 'background-size': fill.tile ? 'auto' : '100% 100%', 'background-repeat': fill.tile ? 'repeat' : 'no-repeat' }; + } + return null; +} + +const DASH = { dash: 'dashed', sysDash: 'dashed', lgDash: 'dashed', dashDot: 'dashed', lgDashDot: 'dashed', sysDot: 'dotted', dot: 'dotted', sysDashDot: 'dashed', sysDashDotDot: 'dashed' }; + +/* --------------------------------------------------------------- text ---- */ + +const WINGDINGS = { + '§': '▪', '\uF0A7': '▪', Ø: '➢', '\uF0D8': '➢', ü: '✓', '\uF0FC': '✓', + l: '●', '\uF06C': '●', n: '■', '\uF06E': '■', v: '❖', '\uF076': '❖', q: '❑', u: '◆', '\uF0B7': '•', +}; +const SYMBOLISH = /wingdings|symbol/i; + +function toRoman(n) { + const map = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']]; + let out = ''; + let rest = n; + for (const [v, s] of map) while (rest >= v) { out += s; rest -= v; } + return out; +} + +function autoNumber(n, scheme) { + const alpha = (v) => { let s = ''; let k = v; while (k > 0) { s = String.fromCharCode(97 + ((k - 1) % 26)) + s; k = Math.floor((k - 1) / 26); } return s; }; + const core = /^alpha/.test(scheme) ? alpha(n) : /^roman/.test(scheme) ? toRoman(n) : String(n); + const text = /Uc/.test(scheme) ? core.toUpperCase() : core; + if (/ParenBoth$/.test(scheme)) return `(${text})`; + if (/ParenR$/.test(scheme)) return `${text})`; + return `${text}.`; +} + +/** Resolve a typeface (including +mj-lt/+mn-lt) to a plain name. */ +const faceName = (face, theme) => (face === '+mj-lt' ? theme.major : face === '+mn-lt' ? theme.minor : face); +const isMono = (rp, theme) => MONO_FONT.test(faceName(rp.font, theme) ?? ''); + +function safeHref(S, part, relId) { + const rel = relId ? part.rels.get(relId) : null; + if (!rel?.external) return null; + return /^(https?:|mailto:)/i.test(rel.target) ? rel.target : null; +} + +/** + * One paragraph -> { html, string }. `string` is the inline HTML that goes in + * the table (null for blank or code paragraphs, which are not translated). + */ +function renderParagraph(p, pIndex, tx) { + const { S, part, env } = tx; + const pPr = child(p, 'a:pPr'); + const level = Math.min(8, num(pPr?.attrs.lvl, 0)); + const props = mergeProps(tx.baseProps(level), readParaProps(pPr, env)); + + const runs = []; + for (const c of p.children) { + if (c.name === 'a:br') runs.push({ br: true }); + else if (c.name === 'a:r' || c.name === 'a:fld') { + const t = child(c, 'a:t')?.text ?? ''; + if (t !== '') runs.push({ text: t, rp: { ...props.r, ...readRunProps(child(c, 'a:rPr'), env) } }); + } + } + const textRuns = runs.filter((r) => !r.br); + const plain = textRuns.map((r) => r.text).join(''); + const sizeOf = (rp) => (rp.sz ?? 1800) * tx.fontScale; + + const common = { + 'text-align': { l: 'left', ctr: 'center', r: 'right', just: 'justify' }[props.algn] ?? null, + }; + const lnScale = 1 - tx.lnReduce; + common['line-height'] = props.lnSpc?.pct ? round(props.lnSpc.pct * 1.2 * lnScale) : props.lnSpc?.pt ? px(ptToPx(props.lnSpc.pt)) : lnScale !== 1 ? round(1.2 * lnScale) : null; + const space = (s) => (s?.pct != null ? `${round(s.pct * 1.2)}em` : s?.pt != null ? px(ptToPx(s.pt)) : null); + common['margin-top'] = space(props.spcBef); + common['margin-bottom'] = space(props.spcAft); + + if (!plain.trim()) { + tx.numbering = {}; + const rp = { ...props.r, ...readRunProps(child(p, 'a:endParaRPr'), env) }; + return { html: `

`, string: null }; + } + + const dominant = textRuns.reduce((a, b) => (b.text.length > a.text.length ? b : a), textRuns[0]).rp; + const codeParagraph = textRuns.filter((r) => r.text.trim()).every((r) => isMono(r.rp, env.theme)); + + // A paragraph is bold, italic or underlined as a whole when every run agrees; + // when they differ, the inherited style is the baseline and the exceptions + // become tags. Colors and sizes come from the dominant run, so the table + // stays translator-friendly. + const visible = textRuns.filter((r) => r.text.trim()); + const base = {}; + for (const k of ['b', 'i', 'u']) { + const values = new Set(visible.map((r) => Boolean(r.rp[k]))); + base[k] = values.size === 1 ? [...values][0] : Boolean(props.r?.[k]); + } + + const segs = []; + for (const r of runs) { + if (r.br) { segs.push({ br: true }); continue; } + const tags = []; + const href = safeHref(S, part, r.rp.link); + if (!codeParagraph && isMono(r.rp, env.theme)) tags.push('code'); + if (r.rp.b && !base.b) tags.push('b'); + if (r.rp.i && !base.i) tags.push('i'); + if (r.rp.u && !base.u) tags.push('u'); + if (r.rp.baseline > 0) tags.push('sup'); + else if (r.rp.baseline < 0) tags.push('sub'); + const key = `${tags.join(',')}|${href ?? ''}`; + const last = segs[segs.length - 1]; + if (last && !last.br && last.key === key) last.text += r.text; + else segs.push({ key, tags, href, text: r.text }); + } + const inline = segs.map((s) => { + if (s.br) return '
'; + let out = esc(s.text); + for (const t of [...s.tags].reverse()) out = `<${t}>${out}`; + return s.href ? `${out}` : out; + }).join(''); + + // Bullets and indentation. + let marL = props.marL ?? 0; + let indent = props.indent ?? 0; + const bu = props.bu ?? { type: 'none' }; + let bullet = null; + if (bu.type === 'char') { + const raw = bu.char ?? '•'; + bullet = SYMBOLISH.test(props.buFont ?? '') ? (WINGDINGS[raw] ?? '•') : raw; + } else if (bu.type === 'auto') { + const n = (tx.numbering[level] ?? bu.startAt - 1) + 1; + tx.numbering[level] = n; + bullet = autoNumber(n, bu.scheme); + } + for (const k of Object.keys(tx.numbering)) if (Number(k) > level) delete tx.numbering[k]; + if (bu.type !== 'auto') delete tx.numbering[level]; + if (bullet && indent >= 0) { indent = -285750; marL += 285750; } + + const mono = codeParagraph; + const font = dominant.font ? fontStack(dominant.font, env.theme) : null; + const css = { + ...common, + 'font-size': `calc(var(--fit,1) * ${px(ptToPx(sizeOf(dominant) / 100))})`, + color: dominant.color, + 'font-family': mono ? (font ?? "Consolas, 'Cascadia Mono', monospace") : font, + 'font-weight': base.b ? 700 : null, + 'font-style': base.i ? 'italic' : null, + 'text-decoration': [base.u ? 'underline' : null, dominant.strike ? 'line-through' : null].filter(Boolean).join(' ') || null, + 'text-transform': dominant.cap === 'all' ? 'uppercase' : null, + 'letter-spacing': dominant.spc ? px(ptToPx(dominant.spc / 100)) : null, + 'padding-left': marL ? px(emuToPx(marL)) : null, + 'text-indent': !bullet && indent ? px(emuToPx(indent)) : null, + }; + let attrs = ''; + if (bullet) { + css['--bl'] = px(emuToPx(marL + indent)); + if (props.buColor) css['--buc'] = props.buColor; + if (props.buSize) css['--bus'] = `${round(props.buSize * 100)}%`; + attrs = ` data-bu="${escAttr(bullet)}"`; + } + + let string = null; + let id = ''; + if (!codeParagraph) { + id = `${tx.idPrefix}.p${pIndex}`; + string = { id, kind: tx.kind, text: inline }; + } else S.stats.codeParagraphs += 1; + + const idAttr = string ? ` data-i18n="${id}"` : ' data-nt'; + return { html: `${inline}

`, string }; +} + +/** Merge bodyPr attributes from the placeholder chain (master -> layout -> slide). */ +function bodyPrOf(txBody, inh) { + const own = child(txBody, 'a:bodyPr'); + const chain = [...inh].reverse().map((n) => child(child(n, 'p:txBody'), 'a:bodyPr')).filter(Boolean); + if (own) chain.push(own); + const attrs = Object.assign({}, ...chain.map((b) => b.attrs)); + const fit = [...chain].reverse().map((b) => b.children.find((c) => c.name.startsWith('a:') && /AutoFit|noAutofit/i.test(c.name))).find(Boolean); + return { attrs, fit }; +} + +/** Paragraphs of a text body -> HTML for the .tx container (and string pushes). */ +function renderTextBody(txBody, S, part, opts) { + const { env } = S.chain; + const ph = opts.ph ?? null; + const inh = opts.inh ?? []; + const body = bodyPrOf(txBody, inh); + const fontScale = body.fit?.name === 'a:normAutofit' ? num(body.fit.attrs.fontScale, 100000) / 100000 : 1; + const lnReduce = body.fit?.name === 'a:normAutofit' ? num(body.fit.attrs.lnSpcReduction, 0) / 100000 : 0; + const fontRef = readColor(child(opts.styleNode, 'a:fontRef'), env); + + const baseProps = (level) => { + let p = levelProps(S.pres.defaultTextStyle, level, env); + if (ph) { + const key = phType(ph.type) === 'title' ? 'p:titleStyle' : phType(ph.type) === 'body' ? 'p:bodyStyle' : 'p:otherStyle'; + p = mergeProps(p, levelProps(child(S.chain.txStyles, key), level, env)); + } + if (fontRef) p = mergeProps(p, { r: { color: colorToCss(fontRef) } }); + for (const n of [...inh].reverse()) p = mergeProps(p, levelProps(child(child(n, 'p:txBody'), 'a:lstStyle'), level, env)); + if (opts.extraLevels) p = mergeProps(p, opts.extraLevels(level)); + return mergeProps(p, levelProps(child(txBody, 'a:lstStyle'), level, env)); + }; + + const tx = { S, part, env, baseProps, fontScale, lnReduce, numbering: {}, idPrefix: opts.idPrefix, kind: opts.kind ?? 'text' }; + const paragraphs = kids(txBody, 'a:p'); + const out = []; + paragraphs.forEach((p, i) => { + const { html, string } = renderParagraph(p, i + 1, tx); + out.push(html); + if (string) S.addString(string); + }); + return { html: out.join(''), body, hasText: paragraphs.some((p) => descendants(p, 'a:t').some((t) => t.text.trim())) }; +} + +function txContainerStyle(body, box) { + const a = body.attrs; + const anchor = { t: 'flex-start', ctr: 'center', b: 'flex-end' }[a.anchor] ?? 'flex-start'; + const css = { + padding: [num(a.tIns, 45720), num(a.rIns, 91440), num(a.bIns, 45720), num(a.lIns, 91440)].map((v) => px(emuToPx(v))).join(' '), + 'justify-content': anchor, + 'min-height': px(box.h), + 'white-space': a.wrap === 'none' ? 'pre' : null, + }; + if (a.vert === 'vert' || a.vert === 'eaVert') css['writing-mode'] = 'vertical-rl'; + else if (a.vert === 'vert270') { css['writing-mode'] = 'vertical-rl'; css.transform = 'rotate(180deg)'; } + return style(css); +} + +/* ------------------------------------------------------------- shapes ---- */ + +function geometryOf(holders, box, S) { + const prstGeom = firstOf(holders, (h) => child(h, 'a:prstGeom')); + const cust = firstOf(holders, (h) => child(h, 'a:custGeom')); + if (prstGeom) { + const prst = prstGeom.attrs.prst ?? 'rect'; + const geo = presetGeometry(prst, readAdjust(prstGeom), box.w, box.h); + if (!geo) { S.unknown.set(prst, (S.unknown.get(prst) ?? 0) + 1); return { kind: 'css', radius: null, prst }; } + return { ...geo, prst }; + } + if (cust) { + const geo = customGeometry(cust, box.w, box.h); + if (geo) return { ...geo, prst: 'custGeom' }; + } + return { kind: 'css', radius: null, prst: 'rect' }; +} + +function lineCss(ln) { + if (!ln) return null; + return `${px(Math.max(1, ln.w))} ${DASH[ln.dash] ?? 'solid'} ${ln.color}`; +} + +function clipRef(S, d) { + let id = S.clips.get(d); + if (!id) { + id = `cp${S.slideNo}-${S.clips.size + 1}`; + S.clips.set(d, id); + S.defs.push(``); + } + return id; +} + +function renderSp(sp, S, part, T) { + const cNv = path(sp, 'p:nvSpPr', 'p:cNvPr'); + if (truthy(cNv?.attrs.hidden)) return ''; + const id = cNv?.attrs.id ?? '0'; + const ph = readPh(sp); + if (part.decor && ph) return ''; + const spPr = child(sp, 'p:spPr'); + const styleNode = child(sp, 'p:style'); + const txBody = child(sp, 'p:txBody'); + const inh = part.decor ? [] : inheritFor(S.chain, ph); + const holders = [spPr, ...inh.map((n) => child(n, 'p:spPr'))].filter(Boolean); + const env = S.chain.env; + + const prst = child(spPr, 'a:prstGeom')?.attrs.prst; + const hasText = txBody && descendants(txBody, 'a:t').some((t) => t.text.trim()); + if (prst && /^(line|straightConnector1|bentConnector\d|curvedConnector\d)$/.test(prst) && !hasText) { + return renderConnector(sp, S, part, T); + } + + const xf = firstOf([spPr, ...inh.map((n) => child(n, 'p:spPr'))], readXfrm); + let box; + if (xf) box = place(xf, T); + else { + box = { x: 0, y: 0, w: S.width, h: 0 }; + warn(S, `shape ${id} has no position; placed at the top-left`); + } + + const geo = geometryOf(holders, box, S); + let fill = firstOf(holders, (h) => readFill(h, env)); + if (!fill && styleNode) fill = styleFill(styleNode, env); + const lnHolder = holders.find((h) => child(h, 'a:ln')) ?? spPr; + const ln = readLine(lnHolder, styleNode, env); + + const lineBox = txBody ? renderTextBody(txBody, S, part, { + ph, inh, styleNode, idPrefix: `${part.scope}.sh${id}`, kind: 'text', + }) : null; + + const bg = bgCss(fill, S, part); + const hasBg = Boolean(bg) || (ln && geo.kind === 'css'); + if (!lineBox?.hasText && !bg && !ln) return ''; + S.stats.shapes += 1; + + const flipT = xf && (box.flipH || box.flipV) ? `scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})` : null; + const parts = []; + if (geo.kind === 'css') { + if (hasBg) { + parts.push(`
`); + } + } else { + if (bg && !geo.open) parts.push(`
`); + if (ln) { + const dash = DASH[ln.dash] ? `stroke-dasharray="${ln.dash.includes('ot') || ln.dash === 'sysDot' ? '1 3' : '6 4'}"` : ''; + parts.push(``); + } + } + if (lineBox?.hasText || lineBox?.html) parts.push(`
${lineBox.html}
`); + + const shapeStyle = style({ + left: px(box.x), top: px(box.y), width: px(box.w), 'min-height': px(box.h), transform: xf ? rotFlip(box) : null, + }); + return `
${parts.join('')}
`; +} + +function markerDefs(S, id, ln, which, end) { + const kind = end.type === 'oval' ? 'oval' : end.type === 'diamond' ? 'diamond' : 'arrow'; + const size = { sm: 2, med: 3, lg: 5 }; + const mw = size[end.w] ?? 3; + const mh = size[end.len] ?? 3; + const shape = kind === 'oval' ? '' : kind === 'diamond' ? '' : ''; + return `${shape}`; +} + +function renderConnector(node, S, part, T) { + const isSp = node.name === 'p:sp'; + const cNv = path(node, isSp ? 'p:nvSpPr' : 'p:nvCxnSpPr', 'p:cNvPr'); + if (truthy(cNv?.attrs.hidden)) return ''; + const id = cNv?.attrs.id ?? '0'; + const spPr = child(node, 'p:spPr'); + const xf = readXfrm(spPr); + const ln = readLine(spPr, child(node, 'p:style'), S.chain.env); + if (!xf || !ln) return ''; + const box = place(xf, T); + const prstGeom = child(spPr, 'a:prstGeom'); + const w = Math.max(box.w, 0.01); + const h = Math.max(box.h, 0.01); + const d = connectorPath(prstGeom?.attrs.prst ?? 'line', readAdjust(prstGeom), w, h); + S.stats.connectors += 1; + + const defs = []; + let startM = ''; + let endM = ''; + if (ln.head) { const mid = `mk${S.slideNo}-${S.scopeKey}${id}h`; defs.push(markerDefs(S, mid, ln, 'h', ln.head)); startM = ` marker-start="url(#${mid})"`; } + if (ln.tail) { const mid = `mk${S.slideNo}-${S.scopeKey}${id}t`; defs.push(markerDefs(S, mid, ln, 't', ln.tail)); endM = ` marker-end="url(#${mid})"`; } + const dash = DASH[ln.dash] ? ` stroke-dasharray="${/ot/i.test(ln.dash) ? '1 3' : '6 4'}"` : ''; + const transform = [box.rot ? `rotate(${round(box.rot)}deg)` : null, box.flipH || box.flipV ? `scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})` : null].filter(Boolean).join(' '); + const css = style({ left: px(box.x), top: px(box.y), width: px(w), height: px(h), transform: transform || null }); + return `${defs.join('')}`; +} + +function renderPic(pic, S, part, T) { + const cNv = path(pic, 'p:nvPicPr', 'p:cNvPr'); + if (truthy(cNv?.attrs.hidden)) return ''; + const id = cNv?.attrs.id ?? '0'; + const ph = readPh(pic); + if (part.decor && ph) return ''; + const inh = part.decor ? [] : inheritFor(S.chain, ph); + const spPr = child(pic, 'p:spPr'); + const xf = firstOf([spPr, ...inh.map((n) => child(n, 'p:spPr'))], readXfrm); + if (!xf) return ''; + const box = place(xf, T); + + const blipFill = child(pic, 'p:blipFill'); + const blip = child(blipFill, 'a:blip'); + const svgBlip = descendants(blip ?? { children: [] }, 'asvg:svgBlip')[0]; + const relId = svgBlip?.attrs['r:embed'] ?? blip?.attrs['r:embed']; + const target = relId ? part.rels.get(relId)?.target : null; + if (!target || !S.pkg.files.has(target)) return ''; + + const env = S.chain.env; + const geo = presetGeometry(child(spPr, 'a:prstGeom')?.attrs.prst ?? 'rect', readAdjust(child(spPr, 'a:prstGeom')), box.w, box.h); + const radius = geo?.kind === 'css' ? (geo.radius === '50%' ? '50%' : geo.radius ? px(geo.radius) : null) : null; + const ln = readLine(spPr, null, env); + S.stats.pictures += 1; + + const outer = style({ + left: px(box.x), top: px(box.y), width: px(box.w), height: px(box.h), transform: rotFlip(box), 'border-radius': radius, border: lineCss(ln), + }); + if (/\.(emf|wmf)$/i.test(target)) { + warn(S, `picture ${id} is EMF/WMF and cannot be shown in a browser`); + // Logos on layouts repeat on every slide; a hatched box on each would be noise. + if (part.decor) return ''; + return `
`; + } + + const crop = child(blipFill, 'a:srcRect'); + const l = num(crop?.attrs.l) / 100000; const t = num(crop?.attrs.t) / 100000; + const r = num(crop?.attrs.r) / 100000; const b = num(crop?.attrs.b) / 100000; + const vw = 1 - l - r || 1; const vh = 1 - t - b || 1; + const imgCss = crop ? style({ + width: `${round(100 / vw)}%`, height: `${round(100 / vh)}%`, left: `${round((-l / vw) * 100)}%`, top: `${round((-t / vh) * 100)}%`, + }) : ''; + const flip = box.flipH || box.flipV ? `transform:scale(${box.flipH ? -1 : 1},${box.flipV ? -1 : 1})` : ''; + + const descr = cNv?.attrs.descr?.trim(); + let altAttr = ' alt=""'; + if (descr) { + const altId = `${part.scope}.sh${id}.alt`; + S.addString({ id: altId, kind: 'alt', text: esc(descr) }); + altAttr = ` alt="${escAttr(descr)}" data-i18n-alt="${altId}"`; + } + return `
`; +} + +/* ------------------------------------------------------------- tables ---- */ + +function tableStyleOf(S, styleId) { + if (!styleId) return null; + if (!S.tableStyles) S.tableStyles = S.pkg.xml('ppt/tableStyles.xml'); + return kids(S.tableStyles, 'a:tblStyle').find((s) => s.attrs.styleId === styleId) ?? null; +} + +function renderTable(frame, tbl, S, part, T) { + const xf = readXfrm(child(frame, 'p:xfrm')); + if (!xf) return ''; + const box = place(xf, T); + const env = S.chain.env; + const id = path(frame, 'p:nvGraphicFramePr', 'p:cNvPr')?.attrs.id ?? '0'; + const tblPr = child(tbl, 'a:tblPr'); + const tstyle = tableStyleOf(S, child(tblPr, 'a:tableStyleId')?.text.trim()); + const firstRow = truthy(tblPr?.attrs.firstRow); + const bandRow = truthy(tblPr?.attrs.bandRow); + + const part2 = (name) => child(tstyle, name); + const cellStyle = (rowIdx) => { + const picks = [part2('wholeTbl'), bandRow ? part2(rowIdx % 2 === (firstRow ? 1 : 0) ? 'band1H' : 'band2H') : null, firstRow && rowIdx === 0 ? part2('firstRow') : null].filter(Boolean); + let fill = null; let color = null; let bold = false; + for (const p of picks) { + const f = readFill(child(child(p, 'a:tcStyle'), 'a:fill'), env); + if (f) fill = f; + const tx = child(p, 'a:tcTxStyle'); + const c = readColor(tx, env); + if (c) color = colorToCss(c); + if (tx?.attrs.b === 'on') bold = true; + } + return { fill, color, bold }; + }; + + const cols = kids(child(tbl, 'a:tblGrid'), 'a:gridCol').map((c) => emuToPx(num(c.attrs.w) * scaleX(T))); + const cells = []; + const rows = kids(tbl, 'a:tr').map((tr, r) => { + const cs = cellStyle(r); + const tds = kids(tr, 'a:tc').map((tc, c) => { + if (truthy(tc.attrs.hMerge) || truthy(tc.attrs.vMerge)) return ''; + const tcPr = child(tc, 'a:tcPr'); + const txBody = child(tc, 'a:txBody'); + const content = txBody ? renderTextBody(txBody, S, part, { + idPrefix: `${part.scope}.sh${id}.r${r + 1}c${c + 1}`, + kind: 'table', + extraLevels: () => ({ r: { ...(cs.color ? { color: cs.color } : {}), ...(cs.bold || (firstRow && r === 0) ? { b: true } : {}) } }), + }) : { html: '' }; + const own = readFill(tcPr, env) ?? cs.fill; + const border = (name) => { const l = readLine(child(tcPr, name) ? { children: [child(tcPr, name)] } : null, null, env); return l ? lineCss(l) : null; }; + const css = style({ + ...(bgCss(own, S, part) ?? {}), + 'vertical-align': { ctr: 'middle', b: 'bottom' }[tcPr?.attrs.anchor] ?? 'top', + padding: [num(tcPr?.attrs.marT, 45720), num(tcPr?.attrs.marR, 91440), num(tcPr?.attrs.marB, 45720), num(tcPr?.attrs.marL, 91440)].map((v) => px(emuToPx(v))).join(' '), + 'border-left': border('a:lnL'), 'border-right': border('a:lnR'), 'border-top': border('a:lnT'), 'border-bottom': border('a:lnB'), + }); + const span = `${num(tc.attrs.gridSpan, 1) > 1 ? ` colspan="${tc.attrs.gridSpan}"` : ''}${num(tc.attrs.rowSpan, 1) > 1 ? ` rowspan="${tc.attrs.rowSpan}"` : ''}`; + return `${content.html}`; + }); + cells.push(tds.length); + return `${tds.join('')}`; + }); + S.stats.tables += 1; + return `${cols.map((w) => ``).join('')}${rows.join('')}
`; +} + +/* --------------------------------------------------- frames and groups ---- */ + +function renderFrame(frame, S, part, T) { + const graphic = path(frame, 'a:graphic', 'a:graphicData'); + const tbl = child(graphic, 'a:tbl'); + if (tbl) return renderTable(frame, tbl, S, part, T); + + const uri = graphic?.attrs.uri ?? ''; + const xf = readXfrm(child(frame, 'p:xfrm')); + const box = xf ? place(xf, T) : null; + const ghost = (label) => (box ? `
` : ''); + + if (/diagram/.test(uri) && box) { + const dm = child(graphic, 'dgm:relIds'); + const drawing = [...part.rels.values()].find((r) => r.type.endsWith('/diagramDrawing'))?.target; + if (drawing && dm) { + // The drawing part holds SmartArt already laid out as plain shapes. + const raw = S.pkg.text(drawing).replace(/<(\/?)dsp:/g, '<$1p:'); + const tree = parseXml(raw); + const inner = path(tree, 'p:spTree'); + if (inner) { + S.stats.smartart += 1; + return renderTree(inner, S, part, translate(T, xf.x, xf.y)); + } + } + warn(S, 'SmartArt could not be rendered'); + return ghost('SmartArt not rendered'); + } + if (/chart/.test(uri)) { warn(S, 'chart not rendered (placeholder shown)'); return ghost('Chart not rendered'); } + if (!/ole|table/i.test(uri)) warn(S, `graphic frame (${uri.split('/').pop() || 'unknown'}) not rendered`); + return ghost('Content not rendered'); +} + +function renderTree(tree, S, part, T) { + const out = []; + const visit = (node, t) => { + switch (node.name) { + case 'p:sp': out.push(renderSp(node, S, part, t)); break; + case 'p:pic': out.push(renderPic(node, S, part, t)); break; + case 'p:cxnSp': out.push(renderConnector(node, S, part, t)); break; + case 'p:graphicFrame': out.push(renderFrame(node, S, part, t)); break; + case 'p:grpSp': { + if (truthy(path(node, 'p:nvGrpSpPr', 'p:cNvPr')?.attrs.hidden)) break; + const xf = readXfrm(child(node, 'p:grpSpPr')); + S.stats.groups += 1; + const inner = xf ? composeGroup(t, xf) : t; + node.children.forEach((c) => visit(c, inner)); + break; + } + case 'mc:AlternateContent': { + const pick = child(node, 'mc:Fallback') ?? child(node, 'mc:Choice'); + pick?.children.forEach((c) => visit(c, t)); + break; + } + default: break; + } + }; + tree.children.forEach((c) => visit(c, T)); + return out.filter(Boolean).join('\n'); +} + +/* ---------------------------------------------------- backgrounds, notes ---- */ + +function backgroundOf(S, sources) { + const env = S.chain.env; + for (const { xml, part } of sources) { + const bg = path(xml, 'p:cSld', 'p:bg'); + if (!bg) continue; + const bgPr = child(bg, 'p:bgPr'); + if (bgPr) { + const css = bgCss(readFill(bgPr, env), S, part); + if (css) return css; + } + const ref = child(bg, 'p:bgRef'); + if (ref) { + const phClr = readColor(ref, env); + const idx = num(ref.attrs.idx); + const entry = S.chain.theme.bgFills[idx - 1001]; + const fill = entry ? fillFromElement(entry, { ...env, phClr }) : phClr ? { type: 'solid', css: colorToCss(phClr) } : null; + const css = bgCss(fill, S, part); + if (css) return css; + } + } + return { background: '#fff' }; +} + +function renderNotes(S, slidePath, slideRels) { + const notesPath = relOfType(slideRels, '/notesSlide'); + const notesXml = notesPath ? S.pkg.xml(notesPath) : null; + const tree = path(notesXml, 'p:cSld', 'p:spTree'); + if (!tree) return ''; + const part = { rels: S.pkg.rels(notesPath), scope: `s${S.slideNo}.notes` }; + const out = []; + for (const sp of kids(tree, 'p:sp')) { + if (readPh(sp)?.type !== 'body') continue; + const txBody = child(sp, 'p:txBody'); + if (!txBody) continue; + kids(txBody, 'a:p').forEach((p, i) => { + const tx = { + S, part, env: S.chain.env, baseProps: () => ({}), fontScale: 1, lnReduce: 0, numbering: {}, idPrefix: part.scope, kind: 'notes', + }; + const { html, string } = renderParagraph(p, i + 1, tx); + if (string) { S.addString(string); out.push(html.replace(/ style="[^"]*"/, '')); } + }); + } + return out.length ? `` : ''; +} + +/* --------------------------------------------------------------- entry ---- */ + +export function pptxToHtml(files, options = {}) { + const pkg = openPackage(files); + const presXml = pkg.xml('ppt/presentation.xml'); + if (!presXml) throw new Error('No slides found. Is this really a .pptx?'); + const presRels = pkg.rels('ppt/presentation.xml'); + const slidePaths = kids(child(presXml, 'p:sldIdLst'), 'p:sldId') + .map((s) => presRels.get(s.attrs['r:id'])?.target) + .filter((p) => p && files.has(p)); + if (!slidePaths.length) throw new Error('No slides found. Is this really a .pptx?'); + const [firstSlide, lastSlide] = options.slideRange ?? [1, slidePaths.length]; + const sz = child(presXml, 'p:sldSz'); + const width = Math.round(emuToPx(num(sz?.attrs.cx, 12192000))); + const height = Math.round(emuToPx(num(sz?.attrs.cy, 6858000))); + + const langCounts = {}; + const warnings = []; + const strings = []; + const images = new Map(); + const unknown = new Map(); + const stats = { slides: 0, shapes: 0, pictures: 0, groups: 0, connectors: 0, tables: 0, smartart: 0, codeParagraphs: 0, strings: 0 }; + const chainCache = new Map(); + const sections = []; + + slidePaths.forEach((slidePath, index) => { + const slideNo = index + 1; + if (slideNo < firstSlide || slideNo > lastSlide) return; + const sld = pkg.xml(slidePath); + for (const m of pkg.text(slidePath).matchAll(/\blang="([A-Za-z-]+)"/g)) langCounts[m[1]] = (langCounts[m[1]] ?? 0) + 1; + + const chain = buildChain(pkg, slidePath, chainCache); + const S = { + pkg, chain, slideNo, width, height, warnings, images, unknown, stats, strings, + pres: { defaultTextStyle: child(presXml, 'p:defaultTextStyle') }, + defs: [], clips: new Map(), title: '', scopeKey: '', + addString(s) { + const entry = { id: s.id, slide: slideNo, kind: s.kind, text: s.text }; + if (this.title && this.title !== s.text) entry.context = this.title; + strings.push(entry); + }, + }; + + const tree = path(sld, 'p:cSld', 'p:spTree'); + const titleSp = kids(tree, 'p:sp').find((sp) => ['title', 'ctrTitle'].includes(readPh(sp)?.type)); + S.title = descendants(titleSp ?? { children: [] }, 'a:t').map((t) => t.text).join('').trim(); + + const slideRels = pkg.rels(slidePath); + const slidePart = { rels: slideRels, scope: `s${slideNo}`, decor: false }; + const showLayout = sld.attrs.showMasterSp !== '0'; + const showMaster = showLayout && chain.layoutXml?.attrs.showMasterSp !== '0'; + + const html = []; + if (showMaster && chain.masterTree) { + S.scopeKey = 'M'; + html.push(renderTree(chain.masterTree, S, { rels: chain.masterRels, scope: `s${slideNo}.M`, decor: true }, IDENTITY)); + } + if (showLayout && chain.layoutTree) { + S.scopeKey = 'L'; + html.push(renderTree(chain.layoutTree, S, { rels: chain.layoutRels, scope: `s${slideNo}.L`, decor: true }, IDENTITY)); + } + S.scopeKey = ''; + if (tree) html.push(renderTree(tree, S, slidePart, IDENTITY)); + + const bg = backgroundOf(S, [ + { xml: sld, part: slidePart }, + { xml: chain.layoutXml, part: { rels: chain.layoutRels } }, + { xml: chain.masterXml, part: { rels: chain.masterRels } }, + ]); + const hl = chain.theme.colors.hlink ? `#${chain.theme.colors.hlink}` : null; + const notes = renderNotes(S, slidePath, slideRels); + const defs = S.defs.length ? `` : ''; + // Hidden slides stay visible here so every string can be reviewed and translated. + const hidden = sld.attrs.show === '0' ? ' data-hidden-slide="true"' : ''; + if (hidden) warn(S, 'hidden in the original deck (shown here)'); + sections.push(`
${defs}${html.filter(Boolean).join('\n')} +
${notes}
`); + stats.slides += 1; + }); + + for (const [prst, count] of unknown) warnings.push(`${count} shape(s) use preset "${prst}", drawn as a rectangle`); + stats.strings = strings.length; + + const sourceLang = Object.entries(langCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'en'; + const core = pkg.xml('docProps/core.xml'); + const coreTitle = child(core, 'dc:title')?.text.trim(); + const coreAuthor = child(core, 'dc:creator')?.text.trim(); + + return { + html: `${sections.join('\n')}\n`, + strings: { version: 1, sourceLang, strings }, + images: [...images].map(([zipPath, name]) => ({ zipPath, name })), + deck: { + title: options.title || coreTitle || options.slug || 'Imported deck', + description: options.description ?? 'Imported from PowerPoint.', + author: options.author ?? coreAuthor ?? '', + date: options.date ?? new Date().toISOString().slice(0, 10), + tags: options.tags ?? [], + theme: options.theme ?? 'white', + format: 'html', + lang: sourceLang, + canvas: { width, height }, + }, + warnings, + stats, + }; +} diff --git a/tools/pptx-style.mjs b/tools/pptx-style.mjs new file mode 100644 index 0000000..420bc2f --- /dev/null +++ b/tools/pptx-style.mjs @@ -0,0 +1,333 @@ +/** + * Colors, fills, lines, themes and text-style resolution for the PPTX to HTML + * converter. Everything here is pure and runs in Node and the browser. + */ +import { child, kids, attrOf } from './xml-lite.mjs'; + +export const EMU_PER_PX = 9525; +export const emuToPx = (v) => Number(v) / EMU_PER_PX; +/** Points to CSS pixels at 96 dpi. */ +export const ptToPx = (pt) => (Number(pt) * 96) / 72; +const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d; +export const px = (n) => `${round(n)}px`; + +/* ------------------------------------------------------------- color ---- */ + +const clamp = (v, lo = 0, hi = 255) => Math.min(hi, Math.max(lo, v)); + +function rgbToHsl({ r, g, b }) { + const rn = r / 255; const gn = g / 255; const bn = b / 255; + const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) return { h: 0, s: 0, l }; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0); + else if (max === gn) h = (bn - rn) / d + 2; + else h = (rn - gn) / d + 4; + return { h: h / 6, s, l }; +} + +function hslToRgb({ h, s, l }) { + if (s === 0) { const v = Math.round(l * 255); return { r: v, g: v, b: v }; } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const f = (t) => { + let x = t; + if (x < 0) x += 1; + if (x > 1) x -= 1; + if (x < 1 / 6) return p + (q - p) * 6 * x; + if (x < 1 / 2) return q; + if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6; + return p; + }; + return { r: Math.round(f(h + 1 / 3) * 255), g: Math.round(f(h) * 255), b: Math.round(f(h - 1 / 3) * 255) }; +} + +const hexToRgb = (hex) => { + const h = hex.replace('#', ''); + const v = h.length === 3 ? [...h].map((c) => c + c).join('') : h.padEnd(6, '0'); + return { r: parseInt(v.slice(0, 2), 16), g: parseInt(v.slice(2, 4), 16), b: parseInt(v.slice(4, 6), 16) }; +}; + +const PRESET_COLORS = { + black: '000000', white: 'ffffff', red: 'ff0000', green: '008000', blue: '0000ff', yellow: 'ffff00', + gray: '808080', grey: '808080', orange: 'ffa500', purple: '800080', cyan: '00ffff', magenta: 'ff00ff', + darkBlue: '00008b', darkGray: 'a9a9a9', lightGray: 'd3d3d3', silver: 'c0c0c0', +}; + +const DEFAULT_CLR_MAP = { + bg1: 'lt1', tx1: 'dk1', bg2: 'lt2', tx2: 'dk2', + accent1: 'accent1', accent2: 'accent2', accent3: 'accent3', accent4: 'accent4', accent5: 'accent5', accent6: 'accent6', + hlink: 'hlink', folHlink: 'folHlink', +}; + +const COLOR_ELEMENTS = new Set(['a:srgbClr', 'a:schemeClr', 'a:sysClr', 'a:prstClr', 'a:scrgbClr']); + +/** + * Resolve a color element (or a parent holding one) to {r,g,b,a}. + * `env` carries the theme, the colour map and the current placeholder colour. + */ +export function readColor(node, env) { + if (!node) return null; + const el = COLOR_ELEMENTS.has(node.name) ? node : node.children.find((c) => COLOR_ELEMENTS.has(c.name)); + if (!el) return null; + + let rgb = null; + const val = attrOf(el, 'val'); + if (el.name === 'a:srgbClr') rgb = hexToRgb(val ?? '000000'); + else if (el.name === 'a:sysClr') rgb = hexToRgb(attrOf(el, 'lastClr') ?? (val === 'window' ? 'ffffff' : '000000')); + else if (el.name === 'a:prstClr') rgb = hexToRgb(PRESET_COLORS[val] ?? '000000'); + else if (el.name === 'a:scrgbClr') { + const ch = (n) => Math.round(clamp((Number(attrOf(el, n, 0)) / 100000) * 255)); + rgb = { r: ch('r'), g: ch('g'), b: ch('b') }; + } else if (el.name === 'a:schemeClr') { + if (val === 'phClr') rgb = env.phClr ? { ...env.phClr } : null; + else { + const mapped = (env.clrMap ?? DEFAULT_CLR_MAP)[val] ?? val; + const hex = env.theme?.colors?.[mapped] ?? env.theme?.colors?.[val]; + rgb = hex ? hexToRgb(hex) : null; + } + } + if (!rgb) return null; + + let color = { ...rgb, a: 1 }; + for (const t of el.children) { + const v = Number(attrOf(t, 'val', 0)) / 100000; + if (t.name === 'a:alpha') color.a = v; + else if (['a:lumMod', 'a:lumOff', 'a:satMod', 'a:satOff'].includes(t.name)) { + const hsl = rgbToHsl(color); + if (t.name === 'a:lumMod') hsl.l = clamp(hsl.l * v, 0, 1); + else if (t.name === 'a:lumOff') hsl.l = clamp(hsl.l + v, 0, 1); + else if (t.name === 'a:satMod') hsl.s = clamp(hsl.s * v, 0, 1); + else hsl.s = clamp(hsl.s + v, 0, 1); + color = { ...hslToRgb(hsl), a: color.a }; + } else if (t.name === 'a:tint') { + color = { r: Math.round(color.r * v + 255 * (1 - v)), g: Math.round(color.g * v + 255 * (1 - v)), b: Math.round(color.b * v + 255 * (1 - v)), a: color.a }; + } else if (t.name === 'a:shade') { + color = { r: Math.round(color.r * v), g: Math.round(color.g * v), b: Math.round(color.b * v), a: color.a }; + } + } + return color; +} + +export const colorToCss = (c) => (c.a < 1 + ? `rgba(${c.r},${c.g},${c.b},${round(c.a, 3)})` + : `#${[c.r, c.g, c.b].map((n) => n.toString(16).padStart(2, '0')).join('')}`); + +/* ------------------------------------------------------------- theme ---- */ + +export function readTheme(themeRoot) { + const theme = { colors: {}, major: null, minor: null, fills: [], bgFills: [], lines: [] }; + if (!themeRoot) return theme; + const elements = child(themeRoot, 'a:themeElements'); + const scheme = child(elements, 'a:clrScheme'); + for (const c of scheme?.children ?? []) { + const inner = c.children[0]; + if (!inner) continue; + const hex = inner.name === 'a:sysClr' ? attrOf(inner, 'lastClr', '000000') : attrOf(inner, 'val', '000000'); + theme.colors[c.name.replace('a:', '')] = hex; + } + const fonts = child(elements, 'a:fontScheme'); + theme.major = attrOf(child(child(fonts, 'a:majorFont'), 'a:latin'), 'typeface'); + theme.minor = attrOf(child(child(fonts, 'a:minorFont'), 'a:latin'), 'typeface'); + const fmt = child(elements, 'a:fmtScheme'); + theme.fills = child(fmt, 'a:fillStyleLst')?.children ?? []; + theme.bgFills = child(fmt, 'a:bgFillStyleLst')?.children ?? []; + theme.lines = child(fmt, 'a:lnStyleLst')?.children ?? []; + return theme; +} + +/* -------------------------------------------------------------- fill ---- */ + +const FILL_NAMES = ['a:noFill', 'a:solidFill', 'a:gradFill', 'a:blipFill', 'a:pattFill', 'a:grpFill']; + +/** Turn one fill element into { type, css?, relId? }. */ +export function fillFromElement(el, env) { + if (!el) return null; + if (el.name === 'a:noFill') return { type: 'none' }; + if (el.name === 'a:solidFill') { + const c = readColor(el, env); + return c ? { type: 'solid', css: colorToCss(c) } : null; + } + if (el.name === 'a:gradFill') { + const stops = (child(el, 'a:gsLst')?.children ?? []) + .map((gs) => ({ pos: Number(attrOf(gs, 'pos', 0)) / 1000, color: readColor(gs, env) })) + .filter((s) => s.color) + .sort((a, b) => a.pos - b.pos); + if (!stops.length) return null; + if (stops.length === 1) return { type: 'solid', css: colorToCss(stops[0].color) }; + const list = stops.map((s) => `${colorToCss(s.color)} ${round(s.pos, 1)}%`).join(', '); + const lin = child(el, 'a:lin'); + if (child(el, 'a:path')) return { type: 'grad', css: `radial-gradient(circle at center, ${list})` }; + const angle = lin ? Number(attrOf(lin, 'ang', 0)) / 60000 : 0; + return { type: 'grad', css: `linear-gradient(${round(angle + 90, 1)}deg, ${list})` }; + } + if (el.name === 'a:blipFill') { + const blip = child(el, 'a:blip'); + return { type: 'pic', relId: attrOf(blip, 'r:embed'), tile: Boolean(child(el, 'a:tile')) }; + } + if (el.name === 'a:pattFill') { + const fg = readColor(child(el, 'a:fgClr'), env); + return fg ? { type: 'solid', css: colorToCss(fg) } : null; + } + return null; +} + +/** First fill child of a shape-properties-like element, or null if unspecified. */ +export function readFill(parent, env) { + const el = parent?.children.find((c) => FILL_NAMES.includes(c.name)); + return el ? fillFromElement(el, env) : null; +} + +/** Fill implied by a shape's , using the theme's fill list. */ +export function styleFill(styleNode, env) { + const ref = child(styleNode, 'a:fillRef'); + if (!ref) return null; + const idx = Number(attrOf(ref, 'idx', 0)); + if (idx === 0) return { type: 'none' }; + const phClr = readColor(ref, env); + const list = idx >= 1001 ? env.theme?.bgFills : env.theme?.fills; + const entry = list?.[(idx >= 1001 ? idx - 1001 : idx - 1)]; + if (!entry) return phClr ? { type: 'solid', css: colorToCss(phClr) } : null; + return fillFromElement(entry, { ...env, phClr }); +} + +/* -------------------------------------------------------------- line ---- */ + +/** Resolve a shape outline to { w (px), color, dash, head, tail } or null. */ +export function readLine(spPr, styleNode, env) { + const ln = child(spPr, 'a:ln'); + const ref = child(styleNode, 'a:lnRef'); + const refIdx = Number(attrOf(ref, 'idx', 0)); + const themeLine = refIdx > 0 ? env.theme?.lines?.[refIdx - 1] : null; + + let w = ln && attrOf(ln, 'w') != null ? Number(attrOf(ln, 'w')) : null; + if (w == null && themeLine) w = Number(attrOf(themeLine, 'w', 12700)); + if (w == null) w = 12700; + + let fill = ln ? readFill(ln, env) : null; + if (!fill && ref && refIdx > 0) { + const phClr = readColor(ref, env); + fill = phClr ? { type: 'solid', css: colorToCss(phClr) } : null; + } + if (!fill || fill.type === 'none' || fill.type === 'pic') return null; + const color = fill.type === 'solid' ? fill.css : '#000'; + + const end = (name) => { + const e = child(ln, name); + const type = attrOf(e, 'type', 'none'); + return type === 'none' ? null : { type, w: attrOf(e, 'w', 'med'), len: attrOf(e, 'len', 'med') }; + }; + return { + w: Math.max(0.5, emuToPx(w)), + color, + dash: attrOf(child(ln, 'a:prstDash'), 'val', 'solid'), + head: end('a:headEnd'), + tail: end('a:tailEnd'), + }; +} + +/* ------------------------------------------------------- text styles ---- */ + +const truthy = (v) => v === '1' || v === 'true'; + +/** Run properties from an a:rPr / a:defRPr / a:endParaRPr element. */ +export function readRunProps(node, env) { + const out = {}; + if (!node) return out; + const a = node.attrs; + if (a.sz) out.sz = Number(a.sz); + if (a.b != null) out.b = truthy(a.b); + if (a.i != null) out.i = truthy(a.i); + if (a.u != null) out.u = a.u !== 'none'; + if (a.strike != null) out.strike = a.strike !== 'noStrike'; + if (a.baseline != null) out.baseline = Number(a.baseline); + if (a.cap != null) out.cap = a.cap; + if (a.spc != null) out.spc = Number(a.spc); + const fill = readFill(node, env); + if (fill?.type === 'solid') out.color = fill.css; + const latin = child(node, 'a:latin'); + if (latin?.attrs.typeface) out.font = latin.attrs.typeface; + const ea = child(node, 'a:ea'); + if (ea?.attrs.typeface) out.eaFont = ea.attrs.typeface; + const link = child(node, 'a:hlinkClick'); + if (link?.attrs['r:id']) out.link = link.attrs['r:id']; + return out; +} + +const spacing = (node) => { + const pct = child(node, 'a:spcPct'); + if (pct) return { pct: Number(pct.attrs.val) / 100000 }; + const pts = child(node, 'a:spcPts'); + if (pts) return { pt: Number(pts.attrs.val) / 100 }; + return undefined; +}; + +/** Paragraph properties from an a:lvlNpPr / a:pPr element. */ +export function readParaProps(node, env) { + const out = {}; + if (!node) return out; + const a = node.attrs; + if (a.algn) out.algn = a.algn; + if (a.marL != null) out.marL = Number(a.marL); + if (a.indent != null) out.indent = Number(a.indent); + if (a.rtl != null) out.rtl = truthy(a.rtl); + const ln = child(node, 'a:lnSpc'); + if (ln) out.lnSpc = spacing(ln); + const bef = child(node, 'a:spcBef'); + if (bef) out.spcBef = spacing(bef); + const aft = child(node, 'a:spcAft'); + if (aft) out.spcAft = spacing(aft); + + if (child(node, 'a:buNone')) out.bu = { type: 'none' }; + else if (child(node, 'a:buChar')) out.bu = { type: 'char', char: attrOf(child(node, 'a:buChar'), 'char', '•') }; + else if (child(node, 'a:buAutoNum')) { + const n = child(node, 'a:buAutoNum'); + out.bu = { type: 'auto', scheme: attrOf(n, 'type', 'arabicPeriod'), startAt: Number(attrOf(n, 'startAt', 1)) }; + } else if (child(node, 'a:buBlip')) out.bu = { type: 'char', char: '•' }; + + const buFont = child(node, 'a:buFont'); + if (buFont?.attrs.typeface) out.buFont = buFont.attrs.typeface; + const buClr = child(node, 'a:buClr'); + if (buClr) { const c = readColor(buClr, env); if (c) out.buColor = colorToCss(c); } + const buSz = child(node, 'a:buSzPct'); + if (buSz) out.buSize = Number(buSz.attrs.val) / 100000; + + const def = child(node, 'a:defRPr'); + if (def) out.r = readRunProps(def, env); + return out; +} + +/** Overlay `over` on `base`, merging the nested run-props object. */ +export function mergeProps(base, over) { + if (!over) return base; + const out = { ...base }; + for (const [k, v] of Object.entries(over)) { + if (v === undefined) continue; + out[k] = k === 'r' ? { ...(base.r ?? {}), ...v } : v; + } + return out; +} + +/** Level N (0-based) properties from an a:lstStyle-like element. */ +export function levelProps(listStyle, level, env) { + return readParaProps(child(listStyle, `a:lvl${level + 1}pPr`), env); +} + +/* ------------------------------------------------------------- fonts ---- */ + +export const MONO_FONT = /^(cascadia|consolas|courier|menlo|monaco|lucida console|fira (code|mono)|source code pro|jetbrains mono|sf mono|roboto mono|ibm plex mono|ubuntu mono|dejavu sans mono|liberation mono)/i; + +const quote = (name) => `'${name.replace(/'/g, '')}'`; + +/** A CSS font stack for a PPTX typeface, resolving theme fonts. */ +export function fontStack(typeface, theme) { + let name = typeface; + if (name === '+mj-lt') name = theme?.major; + else if (name === '+mn-lt') name = theme?.minor; + if (!name) return null; + if (MONO_FONT.test(name)) return `${quote(name)}, Consolas, 'Cascadia Mono', Menlo, monospace`; + return `${quote(name)}, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif`; +} diff --git a/tools/pptx-to-html.mjs b/tools/pptx-to-html.mjs new file mode 100644 index 0000000..f3d1a4c --- /dev/null +++ b/tools/pptx-to-html.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Converts a .pptx into a positioned-HTML deck with a localization string table. + * + * node tools/pptx-to-html.mjs "C:\path\to\talk.pptx" --slug my-talk + * + * Writes decks//{slides.html, strings.json, deck.json, images/}. Run + * `node tools/build-manifest.mjs` afterwards to list it on the landing page. + * Requires Node 18+. No dependencies. + */ +import { readFileSync } from 'node:fs'; +import { mkdir, writeFile, rm, access } from 'node:fs/promises'; +import { inflateRawSync } from 'node:zlib'; +import { basename, dirname, join, resolve, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { listZipEntries, slugify, SLUG_PATTERN } from './pptx-core.mjs'; +import { pptxToHtml } from './pptx-html.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function inflateAll(bytes) { + const files = new Map(); + for (const entry of listZipEntries(bytes)) { + const raw = bytes.subarray(entry.dataStart, entry.dataStart + entry.compSize); + if (entry.method === 0) files.set(entry.name, raw); + else if (entry.method === 8) files.set(entry.name, inflateRawSync(raw)); + else throw new Error(`Unsupported compression (method ${entry.method}) for ${entry.name}`); + } + return files; +} + +const exists = (p) => access(p).then(() => true, () => false); + +/** "1-5" -> [1, 5]; "3" -> [3, 3]. */ +function parseRange(text) { + const m = /^(\d+)(?:-(\d+))?$/.exec(text); + if (!m) throw new Error(`--slides expects a number or a range like 1-5, got "${text}"`); + return [Number(m[1]), Number(m[2] ?? m[1])]; +} + +function parseArgs(argv) { + const opts = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--force') opts.force = true; + else if (a === '--no-images') opts.noImages = true; + else if (a.startsWith('--')) opts[a.slice(2)] = argv[++i]; + else opts._.push(a); + } + return opts; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const file = opts._[0]; + if (!file) { + console.error('Usage: node tools/pptx-to-html.mjs [--slug name] [--out dir] [--title t] [--slides 1-5] [--force]'); + process.exit(1); + } + + const slug = opts.slug ?? slugify(basename(file, extname(file))); + if (!SLUG_PATTERN.test(slug)) throw new Error(`"${slug}" is not a valid slug.`); + const outDir = resolve(opts.out ?? join(ROOT, 'decks', slug)); + if (await exists(outDir) && !opts.force) throw new Error(`${outDir} already exists. Pass --force to overwrite it.`); + + const started = Date.now(); + const files = inflateAll(new Uint8Array(readFileSync(file))); + const result = pptxToHtml(files, { + slug, title: opts.title, author: opts.author, date: opts.date, theme: opts.theme, + description: opts.description ?? `Imported from ${basename(file)}.`, + slideRange: opts.slides ? parseRange(opts.slides) : undefined, + }); + + await rm(outDir, { recursive: true, force: true }); + await mkdir(join(outDir, 'images'), { recursive: true }); + await writeFile(join(outDir, 'slides.html'), result.html, 'utf8'); + await writeFile(join(outDir, 'strings.json'), `${JSON.stringify(result.strings, null, 2)}\n`, 'utf8'); + await writeFile(join(outDir, 'deck.json'), `${JSON.stringify(result.deck, null, 2)}\n`, 'utf8'); + if (!opts.noImages) { + for (const { zipPath, name } of result.images) await writeFile(join(outDir, 'images', name), files.get(zipPath)); + } + + const { stats } = result; + console.log(`Wrote ${outDir} in ${((Date.now() - started) / 1000).toFixed(1)}s`); + console.log(` ${stats.slides} slides, ${stats.strings} strings, ${stats.shapes} shapes, ${stats.pictures} pictures, ${stats.connectors} connectors, ${stats.tables} tables, ${stats.codeParagraphs} code paragraphs`); + if (result.warnings.length) { + console.log(` ${result.warnings.length} warning(s):`); + for (const w of result.warnings.slice(0, 40)) console.log(` - ${w}`); + if (result.warnings.length > 40) console.log(` ... and ${result.warnings.length - 40} more`); + } +} + +main().catch((error) => { console.error(error.message); process.exit(1); }); diff --git a/tools/standalone-html.mjs b/tools/standalone-html.mjs new file mode 100644 index 0000000..c830bcb --- /dev/null +++ b/tools/standalone-html.mjs @@ -0,0 +1,115 @@ +/** + * Assembles one self-contained HTML file for a deck converted from PowerPoint. + * + * Pure string work with no Node APIs, so build-site.mjs and the browser + * converter (convert.html) produce the same file. Callers supply the Reveal, + * theme and slide assets already read into strings. + */ + +const escapeHtml = (value) => + String(value ?? '').replace(/[&<>"']/g, (ch) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[ch]); + +/** Reveal's themes import Google Fonts; assets/theme.css replaces the fonts anyway. */ +export const stripRemoteFonts = (css) => + css.replace(/@import\s+url\(https?:\/\/fonts\.googleapis\.com[^)]*\);?/g, ''); + +/** + * @param {object} o + * @param {string} o.title + * @param {string} [o.source] where it was generated from, for the header comment + * @param {string} [o.lang] source language tag + * @param {{width:number,height:number}} [o.canvas] + * @param {string} o.slidesHtml slides.html as written by the converter (pictures as images/) + * @param {Record} [o.images] picture name -> data URI; embedded once each + * @param {Record} [o.tables] translation tables keyed by language + * @param {{reveal:string, theme:string, shared:string, pptx:string}} o.css + * @param {{reveal:string, i18n:string}} o.js + */ +export function assembleStandaloneHtml(o) { + const canvas = o.canvas ?? { width: 1280, height: 720 }; + // `<` is escaped so no string in a table can close the script element early. + const json = JSON.stringify(o.tables ?? {}).replace(/ { + if (!(name in available)) return m; + used.add(name); + return ` data-img="${escapeHtml(name)}"`; + }) + .replace(/url\(images\/([^)"']+)\)/g, (m, name) => { + if (!(name in available)) return m; + used.add(name); + if (!cssVars.has(name)) cssVars.set(name, `--img-${cssVars.size}`); + return `var(${cssVars.get(name)})`; + }); + const rootCss = cssVars.size ? `:root{${[...cssVars].map(([name, v]) => `${v}:url(${available[name]})`).join(';')}}` : ''; + const imagesJson = JSON.stringify(Object.fromEntries([...used].map((n) => [n, available[n]]))); + slidesHtml = slidesHtml.trim(); + + return ` + + + + + ${escapeHtml(o.title)} + + + + + + ${rootCss ? `` : ''} + + +
+
+${slidesHtml} +
+
+ + + + + + + + +`; +} diff --git a/tools/translate-core.mjs b/tools/translate-core.mjs new file mode 100644 index 0000000..da57607 --- /dev/null +++ b/tools/translate-core.mjs @@ -0,0 +1,356 @@ +// Pure(ish) translation pipeline for decks//strings.json. See docs/string-table.md. +// No I/O except translateLanguage's optional logger; the CLI owns files and exit codes. + +const ALLOWED_TAGS = new Set(['b', 'i', 'u', 'sup', 'sub', 'br', 'a', 'code']); +const DEFAULT_MAX_ITEMS = 30; +const DEFAULT_MAX_CHARS = 6000; + +// FNV-1a 32-bit over UTF-8 bytes, lowercase hex, 8 chars. +export function fnv1a(text) { + let hash = 0x811c9dc5; + for (const byte of Buffer.from(String(text), 'utf8')) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +// ---- inline markup validation ------------------------------------------------- + +const TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)([^<>]*)>/g; +const HREF_RE = /^\s+href\s*=\s*(?:"([^"]*)"|'([^']*)')\s*$/; + +// Returns { tags, hrefs, errors } for an HTML fragment. `tags` entries look like +// "b", "/b" or "br", sorted, so two fragments can be compared as multisets. +export function analyzeMarkup(html) { + const tags = []; + const hrefs = []; + const errors = []; + const stack = []; + + for (const match of html.matchAll(TAG_RE)) { + const closing = match[1] === '/'; + const name = match[2].toLowerCase(); + const attrs = match[3]; + + if (!ALLOWED_TAGS.has(name)) { + errors.push(`disallowed tag <${closing ? '/' : ''}${match[2]}>`); + continue; + } + if (name === 'br') { + if (closing) errors.push('
is not valid'); + else if (!/^\s*\/?\s*$/.test(attrs)) errors.push('
must not have attributes'); + else tags.push('br'); + continue; + } + if (closing) { + if (attrs.trim()) errors.push(` must not have attributes`); + tags.push(`/${name}`); + if (stack.pop() !== name) errors.push(`unbalanced or misnested `); + continue; + } + if (name === 'a') { + const href = HREF_RE.exec(attrs); + if (!href) errors.push(' must have exactly one attribute: href="..."'); + else hrefs.push(href[1] ?? href[2]); + } else if (attrs.trim()) { + errors.push(`<${name}> must not have attributes`); + } + tags.push(name); + stack.push(name); + } + if (stack.length) errors.push(`unclosed tag(s): ${stack.map((t) => `<${t}>`).join('')}`); + + if (html.replace(TAG_RE, '').includes('<')) errors.push('contains an unescaped "<" (use <)'); + + return { tags: tags.sort(), hrefs: hrefs.sort(), errors }; +} + +function countBy(list) { + const counts = new Map(); + for (const item of list) counts.set(item, (counts.get(item) ?? 0) + 1); + return counts; +} + +function diffCounts(expected, actual, label) { + const errors = []; + const e = countBy(expected); + const a = countBy(actual); + for (const key of new Set([...e.keys(), ...a.keys()])) { + if ((e.get(key) ?? 0) !== (a.get(key) ?? 0)) { + errors.push(`${label} ${JSON.stringify(key)}: expected ${e.get(key) ?? 0}, got ${a.get(key) ?? 0}`); + } + } + return errors; +} + +// Returns a list of problems; empty means the translation is acceptable. +export function validateTranslation(sourceText, translated) { + if (typeof translated !== 'string' || !translated.trim()) return ['text is empty or not a string']; + + const source = analyzeMarkup(sourceText); + const result = analyzeMarkup(translated); + const errors = []; + + // Only enforce well-formedness when the source itself is well-formed. + if (!source.errors.length) errors.push(...result.errors); + errors.push(...diffCounts(source.tags, result.tags, 'tag')); + errors.push(...diffCounts(source.hrefs, result.hrefs, 'href')); + return errors; +} + +// ---- planning and batching ---------------------------------------------------- + +// Decides which source strings need translating. +// Returns { kept: Map, todo: SourceString[], dropped: string[] }. +export function planLanguage(table, existing, { force = false } = {}) { + const kept = new Map(); + const todo = []; + const prior = existing?.strings ?? {}; + + for (const item of table.strings) { + if (!item.text.trim()) continue; + const src = fnv1a(item.text); + const entry = prior[item.id]; + const reusable = + !force && + entry && + entry.src === src && + typeof entry.text === 'string' && + validateTranslation(item.text, entry.text).length === 0; + if (reusable) kept.set(item.id, { text: entry.text, src }); + else todo.push(item); + } + const sourceIds = new Set(table.strings.map((s) => s.id)); + const dropped = Object.keys(prior).filter((id) => !sourceIds.has(id)); + return { kept, todo, dropped }; +} + +// Groups consecutive strings (document order keeps a slide together) into batches. +export function makeBatches(items, { maxItems = DEFAULT_MAX_ITEMS, maxChars = DEFAULT_MAX_CHARS } = {}) { + const batches = []; + let current = []; + let chars = 0; + for (const item of items) { + const size = item.text.length + (item.context?.length ?? 0) + item.id.length; + if (current.length && (current.length >= maxItems || chars + size > maxChars)) { + batches.push(current); + current = []; + chars = 0; + } + current.push(item); + chars += size; + } + if (current.length) batches.push(current); + return batches; +} + +// ---- prompting ---------------------------------------------------------------- + +function languageName(code) { + try { + return new Intl.DisplayNames(['en'], { type: 'language' }).of(code) ?? code; + } catch { + return code; + } +} + +function payloadOf(items, sourceLang, targetLang) { + return { + sourceLang, + targetLang, + strings: items.map(({ id, slide, kind, context, text }) => { + const out = { id, slide, kind }; + if (context) out.context = context; + out.text = text; + return out; + }), + }; +} + +function systemPrompt(sourceLang, targetLang) { + return [ + `You are a professional translator localizing slide deck text from ${languageName(sourceLang)} (${sourceLang}) into ${languageName(targetLang)} (${targetLang}).`, + 'You receive a JSON object whose "strings" array holds slide text. Each item has: id, slide (1-based), kind, optional context (usually the slide title) and text.', + 'Use slide, context and neighbouring items to pick the right sense of short fragments (e.g. "Save", "Tool"), but translate only the "text" of each item.', + 'Kinds: "text" = slide text; "table" = table cell (keep it short, match the column style); "alt" = image alt text (a concise description for screen readers); "notes" = speaker notes (natural spoken style).', + 'The text is an HTML fragment. Keep every tag (, , , , , ,
,
) and close each one; move tags to follow the new word order if needed. Never translate the contents of .', + 'Never change, translate or drop an href value. Keep entities (< > &) escaped. Do not add tags, markdown or commentary.', + 'Leave product names, code, URLs and untranslatable proper nouns as they are. Do not leave a string empty.', + 'Reply with JSON only, exactly: {"translations": {"": "", ...}} with one entry for every requested id and no other ids.', + ].join('\n'); +} + +export function buildMessages(items, sourceLang, targetLang) { + return [ + { role: 'system', content: systemPrompt(sourceLang, targetLang) }, + { role: 'user', content: `Translate these strings.\n${JSON.stringify(payloadOf(items, sourceLang, targetLang))}` }, + ]; +} + +function buildRetryMessages(messages, rawReply, items, failures, sourceLang, targetLang) { + const problems = items.map((item) => `- ${item.id}: ${failures.get(item.id).join('; ')}`).join('\n'); + const out = [...messages]; + if (rawReply != null) out.push({ role: 'assistant', content: rawReply }); + out.push({ + role: 'user', + content: + `Your previous reply had problems:\n${problems}\n` + + 'Fix them and reply with JSON only, containing translations for exactly these strings.\n' + + JSON.stringify(payloadOf(items, sourceLang, targetLang)), + }); + return out; +} + +// Extracts {id: text} from a model reply. Throws Error with a message fit for feeding back. +export function parseResponse(raw) { + let body = String(raw).trim(); + const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(body); + if (fence) body = fence[1]; + let data; + try { + data = JSON.parse(body); + } catch { + const start = body.indexOf('{'); + const end = body.lastIndexOf('}'); + try { + data = JSON.parse(body.slice(start, end + 1)); + } catch { + throw new Error('reply is not valid JSON'); + } + } + const translations = data?.translations; + if (!translations || typeof translations !== 'object' || Array.isArray(translations)) { + throw new Error('reply must be {"translations": {"": ""}}'); + } + return translations; +} + +// Checks a parsed reply against the requested items. +// Returns { ok: Map, bad: Map, batchError?: string }. +function evaluate(translations, items) { + const requested = new Set(items.map((i) => i.id)); + const extras = Object.keys(translations).filter((id) => !requested.has(id)); + if (extras.length) { + const message = `unexpected id(s) not requested: ${extras.join(', ')}`; + return { ok: new Map(), bad: new Map(items.map((i) => [i.id, [message]])), batchError: message }; + } + const ok = new Map(); + const bad = new Map(); + for (const item of items) { + if (!Object.hasOwn(translations, item.id)) { + bad.set(item.id, ['missing from reply']); + continue; + } + const problems = validateTranslation(item.text, translations[item.id]); + if (problems.length) bad.set(item.id, problems); + else ok.set(item.id, translations[item.id]); + } + return { ok, bad }; +} + +// Translates one batch; failed strings get exactly one retry with the errors fed back. +// Returns { translated: Map, failed: Map }. Fatal provider errors propagate. +export async function translateBatch({ provider, items, sourceLang, targetLang, signal }) { + const messages = buildMessages(items, sourceLang, targetLang); + const translated = new Map(); + let pending = items; + let failures = new Map(); + let conversation = messages; + let rawReply = null; + + for (let attempt = 0; attempt < 2 && pending.length; attempt++) { + if (attempt === 1) { + conversation = buildRetryMessages(messages, rawReply, pending, failures, sourceLang, targetLang); + } + rawReply = null; + try { + rawReply = await provider.complete({ messages: conversation, signal }); + const result = evaluate(parseResponse(rawReply), pending); + for (const [id, text] of result.ok) translated.set(id, text); + failures = result.bad; + } catch (err) { + if (err?.fatal) throw err; + failures = new Map(pending.map((i) => [i.id, [err.message]])); + } + pending = pending.filter((i) => failures.has(i.id)); + } + return { translated, failed: new Map(pending.map((i) => [i.id, failures.get(i.id)])) }; +} + +// ---- one language end to end -------------------------------------------------- + +// Returns { file, stats } where `file` is the new strings..json content (object). +export async function translateLanguage({ table, existing, lang, provider, force = false, batchOptions, log = () => {} }) { + const { kept, todo, dropped } = planLanguage(table, existing, { force }); + const entries = new Map(kept); + const failed = new Map(); + const batches = makeBatches(todo, batchOptions); + + for (const [index, items] of batches.entries()) { + log(` batch ${index + 1}/${batches.length} (${items.length} strings)`); + const result = await translateBatch({ + provider, + items, + sourceLang: table.sourceLang, + targetLang: lang, + }); + for (const item of items) { + if (result.translated.has(item.id)) { + entries.set(item.id, { text: result.translated.get(item.id), src: fnv1a(item.text) }); + } + } + for (const [id, errors] of result.failed) failed.set(id, errors); + } + + // Source order, so diffs stay stable. + const strings = {}; + for (const item of table.strings) { + if (entries.has(item.id)) strings[item.id] = entries.get(item.id); + } + return { + file: { version: 1, lang, sourceLang: table.sourceLang, strings }, + stats: { + kept: kept.size, + translated: todo.length - failed.size, + failed, + dropped, + batches: batches.length, + }, + }; +} + +// One entry per line keeps diffs reviewable (matches docs/examples/strings.de.json). +export function serializeTranslation(file) { + const ids = Object.keys(file.strings); + const lines = ids.map( + (id, i) => + ` ${JSON.stringify(id)}: { "text": ${JSON.stringify(file.strings[id].text)}, "src": ${JSON.stringify(file.strings[id].src)} }${i < ids.length - 1 ? ',' : ''}`, + ); + return [ + '{', + ` "version": ${file.version},`, + ` "lang": ${JSON.stringify(file.lang)},`, + ` "sourceLang": ${JSON.stringify(file.sourceLang)},`, + ids.length ? ' "strings": {' : ' "strings": {}', + ...(ids.length ? [...lines, ' }'] : []), + '}', + '', + ].join('\n'); +} + +// Throws a descriptive Error if the source table breaks the v1 contract. +export function validateSourceTable(table) { + if (!table || typeof table !== 'object') throw new Error('strings.json is not an object'); + if (table.version !== 1) throw new Error(`Unsupported strings.json version: ${table.version} (expected 1)`); + if (typeof table.sourceLang !== 'string' || !table.sourceLang) throw new Error('strings.json: missing sourceLang'); + if (!Array.isArray(table.strings)) throw new Error('strings.json: "strings" must be an array'); + const seen = new Set(); + for (const item of table.strings) { + if (!item || typeof item.id !== 'string' || typeof item.text !== 'string') { + throw new Error('strings.json: every string needs a string "id" and "text"'); + } + if (seen.has(item.id)) throw new Error(`strings.json: duplicate id ${item.id}`); + seen.add(item.id); + } +} diff --git a/tools/translate.mjs b/tools/translate.mjs new file mode 100644 index 0000000..5099243 --- /dev/null +++ b/tools/translate.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node +// Translate a deck's string table with an LLM. See docs/string-table.md for the contract. +// +// node tools/translate.mjs --to de,fr [--force] [--dry-run] +// [--provider copilot|openai|mock] [--model ] [--base-url ] +// +// Exit codes: 0 ok, 1 error (nothing partial written for the failing language), +// 2 finished but some strings were skipped (they fall back to source in the viewer). + +import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createProvider, PROVIDERS } from './llm-provider.mjs'; +import { planLanguage, makeBatches, serializeTranslation, translateLanguage, validateSourceTable } from './translate-core.mjs'; + +const USAGE = `Usage: node tools/translate.mjs --to [,...] [options] + +Options: + --to Comma-separated BCP 47 tags, e.g. de,pt-BR,ar (required) + --force Retranslate every string, ignoring existing translations + --dry-run Show what would be translated; no LLM calls, nothing written + --provider ${PROVIDERS.join(' | ')} (default: copilot) + --model Model name (or LLM_MODEL); copilot defaults to the CLI's own model + --base-url OpenAI-compatible endpoint (or LLM_BASE_URL); openai provider only + --batch-size Max strings per request (default 30) + --decks-dir Decks directory (default: /decks) + -h, --help Show this help + +Provider "copilot" runs the GitHub Copilot CLI (sign in once with \`copilot\`); it uses the +models in your Copilot subscription and needs no key. Optional: LLM_REASONING_EFFORT, COPILOT_BIN. +Provider "openai": LLM_API_KEY or OPENAI_API_KEY, LLM_MODEL, LLM_BASE_URL, +LLM_AUTH_HEADER=bearer|api-key, LLM_JSON_MODE=off.`; + +const LANG_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/; +const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +class UsageError extends Error {} + +export function parseArgs(argv) { + const opts = { force: false, dryRun: false, provider: 'copilot', langs: [] }; + const positional = []; + const takeValue = (i, flag) => { + if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new UsageError(`${flag} needs a value`); + return argv[i + 1]; + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '-h': + case '--help': + opts.help = true; + break; + case '--force': + opts.force = true; + break; + case '--dry-run': + opts.dryRun = true; + break; + case '--to': + opts.langs.push(...takeValue(i++, arg).split(',').map((s) => s.trim()).filter(Boolean)); + break; + case '--provider': + opts.provider = takeValue(i++, arg); + break; + case '--model': + opts.model = takeValue(i++, arg); + break; + case '--base-url': + opts.baseUrl = takeValue(i++, arg); + break; + case '--batch-size': + opts.batchSize = Number(takeValue(i++, arg)); + break; + case '--decks-dir': + opts.decksDir = takeValue(i++, arg); + break; + default: + if (arg.startsWith('-')) throw new UsageError(`Unknown option ${arg}`); + positional.push(arg); + } + } + if (opts.help) return opts; + if (positional.length !== 1) throw new UsageError('Expected exactly one '); + opts.slug = positional[0]; + if (!SLUG_RE.test(opts.slug)) throw new UsageError(`Invalid slug "${opts.slug}"`); + if (!opts.langs.length) throw new UsageError('--to is required'); + for (const lang of opts.langs) { + if (!LANG_RE.test(lang)) throw new UsageError(`Invalid language tag "${lang}" (expected BCP 47, e.g. de, pt-BR)`); + } + opts.langs = [...new Set(opts.langs)]; + if (opts.batchSize !== undefined && !(Number.isInteger(opts.batchSize) && opts.batchSize > 0)) { + throw new UsageError('--batch-size must be a positive integer'); + } + return opts; +} + +async function readJson(file, what) { + let raw; + try { + raw = await readFile(file, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return { missing: true }; + throw err; + } + try { + return { raw, data: JSON.parse(raw) }; + } catch { + throw new Error(`${what} is not valid JSON: ${file}`); + } +} + +async function writeAtomic(file, content) { + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, content, 'utf8'); + await rename(tmp, file); +} + +// `deps` lets tests inject env, streams, a decks directory and a ready-made provider. +export async function run(argv, deps = {}) { + const out = deps.stdout ?? ((line) => console.log(line)); + const err = deps.stderr ?? ((line) => console.error(line)); + + let opts; + try { + opts = parseArgs(argv); + } catch (e) { + if (!(e instanceof UsageError)) throw e; + err(`${e.message}\n\n${USAGE}`); + return 1; + } + if (opts.help) { + out(USAGE); + return 0; + } + + try { + const decksDir = path.resolve(opts.decksDir ?? deps.decksDir ?? path.join(REPO_ROOT, 'decks')); + const sourceFile = path.join(decksDir, opts.slug, 'strings.json'); + const source = await readJson(sourceFile, 'strings.json'); + if (source.missing) throw new Error(`No source table at ${sourceFile}. Run the PPTX converter first.`); + const table = source.data; + validateSourceTable(table); + + const langs = opts.langs.filter((lang) => { + const same = lang.toLowerCase() === table.sourceLang.toLowerCase(); + if (same) err(`Skipping ${lang}: same as sourceLang`); + return !same; + }); + + let provider = null; + if (!opts.dryRun && langs.length) { + provider = deps.provider ?? createProvider(opts.provider, { env: deps.env, model: opts.model, baseUrl: opts.baseUrl, fetch: deps.fetch, sleep: deps.sleep }); + } + const batchOptions = opts.batchSize ? { maxItems: opts.batchSize } : undefined; + + let skipped = 0; + for (const lang of langs) { + const target = path.join(decksDir, opts.slug, `strings.${lang}.json`); + const prior = await readJson(target, path.basename(target)).catch((e) => { + if (!opts.force) throw e; + return { missing: false, raw: null, data: null }; + }); + if (!prior.missing && !opts.force) { + const d = prior.data; + if (d?.version !== 1 || typeof d.strings !== 'object' || d.strings === null) { + throw new Error(`${target} is not a v1 translation file; fix it or re-run with --force`); + } + } + // With --force the old file is ignored entirely. + const existing = prior.missing || opts.force ? null : prior.data; + const sameSource = existing && existing.sourceLang === table.sourceLang ? existing : null; + + if (opts.dryRun) { + const plan = planLanguage(table, sameSource, { force: opts.force }); + const batches = makeBatches(plan.todo, batchOptions); + out(`${lang}: ${plan.todo.length} to translate in ${batches.length} batch(es), ${plan.kept.size} up to date, ${plan.dropped.length} orphaned`); + for (const item of plan.todo) out(` ${item.id} [${item.kind}]`); + continue; + } + + out(`${lang}: translating with ${provider.name}...`); + const { file, stats } = await translateLanguage({ + table, + existing: sameSource, + lang, + provider, + force: opts.force, + batchOptions, + log: out, + }); + + const serialized = serializeTranslation(file); + if (prior.missing || prior.raw !== serialized) { + await mkdir(path.dirname(target), { recursive: true }); + await writeAtomic(target, serialized); + } + const shown = path.relative(process.cwd(), target); + out(`${lang}: ${stats.translated} translated, ${stats.kept} kept, ${stats.failed.size} skipped -> ${shown.startsWith('..') ? target : shown}`); + for (const [id, errors] of stats.failed) { + err(` skipped ${id}: ${errors.join('; ')}`); + skipped++; + } + } + if (skipped) err(`${skipped} string(s) skipped; they will fall back to the source text. Re-run to retry them.`); + return skipped ? 2 : 0; + } catch (e) { + err(`Error: ${e.message}`); + return 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + process.exitCode = await run(process.argv.slice(2)); +} diff --git a/tools/translate.test.mjs b/tools/translate.test.mjs new file mode 100644 index 0000000..25f8c59 --- /dev/null +++ b/tools/translate.test.mjs @@ -0,0 +1,460 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, mkdir, readFile, writeFile, copyFile, rm, access } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { fnv1a, validateTranslation, planLanguage, makeBatches, translateBatch, translateLanguage, parseResponse } from './translate-core.mjs'; +import { createProvider, parseRetryAfter, ProviderError } from './llm-provider.mjs'; +import { run } from './translate.mjs'; + +const EXAMPLES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'examples'); +const SOURCE_TABLE = JSON.parse(await readFile(path.join(EXAMPLES, 'strings.json'), 'utf8')); + +async function makeDecksDir({ withGerman = false } = {}) { + const decksDir = await mkdtemp(path.join(os.tmpdir(), 'translate-test-')); + const deck = path.join(decksDir, 'demo'); + await mkdir(deck); + await copyFile(path.join(EXAMPLES, 'strings.json'), path.join(deck, 'strings.json')); + if (withGerman) await copyFile(path.join(EXAMPLES, 'strings.de.json'), path.join(deck, 'strings.de.json')); + return { decksDir, deck, cleanup: () => rm(decksDir, { recursive: true, force: true }) }; +} + +function cli(argv, deps) { + const stdout = []; + const stderr = []; + return run(argv, { ...deps, stdout: (l) => stdout.push(l), stderr: (l) => stderr.push(l) }).then((code) => ({ code, stdout, stderr })); +} + +// Provider stub that answers from a script of functions(items) => reply string. +function scripted(...replies) { + const calls = []; + return { + name: 'scripted', + calls, + async complete({ messages }) { + calls.push(messages); + const reply = replies[Math.min(calls.length - 1, replies.length - 1)]; + return typeof reply === 'function' ? reply(messages) : reply; + }, + }; +} +const payloadOf = (messages) => JSON.parse(messages.at(-1).content.split('\n').at(-1)); +const reply = (map) => JSON.stringify({ translations: map }); + +// ---- hash --------------------------------------------------------------------- + +test('fnv1a matches reference vectors and the docs/examples pair', () => { + assert.equal(fnv1a(''), '811c9dc5'); + assert.equal(fnv1a('a'), 'e40c292c'); + assert.equal(fnv1a('foobar'), 'bf9cf968'); + assert.equal(fnv1a('Decks as Code'), '2218a577'); + assert.equal(fnv1a('Replacing PPTX with plain-text presentations'), '2463d0d5'); +}); + +test('fnv1a hashes UTF-8 bytes, not UTF-16 code units', () => { + // "é" is bytes C3 A9: fnv1a = (((811c9dc5 ^ c3) * 01000193) ^ a9) * 01000193 + let h = 0x811c9dc5; + for (const b of [0xc3, 0xa9]) h = Math.imul(h ^ b, 0x01000193) >>> 0; + assert.equal(fnv1a('é'), h.toString(16).padStart(8, '0')); + assert.equal(fnv1a('x').length, 8); +}); + +// ---- validation --------------------------------------------------------------- + +test('validation accepts reordered tags and rejects broken ones', () => { + const src = 'Read the docs first.
Then try it.'; + assert.deepEqual(validateTranslation(src, 'Zuerst die Doku lesen.
Dann ausprobieren.'), []); + assert.deepEqual(validateTranslation(src, 'Zuerst die Doku lesen.
Dann.'), []); + + assert.ok(validateTranslation(src, 'Lesen. Dann.').length, 'dropped tags'); + assert.ok(validateTranslation('x', 'x').length, 'unclosed'); + assert.ok(validateTranslation('x y', 'x y').length, 'misnested'); + assert.ok(validateTranslation('x', 'xy').length, 'extra tag'); + assert.ok(validateTranslation('x', 'x').length, 'attributes'); + assert.ok(validateTranslation('x', '').length, 'disallowed tag'); + assert.ok(validateTranslation('1 < 2', '1 < 2').length, 'unescaped <'); +}); + +test(' is an allowed inline tag and must be kept; new-style ids are opaque', async () => { + assert.deepEqual(validateTranslation('Run npm test now', 'Jetzt npm test ausführen'), []); + assert.ok(validateTranslation('Run npm test now', 'Jetzt npm test ausführen').length); + + const table = { version: 1, sourceLang: 'en', strings: [{ id: 's3.L.sh7.p2', slide: 3, kind: 'text', text: 'Use x' }] }; + const { file } = await translateLanguage({ table, existing: null, lang: 'de', provider: createProvider('mock') }); + assert.equal(file.strings['s3.L.sh7.p2'].text, '[de: Use x]'); +}); + +test('validation requires href values to be unchanged', () => { + const src = 'docs'; + assert.ok(validateTranslation(src, 'Doku').length); + assert.ok(validateTranslation(src, 'Doku').length); + assert.ok(validateTranslation(src, 'Doku').length); + assert.deepEqual(validateTranslation(src, 'Doku'), []); +}); + +test('validation rejects empty and non-string text', () => { + for (const bad of ['', ' ', null, undefined, 42]) { + assert.ok(validateTranslation('Save', bad).length, String(bad)); + } +}); + +// ---- incremental planning ----------------------------------------------------- + +test('plan skips fresh translations and re-queues stale or missing ones', () => { + const existing = JSON.parse(JSON.stringify({ + version: 1, lang: 'de', sourceLang: 'en', + strings: { + 's1.sh2.p1': { text: 'Decks als Code', src: '2218a577' }, + 's1.sh3.p1': { text: 'alt', src: 'deadbeef' }, + 'gone.p1': { text: 'orphan', src: '00000000' }, + }, + })); + const plan = planLanguage(SOURCE_TABLE, existing); + assert.deepEqual([...plan.kept.keys()], ['s1.sh2.p1']); + assert.equal(plan.todo.length, SOURCE_TABLE.strings.length - 1); + assert.ok(plan.todo.some((s) => s.id === 's1.sh3.p1'), 'stale hash re-queued'); + assert.deepEqual(plan.dropped, ['gone.p1']); + + assert.equal(planLanguage(SOURCE_TABLE, existing, { force: true }).todo.length, SOURCE_TABLE.strings.length); +}); + +test('plan re-queues a matching hash whose stored text is invalid', () => { + const existing = { strings: { 's1.sh2.p1': { text: 'Decks als Code', src: '2218a577' } } }; + assert.ok(planLanguage(SOURCE_TABLE, existing).todo.some((s) => s.id === 's1.sh2.p1')); +}); + +test('batches stay in document order and respect the size limit', () => { + const batches = makeBatches(SOURCE_TABLE.strings, { maxItems: 3 }); + assert.deepEqual(batches.map((b) => b.length), [3, 3, 2]); + assert.deepEqual(batches.flat().map((s) => s.id), SOURCE_TABLE.strings.map((s) => s.id)); +}); + +test('CLI only translates what is missing or stale, then is a no-op', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir({ withGerman: true }); + try { + const provider = scripted((m) => reply(Object.fromEntries(payloadOf(m).strings.map((s) => [s.id, `DE ${s.text}`])))); + const first = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(first.code, 0); + const requested = provider.calls.flatMap((m) => payloadOf(m).strings.map((s) => s.id)); + assert.equal(requested.length, SOURCE_TABLE.strings.length - 2); + assert.ok(!requested.includes('s1.sh2.p1') && !requested.includes('s1.sh3.p1')); + + const written = JSON.parse(await readFile(path.join(deck, 'strings.de.json'), 'utf8')); + assert.equal(written.strings['s1.sh2.p1'].text, 'Decks als Code'); + assert.equal(Object.keys(written.strings).length, SOURCE_TABLE.strings.length); + + const before = provider.calls.length; + const second = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(second.code, 0); + assert.equal(provider.calls.length, before, 'nothing left to translate, no LLM call'); + + // Edit one source string: only it is retranslated. + const table = JSON.parse(await readFile(path.join(deck, 'strings.json'), 'utf8')); + table.strings[2].text = 'Why bother?'; + await writeFile(path.join(deck, 'strings.json'), JSON.stringify(table)); + await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.deepEqual(payloadOf(provider.calls.at(-1)).strings.map((s) => s.id), ['s2.sh2.p1']); + + await cli(['demo', '--to', 'de', '--force'], { decksDir, provider }); + const forced = provider.calls.slice(before + 1).flatMap((m) => payloadOf(m).strings); + assert.equal(forced.length, SOURCE_TABLE.strings.length); + } finally { + await cleanup(); + } +}); + +// ---- retry then skip ---------------------------------------------------------- + +test('a failed string is retried once with the error fed back, then succeeds', async () => { + const items = SOURCE_TABLE.strings.slice(0, 2); + const provider = scripted( + reply({ 's1.sh2.p1': 'Decks als Code', 's1.sh3.p1': 'Klartext' }), // first drops the tags + (m) => reply({ 's1.sh2.p1': 'Decks als Code' }), + ); + const result = await translateBatch({ provider, items, sourceLang: 'en', targetLang: 'de' }); + assert.equal(provider.calls.length, 2); + assert.deepEqual([...result.translated.keys()].sort(), ['s1.sh2.p1', 's1.sh3.p1']); + assert.equal(result.failed.size, 0); + + const retry = provider.calls[1]; + const feedback = retry.at(-1).content; + assert.match(feedback, /s1\.sh2\.p1/); + assert.match(feedback, /tag "b"/); + assert.equal(retry.at(-2).role, 'assistant'); + assert.deepEqual(payloadOf(retry).strings.map((s) => s.id), ['s1.sh2.p1'], 'only the failed string is retried'); +}); + +test('strings that still fail after one retry are skipped, reported, and the file stays valid', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const provider = scripted((m) => { + const map = Object.fromEntries(payloadOf(m).strings.map((s) => [s.id, `X ${s.text}`])); + map['s1.sh2.p1'] = 'Decks als Code'; // always loses the tags + return reply(map); + }); + const result = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(result.code, 2); + assert.match(result.stderr.join('\n'), /skipped s1\.sh2\.p1/); + + const file = JSON.parse(await readFile(path.join(deck, 'strings.de.json'), 'utf8')); + assert.equal(file.version, 1); + assert.ok(!('s1.sh2.p1' in file.strings)); + assert.equal(Object.keys(file.strings).length, SOURCE_TABLE.strings.length - 1); + for (const [id, entry] of Object.entries(file.strings)) { + const src = SOURCE_TABLE.strings.find((s) => s.id === id); + assert.equal(entry.src, fnv1a(src.text)); + assert.deepEqual(validateTranslation(src.text, entry.text), []); + } + } finally { + await cleanup(); + } +}); + +test('extra ids, invalid JSON and provider errors fail the batch and then retry', async () => { + const items = SOURCE_TABLE.strings.slice(1, 2); + const ok = reply({ 's1.sh3.p1': 'Klartext' }); + + const extras = scripted(reply({ 's1.sh3.p1': 'Klartext', bogus: 'x' }), ok); + assert.equal((await translateBatch({ provider: extras, items, sourceLang: 'en', targetLang: 'de' })).failed.size, 0); + + const junk = scripted('not json at all', ok); + assert.equal((await translateBatch({ provider: junk, items, sourceLang: 'en', targetLang: 'de' })).failed.size, 0); + + let n = 0; + const flaky = { name: 'flaky', complete: async () => { if (n++ === 0) throw new ProviderError('HTTP 500'); return ok; } }; + assert.equal((await translateBatch({ provider: flaky, items, sourceLang: 'en', targetLang: 'de' })).failed.size, 0); + + const dead = scripted('nope', 'still nope'); + const result = await translateBatch({ provider: dead, items, sourceLang: 'en', targetLang: 'de' }); + assert.equal(dead.calls.length, 2, 'exactly one retry'); + assert.equal(result.failed.size, 1); +}); + +test('fatal provider errors abort without writing a file', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const provider = { name: 'bad-auth', complete: async () => { throw new ProviderError('HTTP 401', { status: 401, fatal: true }); } }; + const result = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(result.code, 1); + await assert.rejects(access(path.join(deck, 'strings.de.json'))); + } finally { + await cleanup(); + } +}); + +test('parseResponse tolerates code fences and surrounding prose', () => { + assert.deepEqual(parseResponse('```json\n{"translations":{"a":"b"}}\n```'), { a: 'b' }); + assert.deepEqual(parseResponse('Here you go: {"translations":{"a":"b"}} Done.'), { a: 'b' }); + assert.throws(() => parseResponse('{"a":"b"}')); +}); + +// ---- RTL / fallback ----------------------------------------------------------- + +test('RTL languages use the same file shape; skipped strings are simply absent (viewer falls back to source)', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const provider = scripted((m) => { + const map = Object.fromEntries(payloadOf(m).strings.map((s) => [s.id, `ع ${s.text}`])); + delete map['s2.notes.p1']; + return reply(map); + }); + const result = await cli(['demo', '--to', 'ar'], { decksDir, provider }); + assert.equal(result.code, 2); + const file = JSON.parse(await readFile(path.join(deck, 'strings.ar.json'), 'utf8')); + assert.deepEqual(Object.keys(file).sort(), ['lang', 'sourceLang', 'strings', 'version'], 'no dir/rtl field; the viewer derives it from lang'); + assert.equal(file.lang, 'ar'); + assert.ok(!('s2.notes.p1' in file.strings)); + assert.ok('s2.sh3.p2' in file.strings); + } finally { + await cleanup(); + } +}); + +// ---- mock provider end to end ------------------------------------------------- + +test('mock provider end to end against docs/examples/strings.json', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const result = await cli(['demo', '--to', 'de,pt-BR', '--provider', 'mock'], { decksDir }); + assert.equal(result.code, 0, result.stderr.join('\n')); + + for (const lang of ['de', 'pt-BR']) { + const file = JSON.parse(await readFile(path.join(deck, `strings.${lang}.json`), 'utf8')); + assert.equal(file.version, 1); + assert.equal(file.lang, lang); + assert.equal(file.sourceLang, 'en'); + assert.deepEqual(Object.keys(file.strings), SOURCE_TABLE.strings.map((s) => s.id), 'source order'); + for (const s of SOURCE_TABLE.strings) { + const entry = file.strings[s.id]; + assert.equal(entry.text, `[${lang}: ${s.text}]`); + assert.equal(entry.src, fnv1a(s.text)); + assert.match(entry.src, /^[0-9a-f]{8}$/); + assert.deepEqual(validateTranslation(s.text, entry.text), []); + } + } + + const again = await cli(['demo', '--to', 'de', '--provider', 'mock'], { decksDir }); + assert.match(again.stdout.join('\n'), /0 translated, 8 kept/); + } finally { + await cleanup(); + } +}); + +test('mock translations also pass through the provider via the retry conversation', async () => { + const mock = createProvider('mock'); + const items = SOURCE_TABLE.strings.slice(0, 2); + const result = await translateBatch({ provider: mock, items, sourceLang: 'en', targetLang: 'fr' }); + assert.equal(result.translated.get('s1.sh2.p1'), '[fr: Decks as Code]'); +}); + +test('--dry-run makes no provider calls and writes nothing', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir({ withGerman: true }); + try { + const result = await cli(['demo', '--to', 'de,fr', '--dry-run'], { decksDir, env: {} }); + assert.equal(result.code, 0); + assert.match(result.stdout.join('\n'), /de: 6 to translate/); + assert.match(result.stdout.join('\n'), /fr: 8 to translate/); + await assert.rejects(access(path.join(deck, 'strings.fr.json'))); + assert.equal(await readFile(path.join(deck, 'strings.de.json'), 'utf8'), await readFile(path.join(EXAMPLES, 'strings.de.json'), 'utf8')); + } finally { + await cleanup(); + } +}); + +test('CLI rejects bad arguments', async () => { + const { decksDir, cleanup } = await makeDecksDir(); + try { + for (const argv of [[], ['demo'], ['demo', '--to', 'de/../x'], ['../demo', '--to', 'de'], ['demo', '--to', 'de', '--bogus']]) { + assert.equal((await cli(argv, { decksDir })).code, 1, argv.join(' ')); + } + assert.equal((await cli(['missing', '--to', 'de', '--provider', 'mock'], { decksDir })).code, 1); + } finally { + await cleanup(); + } +}); + +// ---- openai-compatible provider (fake fetch, no network) ---------------------- + +const KEY = 'sk-test-secret-123'; +const okResponse = (content) => new Response(JSON.stringify({ choices: [{ message: { content }, finish_reason: 'stop' }] }), { status: 200 }); + +test('openai provider honours retry-after on 429 and never leaks the key', async () => { + const sleeps = []; + const requests = []; + const responses = [ + new Response('slow down', { status: 429, headers: { 'retry-after': '7' } }), + new Response('slow down', { status: 429, headers: { 'retry-after': '2' } }), + okResponse('hello'), + ]; + const provider = createProvider('openai', { + env: { LLM_API_KEY: KEY, LLM_MODEL: 'm' }, + baseUrl: 'https://llm.example.com/v1/', + sleep: async (ms) => sleeps.push(ms), + fetch: async (url, init) => { requests.push({ url: String(url), init }); return responses.shift(); }, + }); + assert.equal(await provider.complete({ messages: [{ role: 'user', content: 'hi' }] }), 'hello'); + assert.deepEqual(sleeps, [7000, 2000]); + assert.equal(requests[0].url, 'https://llm.example.com/v1/chat/completions'); + assert.equal(requests[0].init.headers.authorization, `Bearer ${KEY}`); + const body = JSON.parse(requests[0].init.body); + assert.equal(body.model, 'm'); + assert.deepEqual(body.response_format, { type: 'json_object' }); +}); + +test('openai provider: api-key header, 401 is fatal, error text is redacted', async () => { + const seen = []; + const provider = createProvider('openai', { + env: { LLM_API_KEY: KEY, LLM_MODEL: 'm', LLM_AUTH_HEADER: 'api-key', LLM_JSON_MODE: 'off' }, + baseUrl: 'https://x.openai.azure.com/openai/v1', + sleep: async () => {}, + fetch: async (url, init) => { seen.push(init); return new Response(`bad key ${KEY}`, { status: 401 }); }, + }); + await assert.rejects( + provider.complete({ messages: [] }), + (err) => err instanceof ProviderError && err.fatal && !err.message.includes(KEY) && err.message.includes('[redacted]'), + ); + assert.equal(seen[0].headers['api-key'], KEY); + assert.equal(seen[0].headers.authorization, undefined); + assert.equal(JSON.parse(seen[0].body).response_format, undefined); +}); + +test('openai provider gives up on persistent 429 and retries 5xx', async () => { + let calls = 0; + const provider = createProvider('openai', { + env: { LLM_API_KEY: KEY, LLM_MODEL: 'm' }, + sleep: async () => {}, + fetch: async () => { calls++; return new Response('', { status: 429 }); }, + }); + await assert.rejects(provider.complete({ messages: [] }), /429/); + assert.equal(calls, 6); + + const flaky = [new Response('', { status: 503 }), okResponse('fine')]; + const p2 = createProvider('openai', { env: { LLM_API_KEY: KEY, LLM_MODEL: 'm' }, sleep: async () => {}, fetch: async () => flaky.shift() }); + assert.equal(await p2.complete({ messages: [] }), 'fine'); +}); + +test('openai provider config errors are explicit and fatal', () => { + const base = { LLM_API_KEY: KEY, LLM_MODEL: 'm' }; + assert.throws(() => createProvider('openai', { env: { LLM_MODEL: 'm' } }), /API key/); + assert.throws(() => createProvider('openai', { env: { LLM_API_KEY: KEY } }), /model/i); + assert.throws(() => createProvider('openai', { env: base, baseUrl: 'http://example.com/v1' }), /https/); + assert.doesNotThrow(() => createProvider('openai', { env: base, baseUrl: 'http://localhost:11434/v1' })); + assert.throws(() => createProvider('nope'), /Unknown provider/); +}); + +test('copilot provider sends the prompt on stdin with tools disabled and returns stdout', async () => { + const calls = []; + const spawn = (command, args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => {}; + child.stdin = { on() {}, end(text) { calls.push({ command, args, stdin: text }); setImmediate(() => { child.stdout.emit('data', '{"translations":{}}\n'); child.emit('close', 0); }); } }; + return child; + }; + const provider = createProvider('copilot', { env: {}, model: 'some-model', spawn }); + const out = await provider.complete({ messages: [{ role: 'system', content: 'SYS' }, { role: 'user', content: 'USER' }] }); + assert.equal(out, '{"translations":{}}'); + assert.equal(calls[0].command, 'copilot'); + assert.deepEqual(calls[0].args.slice(calls[0].args.indexOf('--available-tools'), calls[0].args.indexOf('--available-tools') + 2), ['--available-tools', '']); + assert.ok(calls[0].args.includes('--no-custom-instructions')); + assert.deepEqual(calls[0].args.slice(-2), ['--model', 'some-model']); + assert.ok(calls[0].stdin.includes('[system]\nSYS') && calls[0].stdin.includes('[user]\nUSER')); + assert.ok(!calls[0].args.some((a) => a.includes('USER')), 'prompt must not be on the command line'); +}); + +test('copilot provider failures: missing CLI and sign-in problems are fatal, others are not', async () => { + const failing = (emit) => () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => {}; + child.stdin = { on() {}, end() { setImmediate(() => emit(child)); } }; + return child; + }; + const messages = [{ role: 'user', content: 'x' }]; + const missing = createProvider('copilot', { env: {}, spawn: failing((c) => c.emit('error', Object.assign(new Error('spawn copilot ENOENT'), { code: 'ENOENT' }))) }); + await assert.rejects(missing.complete({ messages }), (e) => e instanceof ProviderError && e.fatal && /not found/.test(e.message)); + + const signedOut = createProvider('copilot', { env: {}, spawn: failing((c) => { c.stderr.emit('data', 'Please log in with copilot login'); c.emit('close', 1); }) }); + await assert.rejects(signedOut.complete({ messages }), (e) => e.fatal === true); + + const flaky = createProvider('copilot', { env: {}, spawn: failing((c) => { c.stderr.emit('data', 'something odd'); c.emit('close', 1); }) }); + await assert.rejects(flaky.complete({ messages }), (e) => e.fatal === false); +}); + +test('retry-after parsing handles seconds and HTTP dates', () => { + assert.equal(parseRetryAfter('3'), 3000); + assert.equal(parseRetryAfter('Wed, 21 Oct 2015 07:28:05 GMT', Date.parse('Wed, 21 Oct 2015 07:28:00 GMT')), 5000); + assert.equal(parseRetryAfter(null), null); + assert.equal(parseRetryAfter('soon'), null); +}); + +test('translateLanguage output is stable for a ready-made provider', async () => { + const { file, stats } = await translateLanguage({ table: SOURCE_TABLE, existing: null, lang: 'de', provider: createProvider('mock') }); + assert.equal(stats.translated, SOURCE_TABLE.strings.length); + assert.equal(file.strings['s2.sh4.r1c1.p1'].text, '[de: Tool]'); +}); diff --git a/tools/xml-lite.mjs b/tools/xml-lite.mjs new file mode 100644 index 0000000..2ccc0b2 --- /dev/null +++ b/tools/xml-lite.mjs @@ -0,0 +1,106 @@ +/** + * A small XML parser for OOXML parts. Runs unchanged in Node and the browser + * (no DOMParser), and returns a plain tree that is cheap to walk: + * + * { name: 'a:p', attrs: { ... }, children: [...elements], text: 'own text' } + * + * Prefixes are kept as written. PowerPoint always uses the same ones (p:, a:, + * r:, mc:), and every part is read through them. + */ + +const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" }; + +export const decodeEntities = (s) => (s.indexOf('&') < 0 ? s : s.replace( + /&(#x[0-9a-f]+|#\d+|lt|gt|amp|quot|apos);/gi, + (m, e) => { + if (e[0] !== '#') return ENTITIES[e.toLowerCase()]; + const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)); + return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : m; + }, +)); + +const ATTR = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + +function parseAttrs(source) { + const attrs = {}; + ATTR.lastIndex = 0; + let m; + while ((m = ATTR.exec(source))) attrs[m[1]] = decodeEntities(m[2] ?? m[3]); + return attrs; +} + +export function parseXml(xml) { + const root = { name: '#root', attrs: {}, children: [], text: '' }; + const stack = [root]; + let i = 0; + const len = xml.length; + + while (i < len) { + const lt = xml.indexOf('<', i); + const top = stack[stack.length - 1]; + if (lt < 0) { top.text += decodeEntities(xml.slice(i)); break; } + if (lt > i) top.text += decodeEntities(xml.slice(i, lt)); + + if (xml.startsWith('', lt + 4); + i = end < 0 ? len : end + 3; + } else if (xml.startsWith('', lt + 9); + top.text += xml.slice(lt + 9, end < 0 ? len : end); + i = end < 0 ? len : end + 3; + } else if (xml[lt + 1] === '?' || xml[lt + 1] === '!') { + const end = xml.indexOf('>', lt + 2); + i = end < 0 ? len : end + 1; + } else if (xml[lt + 1] === '/') { + const end = xml.indexOf('>', lt + 2); + if (stack.length > 1) stack.pop(); + i = end < 0 ? len : end + 1; + } else { + // Find the end of the tag, skipping any '>' inside a quoted attribute. + let j = lt + 1; + let quote = ''; + while (j < len) { + const c = xml[j]; + if (quote) { if (c === quote) quote = ''; } else if (c === '"' || c === "'") quote = c; + else if (c === '>') break; + j++; + } + const selfClosing = xml[j - 1] === '/'; + const body = xml.slice(lt + 1, selfClosing ? j - 1 : j); + const space = body.search(/\s/); + const name = space < 0 ? body : body.slice(0, space); + const node = { name, attrs: space < 0 ? {} : parseAttrs(body.slice(space)), children: [], text: '' }; + top.children.push(node); + if (!selfClosing) stack.push(node); + i = j + 1; + } + } + return root.children[0] ?? root; +} + +/** First direct child with this name. */ +export const child = (node, name) => node?.children.find((c) => c.name === name) ?? null; + +/** Every direct child with this name. */ +export const kids = (node, name) => (node ? node.children.filter((c) => c.name === name) : []); + +/** Follow a path of child names: path(n, 'p:nvSpPr', 'p:cNvPr'). */ +export function path(node, ...names) { + let cur = node; + for (const n of names) { + cur = child(cur, n); + if (!cur) return null; + } + return cur; +} + +/** Depth-first search for every descendant with this name. */ +export function descendants(node, name, out = []) { + for (const c of node?.children ?? []) { + if (c.name === name) out.push(c); + descendants(c, name, out); + } + return out; +} + +export const attrOf = (node, name, fallback = null) => node?.attrs[name] ?? fallback; diff --git a/tools/zip-tools.mjs b/tools/zip-tools.mjs new file mode 100644 index 0000000..512e795 --- /dev/null +++ b/tools/zip-tools.mjs @@ -0,0 +1,90 @@ +/** + * ZIP helpers for the browser converter: read the .pptx (inflate every part) and + * write the deck folder back out as a ZIP. No dependencies. + */ +import { listZipEntries } from './pptx-core.mjs'; + +export async function inflateRaw(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +/** Inflate every part up front, so the converter itself stays synchronous. */ +export async function readPptx(bytes) { + const files = new Map(); + for (const entry of listZipEntries(bytes)) { + const raw = bytes.subarray(entry.dataStart, entry.dataStart + entry.compSize); + if (entry.method === 0) files.set(entry.name, raw); + else if (entry.method === 8) files.set(entry.name, await inflateRaw(raw)); + else throw new Error(`Unsupported compression in ${entry.name}`); + } + return files; +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c >>> 0; + } + return table; +})(); + +export const crc32 = (bytes) => { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +}; + +/** A ZIP with everything stored uncompressed; the payload is mostly PNG and SVG. */ +export function makeZip(entries) { + const encoder = new TextEncoder(); + const chunks = []; + const central = []; + let offset = 0; + + for (const { name, bytes } of entries) { + const nameBytes = encoder.encode(name); + const crc = crc32(bytes); + + const local = new Uint8Array(30 + nameBytes.length); + const lv = new DataView(local.buffer); + lv.setUint32(0, 0x04034b50, true); + lv.setUint16(4, 20, true); + lv.setUint16(6, 0x0800, true); // UTF-8 names + lv.setUint32(14, crc, true); + lv.setUint32(18, bytes.length, true); + lv.setUint32(22, bytes.length, true); + lv.setUint16(26, nameBytes.length, true); + local.set(nameBytes, 30); + chunks.push(local, bytes); + + const dir = new Uint8Array(46 + nameBytes.length); + const dv = new DataView(dir.buffer); + dv.setUint32(0, 0x02014b50, true); + dv.setUint16(4, 20, true); + dv.setUint16(6, 20, true); + dv.setUint16(8, 0x0800, true); + dv.setUint32(16, crc, true); + dv.setUint32(20, bytes.length, true); + dv.setUint32(24, bytes.length, true); + dv.setUint16(28, nameBytes.length, true); + dv.setUint32(42, offset, true); + dir.set(nameBytes, 46); + central.push(dir); + + offset += local.length + bytes.length; + } + + const centralSize = central.reduce((n, c) => n + c.length, 0); + const end = new Uint8Array(22); + const ev = new DataView(end.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(8, entries.length, true); + ev.setUint16(10, entries.length, true); + ev.setUint32(12, centralSize, true); + ev.setUint32(16, offset, true); + + return new Blob([...chunks, ...central, end], { type: 'application/zip' }); +}