Przeglądaj źródła

Render Slides PDF in browser and save bytes

Switch Slides PDF export from the server converter to a browser-side renderer (`slides_pdf.js`) using `pdf-lib`, so layout/font decisions match what the editor actually shows. The export now writes generated bytes through a new cross-host path: `OfficePlatform.writeBytes` (ArozOS backend or standalone download) and `common/backend/binsaver.agi`.

Add `office.writeBinaryFile(base64, destVpath)` to the AGI Office library to persist client-produced binary exports with existing write-permission checks, and wire it into API/help docs plus Office contract/README guidance.
Toby Chui 3 tygodni temu
rodzic
commit
7d6ba1e76c

+ 15 - 1
src/mod/agi/README.md

@@ -1463,6 +1463,20 @@ var ok = office.documentToPdf(bodyJsonString, "user:/Desktop/report.pdf");
 if (ok) { sendResp("OK"); }
 ```
 
+### `office.writeBinaryFile(base64, destVpath)`
+Write a file the web client produced. The payload is the file's bytes,
+base64 encoded; the same write permission check as every exporter above
+applies to `destVpath`. This exists because some exports can only be
+rendered in the browser - the Slides PDF is built there, since only the
+browser knows which font it resolved and where every line wrapped.
+Returns `true` on success.
+
+```javascript
+requirelib("office");
+var ok = office.writeBinaryFile(base64Payload, "user:/Desktop/deck.pdf");
+if (ok) { sendResp("OK"); }
+```
+
 ## ffmpeg API
 
 Load:
@@ -2569,6 +2583,6 @@ This documentation covers all available AGI APIs with practical examples. For mo
 ## Notes and Caveats
 
 - `requirelib("audio")` is registered in code but currently has no callable functions.
-- `filelib` currently does not expose `writeBinaryFile` / `readBinaryFile` on the public `filelib` object.
+- `filelib` currently does not expose `writeBinaryFile` / `readBinaryFile` on the public `filelib` object. `office.writeBinaryFile(base64, destVpath)` covers the write half for anything the web client renders itself.
 - Most APIs return `false` or `null` on failure; many also raise AGI runtime errors.
 - For admin-only APIs (`userExists`, `createUser`, `removeUser`), check `userIsAdmin()` first.

+ 15 - 0
src/mod/agi/agi.office.go

@@ -3,6 +3,7 @@ package agi
 import (
 	"bytes"
 	"crypto/sha1"
+	"encoding/base64"
 	"encoding/hex"
 	"errors"
 	"fmt"
@@ -698,6 +699,19 @@ func (g *Gateway) injectOfficeLibFunctions(payload *static.AgiLibInjectionPayloa
 		return office.BuildSlidesPdf(pres)
 	})
 
+	/* ---------- write a file the web client produced ----------
+	   Slides builds its PDF in the browser (only the browser knows how the
+	   text actually laid out), so it needs a way to put those bytes on the
+	   file system. Same (payload, destVpath) shape and the same write
+	   permission check as every exporter above. */
+	registerOdfExport("_office_writeBinaryFile", func(b64 string) ([]byte, error) {
+		data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
+		if err != nil {
+			return nil, errors.New("payload is not valid base64: " + err.Error())
+		}
+		return data, nil
+	})
+
 	vm.Run(`
 		var office = {};
 
@@ -721,6 +735,7 @@ func (g *Gateway) injectOfficeLibFunctions(payload *static.AgiLibInjectionPayloa
 		office.documentToPdf = _office_documentToPdf;             // Docs body JSON string -> pdf file (real text)
 		office.workbookPrintToPdf = _office_workbookPrintToPdf;   // Sheets print-model JSON -> pdf file (real text)
 		office.presentationToPdf = _office_presentationToPdf;     // Slides body JSON string -> pdf file (real text)
+		office.writeBinaryFile = _office_writeBinaryFile;         // base64 string -> binary file (client-produced exports)
 	`)
 }
 

+ 83 - 6
src/web/Office/README.md

@@ -95,7 +95,7 @@ have written. `generate.go -wasm` builds and ships that module, and
 Two capability questions, and they are **not** the same:
 
 - `OfficePlatform.hasBackend()` — is there a server? (storage, AGI scripts,
-  the real-text PDF renderer)
+  the Docs/Sheets PDF renderers — Slides renders its own PDF in the browser)
 - `OfficePlatform.canConvert()` — can this build convert Office formats?
 
 **Gate anything new on the right one** (details in `CONTRACT.md`), or it will
@@ -163,6 +163,78 @@ body before posting:
   (`rasterizeEmojiForPdf` in `docs.js`) because PDF core fonts are
   Latin-1 and have no emoji glyphs.
 
+### Slides: PDF export is rendered in the browser
+
+**Docs and Sheets export PDF on the server; Slides does not.** Its
+exporter is [`slides/slides_pdf.js`](slides/slides_pdf.js) (`SlidesPdf`),
+built on the vendored `pdf-lib`, and it runs against the very DOM the
+editor is showing.
+
+The reason is that a slide's appearance is decided by the browser: which
+font it resolved out of a stack, where each line wrapped, how tall each
+line box came out. A server-side renderer has to re-derive all of that and
+the derivations drift — the symptom was a CJK deck exporting as rows of
+dots, because `fpdf`'s core fonts are cp1252 and the layout had been
+guessed in Helvetica anyway.
+
+Every element goes in as the PDF object it should be:
+
+| element | becomes |
+|---|---|
+| text | real `Tj` text, one show-text per line fragment, at the baseline the browser laid it out on |
+| image | the original JPEG/PNG bytes, embedded once and re-used |
+| crop / shaped crop / rounded corners | a real PDF **clip path** (`shapePathOps`, from the same `shapePoints()` the canvas draws) |
+| flip | a negative scale in the transformation matrix |
+| transparency | an `ExtGState` with `/ca` |
+| re-colour, brightness, contrast | the picture re-encoded through a canvas — a pixel operation in any renderer |
+| shape | a real vector path, filled and stroked |
+| line | a real vector polyline, arrow heads as filled triangles |
+| table | real vector cell fills and rules, plus text |
+| chart | the chart's own SVG, translated element by element into PDF vectors |
+| rotation | one matrix about the object's centre, with the element measured unrotated |
+
+**The font rule, and the one fallback.** A PDF can only show text in a font
+it embeds, and a browser will not hand over the bytes of a system font. So
+real text is possible exactly when the family the browser *actually
+resolved* is metric-compatible with one of the 14 standard PDF fonts
+(Helvetica / Times / Courier and their clones — `stdFamilyOf`) and every
+character is WinAnsi-encodable. Which family was resolved has to be
+measured, not asked: `document.fonts.check()` answers "is it loaded" and
+says yes to any name, so `haveFamily` probes widths against two generics
+instead.
+
+When that test fails — a deck set in Open Sans, or in Chinese — **that one
+text box** is rasterized and nothing else on the page is. The picture is
+taken through an SVG `<foreignObject>`, so the *browser* lays it out and
+paints it. html2canvas was tried first and is wrong for this: it
+re-implements layout over a clone, and on mixed CJK/Latin text with
+`pre-wrap` it breaks lines somewhere the browser did not — exactly the
+drift this rework removes. It survives only as a last resort for the case
+where even the foreignObject route fails, because a wrong element beats a
+missing one. Two things that route needs and that are easy to get wrong:
+the computed styles have to be inlined onto the clone (an SVG image cannot
+reach the page's stylesheets), and the markup must be serialized with
+`XMLSerializer` — `innerHTML` writes `<br>` unclosed, which is not
+well-formed XML and makes the whole element vanish.
+
+**Why the slide surface has its own font.** `.sl-slidebase` sets
+`Arial, "Liberation Sans", Helvetica, sans-serif` instead of inheriting the
+app's UI font. A deck must not change shape depending on which OS the
+editor runs on, and those three are metrically identical to each other and
+to PDF's Helvetica — so editor-authored text exports as real, selectable
+text everywhere. Text that states its own font (anything imported)
+overrides it.
+
+The bytes are written through `OfficePlatform.writeBytes`, which base64s
+them down the same oversized-payload path every export uses and lands in
+`common/backend/binsaver.agi` → `office.writeBinaryFile`. In the standalone
+web edition it is a download, which means **PDF export now works there
+too** — it no longer needs a backend.
+
+`mod/office/pdf_slides.go` stays: `office.presentationToPdf` is a
+documented AGI function that scripts may call. The webapp no longer uses
+it.
+
 ### Slides: cropping a picture
 
 Cropping never touches the pixels. The object frame says which part of the
@@ -528,7 +600,12 @@ sh ../scripts/check-conventions.sh --diff origin/master
 
 ## Ideas / known gaps (future work)
 
-- CJK/Unicode text in PDF export (needs an embedded font — see above).
+- CJK/Unicode text as *text* in PDF export. Both renderers hit the same
+  wall: a PDF can only show glyphs from a font it embeds, and neither the
+  Go binary nor the browser can supply a CJK font's bytes. Slides works
+  around it per text box (see above); Docs and Sheets still degrade.
+  Embedding a font would need it vendored, plus `@pdf-lib/fontkit` on the
+  client — that is the fix if someone wants it.
 - **MicroType Express decompression** so Google-Slides-embedded fonts can
   be used (see the format notes) — the last visible gap between an
   imported deck and its source.
@@ -540,10 +617,10 @@ sh ../scripts/check-conventions.sh --diff origin/master
 - Embedded fonts are read but not written back, so a `.pptx` exported from
   a deck that carried its fonts no longer carries them.
 - A **shaped crop** (`props.mask`) round-trips through `.pptx` as the
-  picture's `prstGeom`, but neither the `.odp` writer nor the PDF exporter
-  draws one — ODF would need a custom shape with a bitmap fill, and
-  `pdf_slides.go` places pictures as plain rectangles. The rectangular
-  crop itself is exported to all three.
+  picture's `prstGeom` and is a real clip path in the browser-rendered
+  PDF, but the `.odp` writer does not draw one — ODF would need a custom
+  shape with a bitmap fill. (`mod/office/pdf_slides.go` does not draw one
+  either; nothing in the webapp reaches it any more.)
 - Slides: SmartArt (`dgm:`), 3-D effects, shadows and animations are
   skipped rather than approximated.
 - Real-time collaboration (the `sharedspace` AGI lib was built for this).

+ 18 - 3
src/web/Office/common/CONTRACT.md

@@ -235,7 +235,7 @@ OfficePlatform.convertOut({agi, action, wasm}, destPath, bodyJson,
 
 // io - OfficeApp.vfsLoad / vfsSave / blobToSrc / mediaUrl / agirunLarge all
 // forward to these, so app code normally keeps using OfficeApp
-readText writeText containerLoad containerSave
+readText writeText writeBytes containerLoad containerSave
 sessionSave sessionLoad sessionDelete
 agirun agirunLarge prepareWorkdir mediaUrl blobToSrc
 loadInputFiles adoptDroppedFile setWindowTitle setWindowTheme
@@ -243,6 +243,13 @@ openDocument     // open a document in a second window of this app;
                  // false = nowhere to open it from (standalone downloads)
 ```
 
+`writeBytes(path, Uint8Array, cb, errcb)` is for a file the client
+rendered itself. In ArozOS it base64s the payload down the same
+oversized-payload path as every export and lands in
+`common/backend/binsaver.agi` -> `office.writeBinaryFile`; in the
+standalone edition it is a download. The Slides PDF export is the reason
+it exists (see the Office README).
+
 **Adding a format conversion:** add the converter to
 [`src/wasm/office/convert.go`](../../../wasm/office/convert.go) (and its
 pairing test), then call `OfficePlatform.convertIn/convertOut` with a
@@ -647,8 +654,16 @@ Text inherits `currentColor` → theme-aware automatically.
 ## Vendored libs (common/lib/, all MIT)
 
 - `marked.min.js` — Markdown → HTML (Docs import)
-- `pdf-lib.min.js` — PDF generation (global `PDFLib`)
-- `html2canvas.min.js` — DOM → canvas (Slides PNG export)
+- `pdf-lib.min.js` — PDF generation (global `PDFLib`). Used by the Slides
+  PDF export, which builds the file in the browser out of real PDF objects
+  (`slides/slides_pdf.js`). No `@pdf-lib/fontkit` is vendored, so it can
+  only embed the 14 standard fonts — that is what decides when text can go
+  in as text; see the Office README.
+- `html2canvas.min.js` — DOM → canvas (Slides PNG export). **Not** the
+  first choice for the PDF export's raster fallback: it re-implements
+  layout over a clone and re-wraps mixed-script text. That path uses an
+  SVG `<foreignObject>` so the browser lays out its own content, and only
+  falls back here if that fails outright.
 
 ## Testing without a full ArozOS server
 

+ 62 - 0
src/web/Office/common/backend/binsaver.agi

@@ -0,0 +1,62 @@
+/*
+    ArozOS Office Suite - binary FileSaver backend
+    Writes a base64-encoded payload the web client produced to a file on
+    the server. Used by the Slides PDF export, which is built in the
+    browser because only the browser knows how its text laid out.
+
+    Required POST parameters:
+        filepath  - virtual path to the target file (e.g. user:/Desktop/deck.pdf)
+        content   - the file's bytes, base64 encoded
+        contentFile - vpath of an uploaded temp file holding that base64,
+                    used instead of content when the payload is too big for
+                    a POST form body (deleted after reading)
+
+    Response: JSON {"error": "..."} on failure, "OK" on success
+*/
+
+/*
+    Same oversized-payload path as filesaver.agi: Go's r.ParseForm drops
+    EVERY parameter once a urlencoded body passes 10MB, so the front end
+    streams the payload to a temp file and passes its vpath instead.
+*/
+function resolveContent(){
+    if (typeof contentFile !== "undefined" && contentFile){
+        if (!requirelib("filelib")){
+            return "";
+        }
+        var loaded = filelib.readFile(contentFile);
+        filelib.deleteFile(contentFile);
+        return loaded;
+    }
+    return (typeof content === "undefined" || !content) ? "" : content;
+}
+
+function main(){
+    if (!requirelib("office")){
+        sendJSONResp(JSON.stringify({ error: "office lib unavailable - please update your ArozOS host" }));
+        return;
+    }
+
+    if (typeof filepath === "undefined" || !filepath || filepath.trim() === ""){
+        sendJSONResp(JSON.stringify({ error: "filepath is required" }));
+        return;
+    }
+
+    var payload = resolveContent();
+    if (!payload){
+        sendJSONResp(JSON.stringify({ error: "content is required" }));
+        return;
+    }
+
+    try {
+        if (!office.writeBinaryFile(payload, filepath)){
+            sendJSONResp(JSON.stringify({ error: "unable to write file" }));
+            return;
+        }
+        sendResp("OK");
+    } catch (e) {
+        sendJSONResp(JSON.stringify({ error: "write failed: " + e.message }));
+    }
+}
+
+main();

+ 42 - 0
src/web/Office/common/platform.js

@@ -272,6 +272,24 @@ var OfficePlatform = (function () {
             });
         },
 
+        /* Write bytes the client produced (the Slides PDF renderer is the
+           one that needs this: only the browser knows how the text laid
+           out). Base64 rides the same oversized-payload path every export
+           uses, and the backend decodes it with office.writeBinaryFile. */
+        writeBytes: function (path, bytes, cb, errcb) {
+            var b64;
+            try { b64 = bytesToBase64(bytes); }
+            catch (e) { if (errcb) errcb("could not encode the file"); return; }
+            arozos.agirunLarge("Office/common/backend/binsaver.agi", {
+                filepath: path,
+                content: b64
+            }, "content", function () {
+                if (cb) cb();
+            }, function (msg) {
+                if (errcb) errcb(msg);
+            });
+        },
+
         containerLoad: function (path, cb, errcb) {
             // templates and ?open= documents are web assets: read and unpack
             // them here rather than asking the backend for a vpath it has no
@@ -432,6 +450,18 @@ var OfficePlatform = (function () {
         xhr.onerror = function () { errcb("network error"); };
         xhr.send();
     }
+    /* base64 in chunks: String.fromCharCode.apply on a multi-megabyte
+       array blows the argument limit */
+    function bytesToBase64(bytes) {
+        var arr = (bytes instanceof Uint8Array) ? bytes : new Uint8Array(bytes);
+        var CHUNK = 0x8000;
+        var parts = [];
+        for (var i = 0; i < arr.length; i += CHUNK) {
+            parts.push(String.fromCharCode.apply(null, arr.subarray(i, i + CHUNK)));
+        }
+        return btoa(parts.join(""));
+    }
+
     function download(bytesOrText, filename, mime) {
         var blob = (bytesOrText instanceof Uint8Array)
             ? new Blob([bytesOrText], { type: mime || "application/octet-stream" })
@@ -729,6 +759,15 @@ var OfficePlatform = (function () {
                 if (errcb) errcb(e.message || "download failed");
             }
         },
+        writeBytes: function (path, bytes, cb, errcb) {
+            try {
+                download(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes),
+                    saveName(path), "application/octet-stream");
+                if (cb) cb();
+            } catch (e) {
+                if (errcb) errcb(e.message || "download failed");
+            }
+        },
 
         containerLoad: function (path, cb, errcb) {
             var f = localFiles[path];
@@ -868,6 +907,9 @@ var OfficePlatform = (function () {
 
         readText: function (p, cb, errcb) { host.readText(p, cb, errcb); },
         writeText: function (p, c, cb, errcb) { host.writeText(p, c, cb, errcb); },
+        // a file the client rendered itself (the Slides PDF); bytes is a
+        // Uint8Array, and the standalone host turns it into a download
+        writeBytes: function (p, b, cb, errcb) { host.writeBytes(p, b, cb, errcb); },
 
         containerLoad: function (p, cb, errcb) { host.containerLoad(p, cb, errcb); },
         containerSave: function (p, j, cb, errcb) { host.containerSave(p, j, cb, errcb); },

+ 2 - 0
src/web/Office/slides/index.html

@@ -22,6 +22,7 @@
     <script src="../common/textedit.js"></script>
     <script src="../common/charts.js"></script>
     <script src="../common/lib/html2canvas.min.js"></script>
+    <script src="../common/lib/pdf-lib.min.js"></script>
 </head>
 <body data-officeapp="slides">
     <div class="of-toolbar of-noprint" id="toolbar"></div>
@@ -53,6 +54,7 @@
     <div id="slPrintArea"></div>
     <input type="file" id="slDeviceImage" accept="image/*" multiple style="display:none;">
     <script src="slides_image.js"></script>
+    <script src="slides_pdf.js"></script>
     <script src="slides.js"></script>
     <script src="present.js"></script>
 </body>

+ 11 - 0
src/web/Office/slides/slides.css

@@ -166,6 +166,17 @@
 #slNotes.collapsed #slNotesText { display: none; }
 
 /* ============ Slide surface & objects ============ */
+/* A slide carries a document font of its own rather than inheriting the
+   app's UI font. Two reasons, and they are the same reason: a deck must
+   not change shape depending on which OS the editor happens to run on,
+   and the PDF export can only put text in the PDF *as text* when the
+   font it was laid out in is one a PDF can show. Arial and Liberation
+   Sans are metrically identical to each other and to PDF's Helvetica, so
+   this stack resolves to the same metrics on Windows, macOS and Linux.
+   Text that states its own font (anything imported) overrides this. */
+.sl-slidebase, #slCanvas {
+    font-family: Arial, "Liberation Sans", Helvetica, sans-serif;
+}
 .sl-slidebase {
     position: absolute;
     inset: 0;

+ 56 - 6
src/web/Office/slides/slides.js

@@ -3533,9 +3533,54 @@ var SlidesApp = (function () {
     }
     function exportPptx() { exportSlidesFile(".pptx", "export", "Exporting PowerPoint file..."); }
     function exportOdp() { exportSlidesFile(".odp", "export-odf", "Exporting OpenDocument file..."); }
-    // server-side real-text PDF (mod/office); video/audio render their
-    // captured poster frame (or a generic placeholder)
-    function exportPdf() { exportSlidesFile(".pdf", "export-pdf", "Exporting PDF..."); }
+    /* PDF is built in the browser (slides_pdf.js), not on the server: only
+       the browser knows which font it actually resolved and where every
+       line wrapped, and that is exactly what the export has to reproduce.
+       Each element goes in as the real PDF object it should be - text as
+       text, pictures as embedded images with real clip paths, shapes and
+       charts as vectors - and only an element the format genuinely cannot
+       express falls back to a raster of itself. */
+    function canExportPdf() {
+        return typeof PDFLib !== "undefined" && !!window.SlidesPdf;
+    }
+    function exportPdf() {
+        if (!canExportPdf()) {
+            OfficeApp.toast("The PDF library failed to load", "error");
+            return;
+        }
+        endEdit(true);
+        endCrop(true);
+        var defName = OfficeApp.stripExt(OfficeApp.getFileName() || "New Presentation.ppta") + ".pdf";
+        OfficePlatform.pickSave({ defaultName: defName, ext: ".pdf", memoryKey: "export" }, function (file) {
+            var fp = file.filepath;
+            OfficeApp.showBusy("Exporting PDF...");
+            savePdfTo(fp, function () {
+                OfficeApp.hideBusy();
+                OfficeApp.setStatus("Exported " + OfficeApp.basename(fp));
+                OfficeApp.toast("Exported " + OfficeApp.basename(fp));
+            }, function (msg) {
+                OfficeApp.hideBusy();
+                OfficeApp.toast("Export failed: " + msg, "error");
+            });
+        });
+    }
+
+    // savePdfTo renders the deck and writes the bytes; shared by File >
+    // Export and by the .pdf entry in SAVE_FORMATS
+    function savePdfTo(fp, done, fail) {
+        if (!canExportPdf()) { fail("the PDF library failed to load"); return; }
+        endEdit(true);
+        endCrop(true);
+        SlidesPdf.build(body, {
+            onProgress: function (n, total) {
+                OfficeApp.showBusy("Exporting PDF... slide " + n + " of " + total);
+            }
+        }).then(function (bytes) {
+            OfficePlatform.writeBytes(fp, bytes, done, fail);
+        }).catch(function (err) {
+            fail(err && err.message ? err.message : "render error");
+        });
+    }
 
     /* ================= saving back into a foreign format =================
        A deck opened from .pptx / .odp goes on living in that file: the
@@ -3594,9 +3639,11 @@ var SlidesApp = (function () {
             save: function (fp, fn, done, fail) { saveViaConverter("export-odf", fp, done, fail); }
         },
         {
+            // rendered in the browser, so it needs no backend - only the
+            // PDF library (see exportPdf)
             ext: ".pdf", label: "PDF document (.pdf)", icon: "file pdf outline",
-            needsBackend: true, oneWay: true,
-            save: function (fp, fn, done, fail) { saveViaConverter("export-pdf", fp, done, fail); }
+            oneWay: true,
+            save: function (fp, fn, done, fail) { savePdfTo(fp, done, fail); }
         }
     ];
 
@@ -3979,7 +4026,7 @@ var SlidesApp = (function () {
                                 action: exportOdp
                             });
                         }
-                        if (OfficePlatform.hasBackend()) {
+                        if (canExportPdf()) {
                             items.push({
                                 label: "PDF document (.pdf)", icon: "file pdf outline",
                                 action: exportPdf
@@ -4051,6 +4098,9 @@ var SlidesApp = (function () {
         getCurrentIndex: function () { return cur; },
         renderSlideContent: renderSlideContent,
         themeOf: themeOf,
+        // the PDF exporter builds its clip paths from the very same
+        // outlines the canvas draws, so the two cannot disagree
+        shapePoints: shapePoints,
         slideCount: function () { return body ? body.slides.length : 0; }
     };
 })();

+ 1106 - 0
src/web/Office/slides/slides_pdf.js

@@ -0,0 +1,1106 @@
+/*
+    ArozOS Office - Slides PDF export
+    =================================
+    Builds the PDF in the browser, out of real PDF objects, from the very
+    DOM the editor is showing. That is the only place the truth lives: how
+    a line of text wrapped, where each line landed, which font the browser
+    actually resolved. A server-side renderer has to guess all of it, and
+    the guesses drift.
+
+    What each element becomes:
+
+      text      real PDF text (Tj), one show-text per line fragment, placed
+                at the baseline the browser laid it out on - when the glyphs
+                and the font allow it (see canDrawAsText). Otherwise that
+                one text box is rasterized, nothing else.
+      image     the original JPEG/PNG bytes, embedded once and re-used;
+                crops, shaped crops and rounded corners are real PDF clip
+                paths, flips are a negative scale in the matrix, and
+                transparency is an ExtGState. A re-coloured picture is
+                re-encoded through a canvas first, which is a pixel
+                operation either way.
+      shape     a real vector path, filled and stroked
+      line      a real vector polyline, with arrow heads as filled triangles
+      table     real vector cell fills and rules, text through the rules above
+      chart     the chart's own SVG, translated element by element into PDF
+                vectors - not a picture of it
+      video     the captured poster frame, as an image
+      /audio
+
+    Rasterizing is the documented last resort, never the first move, and it
+    is always scoped to the one element that needs it.
+
+    The font rule. A PDF can only show text in a font it embeds, and the
+    browser will not hand over the bytes of a system font. So real text is
+    possible exactly when the resolved family is metric-compatible with one
+    of the 14 standard PDF fonts (Helvetica / Times / Courier and their
+    aliases) and every character is WinAnsi-encodable. A deck set in Open
+    Sans or in Chinese falls back to a per-object raster, which is at least
+    pixel-exact. Embedding arbitrary fonts would need fontkit and the font
+    files themselves; see the README's known gaps.
+
+    Usage:
+        SlidesPdf.build(body, { onProgress: fn(done, total) })
+            -> Promise<Uint8Array>
+*/
+
+var SlidesPdf = (function () {
+    "use strict";
+
+    // the slide is 960x540 css px; a PDF point is 1/72", a css px 1/96",
+    // so the page is 720x405 pt - the 10" x 5.625" of the pptx slide size
+    var PX_TO_PT = 0.75;
+    var SLIDE_W = 960, SLIDE_H = 540;
+    var RASTER_SCALE = 3;      // device pixels per css px for a fallback raster
+
+    /* ---------------- fonts ---------------- */
+
+    /* Families that are metric-compatible with a standard PDF font, so
+       text set in them lands in exactly the same place as on screen. */
+    var STD_FAMILIES = {
+        "helvetica": "Helvetica", "arial": "Helvetica", "liberation sans": "Helvetica",
+        "arimo": "Helvetica", "sans-serif": "Helvetica", "nimbus sans": "Helvetica",
+        "times": "TimesRoman", "times new roman": "TimesRoman", "serif": "TimesRoman",
+        "liberation serif": "TimesRoman", "tinos": "TimesRoman", "nimbus roman": "TimesRoman",
+        "courier": "Courier", "courier new": "Courier", "monospace": "Courier",
+        "liberation mono": "Courier", "cousine": "Courier", "nimbus mono": "Courier"
+    };
+    var STD_VARIANTS = {
+        Helvetica: ["Helvetica", "HelveticaBold", "HelveticaOblique", "HelveticaBoldOblique"],
+        TimesRoman: ["TimesRoman", "TimesRomanBold", "TimesRomanItalic", "TimesRomanBoldItalic"],
+        Courier: ["Courier", "CourierBold", "CourierOblique", "CourierBoldOblique"]
+    };
+
+    // WinAnsi is what the standard fonts can encode; anything outside it
+    // (CJK, most symbols, emoji) has no glyph to show
+    function isWinAnsi(str) {
+        for (var i = 0; i < str.length; i++) {
+            var c = str.charCodeAt(i);
+            if (c === 9 || c === 10 || c === 13) continue;
+            if (c < 32) return false;
+            if (c > 255) {
+                // a handful above U+00FF are in WinAnsi; keeping to the
+                // Latin-1 range is the safe, checkable rule
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /* haveFamily reports whether a family is actually installed. It has to
+       be measured: document.fonts.check() answers "is it loaded", and for a
+       local family Chrome says yes whatever name you give it. The reliable
+       test is the old one - render a probe string backed by two different
+       generics; a family that exists overrides both and comes out a
+       different width from each, one that does not falls through to the
+       generic and matches it exactly.
+
+       This matters because a deck may ask for a font the machine does not
+       have: the browser laid the text out in the fallback, so the fallback
+       is what the PDF must match - and that may well be one it can show. */
+    var PROBE = "mmmmmmmmwwwwwwwwiiiiiiiil1I0Oo";
+    var familyKnown = {};
+    var probeCtx = null;
+    var probeBase = null;
+    function haveFamily(name) {
+        if (familyKnown[name] !== undefined) return familyKnown[name];
+        try {
+            if (!probeCtx) {
+                probeCtx = document.createElement("canvas").getContext("2d");
+                probeBase = {};
+                ["monospace", "serif"].forEach(function (g) {
+                    probeCtx.font = '72px ' + g;
+                    probeBase[g] = probeCtx.measureText(PROBE).width;
+                });
+            }
+            var found = true;
+            ["monospace", "serif"].forEach(function (g) {
+                probeCtx.font = '72px "' + name.replace(/"/g, "") + '", ' + g;
+                if (probeCtx.measureText(PROBE).width === probeBase[g]) found = false;
+            });
+            familyKnown[name] = found;
+        } catch (e) {
+            familyKnown[name] = true;
+        }
+        return familyKnown[name];
+    }
+
+    /* stdFamilyOf resolves a computed font-family list down to the standard
+       font it is metric-compatible with, or "" when there is none. The
+       first family the browser can actually use is the one that decides,
+       because that is the one the text was measured in. */
+    function stdFamilyOf(cssFamily) {
+        var parts = String(cssFamily || "").split(",");
+        for (var i = 0; i < parts.length; i++) {
+            var name = parts[i].trim().replace(/^["']|["']$/g, "");
+            if (!name) continue;
+            var key = name.toLowerCase();
+            var generic = (key === "sans-serif" || key === "serif" ||
+                key === "monospace" || key === "cursive" || key === "fantasy");
+            if (!generic && !haveFamily(name)) continue;   // the browser skipped it too
+            return STD_FAMILIES[key] || "";
+        }
+        return "";
+    }
+
+    /* fontCache hands out the embedded standard fonts lazily, so a deck
+       that never uses italics does not carry an italic font object */
+    function makeFontCache(pdfDoc) {
+        var cache = {};
+        return function (family, bold, italic) {
+            var names = STD_VARIANTS[family] || STD_VARIANTS.Helvetica;
+            var key = names[(bold ? 1 : 0) + (italic ? 2 : 0)];
+            if (!cache[key]) cache[key] = pdfDoc.embedStandardFont(PDFLib.StandardFonts[key]);
+            return cache[key];
+        };
+    }
+
+    /* ---------------- small helpers ---------------- */
+
+    function px(v) { return v * PX_TO_PT; }
+    function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
+
+    /* parseFill returns both halves of a CSS colour: the colour itself and
+       its alpha. The alpha matters - a table's header band is a translucent
+       wash of the theme accent, and dropping it turns a tint into a slab. */
+    function parseFill(css) {
+        if (!css) return null;
+        var m = /^rgba?\(([^)]+)\)$/i.exec(String(css).trim());
+        if (m) {
+            var p = m[1].split(",").map(function (x) { return parseFloat(x); });
+            var a = p.length >= 4 ? clamp(p[3], 0, 1) : 1;
+            if (a === 0) return null;
+            return {
+                c: PDFLib.rgb(clamp(p[0] / 255, 0, 1), clamp(p[1] / 255, 0, 1), clamp(p[2] / 255, 0, 1)),
+                a: a
+            };
+        }
+        var t = String(css).trim();
+        var h = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(t);
+        if (!h) return null;
+        var v = h[1];
+        var alpha = 1;
+        if (v.length === 8) { alpha = parseInt(v.substring(6, 8), 16) / 255; v = v.substring(0, 6); }
+        if (v.length === 3) v = v[0] + v[0] + v[1] + v[1] + v[2] + v[2];
+        if (alpha === 0) return null;
+        var n = parseInt(v, 16);
+        return {
+            c: PDFLib.rgb(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255),
+            a: alpha
+        };
+    }
+    // parseColor is parseFill when only the colour is wanted
+    function parseColor(css) {
+        var f = parseFill(css);
+        return f ? f.c : null;
+    }
+
+    function dataUrlBytes(src) {
+        var comma = String(src || "").indexOf(",");
+        if (comma < 0) return null;
+        var head = src.substring(0, comma);
+        if (head.indexOf(";base64") < 0) return null;
+        var bin = atob(src.substring(comma + 1));
+        var out = new Uint8Array(bin.length);
+        for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+        return { bytes: out, mime: (/^data:([^;]+)/.exec(head) || [])[1] || "" };
+    }
+
+    /* ---------------- the drawing context ---------------- */
+
+    /* Page is a thin wrapper that flips the y axis once: the editor's
+       coordinates run down from the top-left of the slide, a PDF page's run
+       up from the bottom-left, and mixing the two up is the single easiest
+       way to get an export subtly wrong. */
+    function Page(page, pdfDoc, fontFor) {
+        this.p = page;
+        this.doc = pdfDoc;
+        this.fontFor = fontFor;
+        this.gsCache = {};
+    }
+    Page.prototype.y = function (topPx) { return px(SLIDE_H - topPx); };
+
+    Page.prototype.rect = function (x, y, w, h, opts) {
+        this.p.drawRectangle({
+            x: px(x), y: this.y(y + h), width: px(w), height: px(h),
+            color: opts.fill || undefined,
+            borderColor: opts.stroke || undefined,
+            borderWidth: opts.strokeW ? px(opts.strokeW) : undefined,
+            borderDashArray: opts.dash ? [px(opts.strokeW * 3), px(opts.strokeW * 2)] : undefined,
+            opacity: opts.fillOpacity !== undefined ? opts.fillOpacity : opts.opacity,
+            borderOpacity: opts.strokeOpacity !== undefined ? opts.strokeOpacity : opts.opacity
+        });
+    };
+
+    // ops pushes raw content-stream operators, which is how the clip paths
+    // and the matrices below are expressed
+    Page.prototype.ops = function (list) {
+        this.p.pushOperators.apply(this.p, list);
+    };
+    Page.prototype.save = function () { this.ops([PDFLib.pushGraphicsState()]); };
+    Page.prototype.restore = function () { this.ops([PDFLib.popGraphicsState()]); };
+
+    // alpha returns the name of an ExtGState for a given opacity, making one
+    // only the first time each distinct value is used on this page
+    Page.prototype.alpha = function (a) {
+        var key = "a" + Math.round(a * 1000);
+        if (!this.gsCache[key]) {
+            var ref = this.doc.context.register(this.doc.context.obj({
+                Type: "ExtGState", ca: a, CA: a
+            }));
+            this.p.node.setExtGState(PDFLib.PDFName.of(key), ref);
+            this.gsCache[key] = true;
+        }
+        return key;
+    };
+
+    /* ---------------- geometry -> PDF path operators ---------------- */
+
+    // shapePathOps turns one of the editor's shape outlines into path
+    // operators in page space. Used for both shape objects and the clip
+    // path of a shaped crop, so the two cannot disagree.
+    function shapePathOps(kind, x, y, w, h, radius) {
+        var X = function (v) { return px(x + v); };
+        var Y = function (v) { return px(SLIDE_H - (y + v)); };
+        var ops = [];
+        if (!kind || kind === "rect") {
+            ops.push(PDFLib.moveTo(X(0), Y(0)), PDFLib.lineTo(X(w), Y(0)),
+                PDFLib.lineTo(X(w), Y(h)), PDFLib.lineTo(X(0), Y(h)), PDFLib.closePath());
+            return ops;
+        }
+        if (kind === "round") {
+            var r = Math.min(radius > 0 ? radius : Math.min(w, h) * 0.15, Math.min(w, h) / 2);
+            var k = r * 0.5523;
+            ops.push(PDFLib.moveTo(X(r), Y(0)));
+            ops.push(PDFLib.lineTo(X(w - r), Y(0)));
+            ops.push(PDFLib.appendBezierCurve(X(w - r + k), Y(0), X(w), Y(r - k), X(w), Y(r)));
+            ops.push(PDFLib.lineTo(X(w), Y(h - r)));
+            ops.push(PDFLib.appendBezierCurve(X(w), Y(h - r + k), X(w - r + k), Y(h), X(w - r), Y(h)));
+            ops.push(PDFLib.lineTo(X(r), Y(h)));
+            ops.push(PDFLib.appendBezierCurve(X(r - k), Y(h), X(0), Y(h - r + k), X(0), Y(h - r)));
+            ops.push(PDFLib.lineTo(X(0), Y(r)));
+            ops.push(PDFLib.appendBezierCurve(X(0), Y(r - k), X(r - k), Y(0), X(r), Y(0)));
+            ops.push(PDFLib.closePath());
+            return ops;
+        }
+        if (kind === "ellipse") {
+            var cx = w / 2, cy = h / 2, kx = cx * 0.5523, ky = cy * 0.5523;
+            ops.push(PDFLib.moveTo(X(0), Y(cy)));
+            ops.push(PDFLib.appendBezierCurve(X(0), Y(cy - ky), X(cx - kx), Y(0), X(cx), Y(0)));
+            ops.push(PDFLib.appendBezierCurve(X(cx + kx), Y(0), X(w), Y(cy - ky), X(w), Y(cy)));
+            ops.push(PDFLib.appendBezierCurve(X(w), Y(cy + ky), X(cx + kx), Y(h), X(cx), Y(h)));
+            ops.push(PDFLib.appendBezierCurve(X(cx - kx), Y(h), X(0), Y(cy + ky), X(0), Y(cy)));
+            ops.push(PDFLib.closePath());
+            return ops;
+        }
+        var pts = (window.SlidesApp && SlidesApp.shapePoints)
+            ? SlidesApp.shapePoints(kind, w, h) : null;
+        if (!pts || !pts.length) {
+            return shapePathOps("rect", x, y, w, h, 0);
+        }
+        pts.forEach(function (pt, i) {
+            ops.push(i === 0 ? PDFLib.moveTo(X(pt[0]), Y(pt[1])) : PDFLib.lineTo(X(pt[0]), Y(pt[1])));
+        });
+        ops.push(PDFLib.closePath());
+        return ops;
+    }
+
+    /* ---------------- text ---------------- */
+
+    /* A run is one uniformly formatted fragment of a single line, measured
+       off the live DOM: its box, its baseline and the style in force. */
+    function collectRuns(rootEl, origin) {
+        var runs = [];
+        var walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null);
+        var node;
+        while ((node = walker.nextNode())) {
+            var text = node.nodeValue;
+            if (!text || !text.trim()) continue;
+            var parent = node.parentElement;
+            if (!parent) continue;
+            var cs = window.getComputedStyle(parent);
+            if (cs.visibility === "hidden" || cs.display === "none") continue;
+            // one entry per line box the fragment occupies
+            var range = document.createRange();
+            range.selectNodeContents(node);
+            var rects = Array.prototype.slice.call(range.getClientRects());
+            if (!rects.length) continue;
+            var perLine = splitByLine(node, text, rects);
+            perLine.forEach(function (ln) {
+                runs.push({
+                    text: ln.text,
+                    rect: ln.rect,
+                    origin: origin,
+                    family: cs.fontFamily,
+                    size: parseFloat(cs.fontSize) || 12,
+                    weight: parseInt(cs.fontWeight, 10) || (cs.fontWeight === "bold" ? 700 : 400),
+                    italic: cs.fontStyle === "italic" || cs.fontStyle === "oblique",
+                    underline: cs.textDecorationLine.indexOf("underline") >= 0,
+                    strike: cs.textDecorationLine.indexOf("line-through") >= 0,
+                    color: cs.color,
+                    background: cs.backgroundColor
+                });
+            });
+        }
+        return runs;
+    }
+
+    /* splitByLine maps a text node's client rects back onto the substrings
+       that produced them, so each line can be drawn at its own baseline.
+       Character-by-character is the only reliable way: the browser decides
+       where the break went, and only it knows. */
+    function splitByLine(node, text, lineRects) {
+        if (lineRects.length === 1) {
+            return [{ text: text, rect: lineRects[0] }];
+        }
+        var range = document.createRange();
+        var out = [];
+        var cur = "";
+        var curTop = null;
+        var curRect = null;
+        for (var i = 0; i < text.length; i++) {
+            range.setStart(node, i);
+            range.setEnd(node, i + 1);
+            var r = range.getBoundingClientRect();
+            if (r.width === 0 && r.height === 0) { cur += text[i]; continue; }
+            var top = Math.round(r.top * 10) / 10;
+            if (curTop === null || Math.abs(top - curTop) < 0.6) {
+                if (curTop === null) { curTop = top; curRect = { left: r.left, top: r.top, bottom: r.bottom, right: r.right }; }
+                else { curRect.right = Math.max(curRect.right, r.right); }
+                cur += text[i];
+            } else {
+                out.push({ text: cur, rect: curRect });
+                cur = text[i];
+                curTop = top;
+                curRect = { left: r.left, top: r.top, bottom: r.bottom, right: r.right };
+            }
+        }
+        if (cur !== "" && curRect) out.push({ text: cur, rect: curRect });
+        return out.length ? out : [{ text: text, rect: lineRects[0] }];
+    }
+
+    // canDrawAsText is the whole fallback decision, in one place
+    function canDrawAsText(runs) {
+        for (var i = 0; i < runs.length; i++) {
+            if (!stdFamilyOf(runs[i].family)) return false;
+            if (!isWinAnsi(runs[i].text)) return false;
+        }
+        return true;
+    }
+
+    /* drawRuns puts every run on the page as real text, each at the
+       baseline the browser gave it. The standard fonts are metrically the
+       families they stand in for, so the glyphs land where they were. */
+    function drawRuns(pg, runs) {
+        runs.forEach(function (r) {
+            var fam = stdFamilyOf(r.family) || "Helvetica";
+            var font = pg.fontFor(fam, r.weight >= 600, r.italic);
+            var sizePt = px(r.size);
+            // the browser's rect is the line box; the baseline sits an
+            // ascender below its top
+            var ascent = font.heightAtSize(r.size, { descender: false });
+            var lineH = r.rect.bottom - r.rect.top;
+            var glyphH = font.heightAtSize(r.size, { descender: true });
+            var baselineTop = r.rect.top - r.origin.top + (lineH - glyphH) / 2 + ascent;
+            var x = r.rect.left - r.origin.left;
+            var col = parseColor(r.color) || PDFLib.rgb(0, 0, 0);
+            var bg = parseFill(r.background);
+            var w = font.widthOfTextAtSize(r.text, r.size);
+            if (bg) {
+                pg.rect(r.origin.x + x, r.origin.y + (r.rect.top - r.origin.top),
+                    w, lineH, { fill: bg.c, fillOpacity: bg.a });
+            }
+            pg.p.drawText(r.text, {
+                x: px(r.origin.x + x),
+                y: pg.y(r.origin.y + baselineTop),
+                size: sizePt, font: font, color: col
+            });
+            if (r.underline || r.strike) {
+                var yOff = r.underline ? baselineTop + r.size * 0.11 : baselineTop - r.size * 0.28;
+                pg.rect(r.origin.x + x, r.origin.y + yOff, w, Math.max(0.7, r.size * 0.06),
+                    { fill: col });
+            }
+        });
+    }
+
+    /* ---------------- rasterizing one element ---------------- */
+
+    /* The documented last resort, and it has one rule: the pixels come out
+       of a render of the whole slide, then get cropped to the element that
+       needed them.
+
+       Rasterizing an element on its own looks tempting and is wrong.
+       html2canvas re-renders a *clone*, and a clone torn out of its
+       absolutely positioned parent loses the width it was laid out in -
+       so the text re-wraps and the export stops matching the editor,
+       which is the one thing it must not do. Rendering the slide keeps
+       every element in the context it was measured in.
+
+       What lands in the PDF is still only that element's own box: one
+       image, at its own position, with everything else on the page a real
+       PDF object. */
+    /* The picture is taken through an SVG <foreignObject>, which means the
+       *browser* lays the element out and paints it - the same engine, the
+       same fonts, the same line breaks as the editor.
+
+       This is not the obvious choice, so: html2canvas was tried first and
+       is wrong for this. It re-implements layout over a clone, and on
+       mixed CJK/Latin text with pre-wrap it breaks lines somewhere else
+       than the browser did, which is precisely the failure this whole
+       rework exists to remove. A foreignObject cannot re-wrap anything,
+       because it is not re-laying anything out.
+
+       The cost is that computed styles have to be inlined onto the clone
+       (an SVG image cannot reach the page's stylesheets) and every
+       resource must already be a data URL - which, in a slide, it is. */
+    function rasterizeElement(el, w, h) {
+        try {
+            var clone = el.cloneNode(true);
+            inlineStyles(el, clone);
+            // the foreignObject supplies the box, so the clone must not
+            // carry the absolute placement it had on the slide
+            clone.style.position = "static";
+            clone.style.left = "auto";
+            clone.style.top = "auto";
+            clone.style.transform = "none";
+            clone.style.margin = "0";
+            clone.style.width = w + "px";
+            clone.style.height = h + "px";
+
+            var cw = Math.max(1, Math.ceil(w)), ch = Math.max(1, Math.ceil(h));
+            /* The markup inside a foreignObject has to be well-formed XML,
+               and innerHTML is not: it writes <br> unclosed, which makes the
+               whole SVG fail to parse and the element vanish from the page.
+               XMLSerializer writes real XHTML, so it cannot. */
+            var wrap = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
+            wrap.setAttribute("style", "width:" + cw + "px;height:" + ch + "px;");
+            wrap.appendChild(clone);
+            var xhtml = new XMLSerializer().serializeToString(wrap);
+            var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + cw +
+                '" height="' + ch + '"><foreignObject x="0" y="0" width="' + cw +
+                '" height="' + ch + '">' + xhtml + "</foreignObject></svg>";
+            var url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
+            return new Promise(function (resolve) {
+                var img = new Image();
+                img.onload = function () {
+                    try {
+                        var c = document.createElement("canvas");
+                        c.width = Math.max(1, Math.round(cw * RASTER_SCALE));
+                        c.height = Math.max(1, Math.round(ch * RASTER_SCALE));
+                        var g = c.getContext("2d");
+                        g.drawImage(img, 0, 0, c.width, c.height);
+                        resolve(c.toDataURL("image/png"));
+                    } catch (e) { resolve(null); }
+                };
+                img.onerror = function () { resolve(null); };
+                img.src = url;
+            });
+        } catch (e) {
+            return Promise.resolve(null);
+        }
+    }
+
+    /* rasterizeFallback is the very last resort, for the case where even
+       the foreignObject route fails. html2canvas re-implements layout and
+       can break mixed-script lines somewhere the browser did not, so it is
+       only ever reached when the alternative is dropping the element
+       from the page entirely - which would be worse. */
+    function rasterizeFallback(el, w, h) {
+        if (typeof html2canvas === "undefined") return Promise.resolve(null);
+        return html2canvas(el, {
+            scale: RASTER_SCALE, useCORS: true, backgroundColor: null, logging: false
+        }).then(function (canvas) {
+            return canvas.toDataURL("image/png");
+        }).catch(function () { return null; });
+    }
+
+    /* inlineStyles copies the computed style of every node in the subtree
+       onto the clone, because the SVG image has no access to the page's
+       stylesheets. Slide markup is small - a few divs and spans - so
+       walking the whole property list is affordable and leaves nothing out. */
+    function inlineStyles(src, dst) {
+        var cs = window.getComputedStyle(src);
+        var out = "";
+        for (var i = 0; i < cs.length; i++) {
+            var prop = cs[i];
+            out += prop + ":" + cs.getPropertyValue(prop) + ";";
+        }
+        dst.setAttribute("style", out);
+        var a = src.children, b = dst.children;
+        for (var j = 0; j < a.length && j < b.length; j++) inlineStyles(a[j], b[j]);
+    }
+
+    // boxOf returns an element's box in slide coordinates
+    function boxOf(el, stageEl) {
+        var r = el.getBoundingClientRect(), s = stageEl.getBoundingClientRect();
+        return { x: r.left - s.left, y: r.top - s.top, w: r.width, h: r.height };
+    }
+
+    /* drawAsRaster puts one element in as an image of itself, at the box it
+       occupies on the slide. A rotated object is drawn unrotated inside the
+       rotation matrix its caller set up, so the picture stays crisp
+       rather than being a picture of something already turned. */
+    function drawAsRaster(pg, o, el, ctx) {
+        var b = boxOf(el, ctx.stage);
+        return rasterizeElement(el, b.w, b.h).then(function (src) {
+            return src || rasterizeFallback(el, b.w, b.h);
+        }).then(function (src) {
+            if (!src) return;
+            return ctx.embed(src).then(function (img) {
+                if (!img) return;
+                pg.p.drawImage(img, {
+                    x: px(b.x), y: pg.y(b.y + b.h), width: px(b.w), height: px(b.h)
+                });
+            });
+        });
+    }
+
+    /* ---------------- images ---------------- */
+
+    /* filteredImageData re-encodes a picture through a canvas when it
+       carries a colour treatment. Applying a filter is a pixel operation in
+       any renderer, so this is the correct way to do it, not a fallback. */
+    function filteredImageData(src, filter, naturalW, naturalH) {
+        return new Promise(function (resolve) {
+            var img = new Image();
+            img.onload = function () {
+                try {
+                    var c = document.createElement("canvas");
+                    c.width = naturalW || img.naturalWidth;
+                    c.height = naturalH || img.naturalHeight;
+                    var g = c.getContext("2d");
+                    g.filter = filter;
+                    g.drawImage(img, 0, 0, c.width, c.height);
+                    resolve(c.toDataURL("image/png"));
+                } catch (e) { resolve(null); }
+            };
+            img.onerror = function () { resolve(null); };
+            img.src = src;
+        });
+    }
+
+    /* sniffImage decides which embedder to use from the bytes themselves
+       rather than from a mime string, which a fetched file may not carry */
+    function sniffImage(bytes) {
+        if (bytes.length > 3 && bytes[0] === 0xFF && bytes[1] === 0xD8) return "jpg";
+        return "png";
+    }
+
+    /* embedImage caches by source string: a deck that uses one picture on
+       twenty slides embeds its bytes once.
+
+       A native .ppta keeps its large media out of the body as media?file=
+       links, so a source that is not a data URL is fetched. Embedding the
+       original bytes is the point - re-encoding through a canvas would
+       turn a photo into a much larger lossless PNG. */
+    function makeImageEmbedder(pdfDoc) {
+        var cache = {};
+        function embedBytes(bytes) {
+            return sniffImage(bytes) === "jpg" ? pdfDoc.embedJpg(bytes) : pdfDoc.embedPng(bytes);
+        }
+        return function (src) {
+            if (!src) return Promise.resolve(null);
+            if (cache[src]) return cache[src];
+            var p;
+            var d = dataUrlBytes(src);
+            if (d) {
+                p = /jpe?g/i.test(d.mime) ? pdfDoc.embedJpg(d.bytes) : embedBytes(d.bytes);
+            } else {
+                p = fetch(src).then(function (r) {
+                    if (!r.ok) throw new Error("cannot read " + src);
+                    return r.arrayBuffer();
+                }).then(function (buf) {
+                    return embedBytes(new Uint8Array(buf));
+                });
+            }
+            cache[src] = p.catch(function () { return null; });
+            return cache[src];
+        };
+    }
+
+    /* ---------------- SVG (charts) -> PDF vectors ---------------- */
+
+    /* OfficeCharts draws with rect / line / polyline / polygon / path /
+       circle / text, so a chart can be put in the PDF as the vectors it
+       already is. Anything unexpected in the tree makes the caller fall
+       back to a raster for that one object. */
+    var SVG_KNOWN = { g: 1, rect: 1, line: 1, circle: 1, polyline: 1,
+        polygon: 1, path: 1, text: 1, defs: 1, title: 1, desc: 1 };
+
+    /* svgTranslatable asks whether drawSvg can express this tree, before
+       anything is drawn - an element it does not know, or a label in a
+       script the standard fonts cannot show, means the chart has to come
+       in as a picture instead. */
+    function svgTranslatable(el) {
+        for (var i = 0; i < el.children.length; i++) {
+            var c = el.children[i];
+            var tag = c.tagName.toLowerCase();
+            if (!SVG_KNOWN[tag]) return false;
+            if (tag === "text") {
+                var str = c.textContent || "";
+                if (str.trim() && !isWinAnsi(str)) return false;
+                if (!stdFamilyOf(window.getComputedStyle(c).fontFamily)) return false;
+            }
+            if (tag === "g" && !svgTranslatable(c)) return false;
+        }
+        return true;
+    }
+
+    function drawSvg(pg, svgEl, x, y, w, h) {
+        var vb = (svgEl.getAttribute("viewBox") || "").split(/[\s,]+/).map(parseFloat);
+        var vw = vb.length === 4 ? vb[2] : w, vh = vb.length === 4 ? vb[3] : h;
+        var vx = vb.length === 4 ? vb[0] : 0, vy = vb.length === 4 ? vb[1] : 0;
+        if (!(vw > 0) || !(vh > 0)) return false;
+        var k = Math.min(w / vw, h / vh);
+        var offX = x + (w - vw * k) / 2, offY = y + (h - vh * k) / 2;
+        var X = function (v) { return offX + (v - vx) * k; };
+        var Y = function (v) { return offY + (v - vy) * k; };
+
+        var ok = true;
+        var walk = function (el) {
+            if (!ok) return;
+            for (var i = 0; i < el.children.length; i++) {
+                var c = el.children[i];
+                var tag = c.tagName.toLowerCase();
+                var cs = window.getComputedStyle(c);
+                var fill = c.getAttribute("fill");
+                var stroke = c.getAttribute("stroke");
+                // charts inherit their text colour; resolve it the way the
+                // browser did rather than guessing
+                var fillF = (fill === "none") ? null
+                    : parseFill(fill === "currentColor" || !fill ? cs.color : fill);
+                var strokeF = (!stroke || stroke === "none") ? null
+                    : parseFill(stroke === "currentColor" ? cs.color : stroke);
+                var fillCol = fillF ? fillF.c : null, fillA = fillF ? fillF.a : 1;
+                var strokeCol = strokeF ? strokeF.c : null, strokeA = strokeF ? strokeF.a : 1;
+                var sw = parseFloat(c.getAttribute("stroke-width") || "1") * k;
+                switch (tag) {
+                    case "g": walk(c); break;
+                    case "rect":
+                        pg.rect(X(parseFloat(c.getAttribute("x") || 0)),
+                            Y(parseFloat(c.getAttribute("y") || 0)),
+                            parseFloat(c.getAttribute("width") || 0) * k,
+                            parseFloat(c.getAttribute("height") || 0) * k,
+                            { fill: fillCol, stroke: strokeCol, strokeW: strokeCol ? sw : 0,
+                              fillOpacity: fillA, strokeOpacity: strokeA });
+                        break;
+                    case "line":
+                        pg.p.drawLine({
+                            start: { x: px(X(parseFloat(c.getAttribute("x1") || 0))), y: pg.y(Y(parseFloat(c.getAttribute("y1") || 0))) },
+                            end: { x: px(X(parseFloat(c.getAttribute("x2") || 0))), y: pg.y(Y(parseFloat(c.getAttribute("y2") || 0))) },
+                            thickness: px(sw), color: strokeCol || PDFLib.rgb(0, 0, 0)
+                        });
+                        break;
+                    case "circle":
+                        pg.p.drawCircle({
+                            x: px(X(parseFloat(c.getAttribute("cx") || 0))),
+                            y: pg.y(Y(parseFloat(c.getAttribute("cy") || 0))),
+                            size: px(parseFloat(c.getAttribute("r") || 0) * k),
+                            color: fillCol || undefined,
+                            borderColor: strokeCol || undefined,
+                            borderWidth: strokeCol ? px(sw) : undefined
+                        });
+                        break;
+                    case "polyline":
+                    case "polygon":
+                        var pts = (c.getAttribute("points") || "").trim().split(/\s+/).map(function (p) {
+                            var xy = p.split(",");
+                            return [X(parseFloat(xy[0])), Y(parseFloat(xy[1]))];
+                        }).filter(function (p) { return isFinite(p[0]) && isFinite(p[1]); });
+                        if (pts.length < 2) break;
+                        var ops = [];
+                        pts.forEach(function (p, i) {
+                            ops.push(i === 0 ? PDFLib.moveTo(px(p[0]), pg.y(p[1]))
+                                : PDFLib.lineTo(px(p[0]), pg.y(p[1])));
+                        });
+                        if (tag === "polygon") ops.push(PDFLib.closePath());
+                        pg.save();
+                        if (fillCol) ops.unshift(PDFLib.setFillingColor(fillCol));
+                        if (strokeCol) {
+                            ops.unshift(PDFLib.setStrokingColor(strokeCol));
+                            ops.unshift(PDFLib.setLineWidth(px(sw)));
+                        }
+                        ops.push(fillCol && strokeCol ? PDFLib.fillAndStroke()
+                            : (fillCol ? PDFLib.fill() : PDFLib.stroke()));
+                        pg.ops(ops);
+                        pg.restore();
+                        break;
+                    case "path":
+                        var d = c.getAttribute("d");
+                        if (!d) break;
+                        pg.p.drawSvgPath(d, {
+                            x: px(offX - vx * k), y: pg.y(offY - vy * k), scale: k * PX_TO_PT,
+                            color: fillCol || undefined,
+                            borderColor: strokeCol || undefined,
+                            borderWidth: strokeCol ? px(sw) : undefined
+                        });
+                        break;
+                    case "text":
+                        var str = c.textContent || "";
+                        if (!str.trim()) break;
+                        if (!isWinAnsi(str)) { ok = false; return; }
+                        // the svg is measured in px like the rest of the
+                        // slide; only the final drawText call is in points
+                        var sizePx = (parseFloat(cs.fontSize) || 12) * k;
+                        var fam = stdFamilyOf(cs.fontFamily) || "Helvetica";
+                        var font = pg.fontFor(fam, (parseInt(cs.fontWeight, 10) || 400) >= 600, false);
+                        var twPx = font.widthOfTextAtSize(str, sizePx);
+                        var anchor = c.getAttribute("text-anchor") || "start";
+                        var tx = X(parseFloat(c.getAttribute("x") || 0));
+                        if (anchor === "middle") tx -= twPx / 2;
+                        else if (anchor === "end") tx -= twPx;
+                        pg.p.drawText(str, {
+                            x: px(tx), y: pg.y(Y(parseFloat(c.getAttribute("y") || 0))),
+                            size: px(sizePx), font: font, color: fillCol || PDFLib.rgb(0, 0, 0)
+                        });
+                        break;
+                    case "defs":
+                    case "title":
+                    case "desc":
+                        break;
+                    default:
+                        ok = false;
+                        return;
+                }
+            }
+        };
+        walk(svgEl);
+        return ok;
+    }
+
+    /* ---------------- one object ---------------- */
+
+    /* An object's rotation turns the whole object about its own centre.
+       Rather than rotate every primitive, the drawing runs inside one
+       matrix and the offscreen element is measured with its CSS rotation
+       taken off - otherwise getBoundingClientRect would hand back the
+       bounding box of the rotated result instead of the box itself. */
+    function withRotation(pg, o, el, fn) {
+        if (!o.rot) return fn();
+        var prev = el ? el.style.transform : null;
+        if (el) {
+            el.style.transform = "none";
+            void el.offsetWidth;                 // force the reflow
+        }
+        var rad = -o.rot * Math.PI / 180;        // css turns clockwise, pdf does not
+        var cos = Math.cos(rad), sin = Math.sin(rad);
+        var cx = px(o.x + o.w / 2), cy = pg.y(o.y + o.h / 2);
+        pg.save();
+        pg.ops([PDFLib.concatTransformationMatrix(cos, sin, -sin, cos,
+            cx - (cos * cx - sin * cy), cy - (sin * cx + cos * cy))]);
+        var restore = function () {
+            pg.restore();
+            if (el) el.style.transform = prev;
+        };
+        var out;
+        try { out = fn(); } catch (e) { restore(); throw e; }
+        return Promise.resolve(out).then(function (v) { restore(); return v; },
+            function (e) { restore(); throw e; });
+    }
+
+    /* needsRaster answers the fallback question up front, before anything
+       is drawn and before any transform is touched. It only inspects fonts
+       and characters, so a rotated element can be asked safely. */
+    function needsRaster(o, el) {
+        if (!el) return false;
+        if (o.type === "chart") {
+            var svg = el.querySelector("svg");
+            return !svg || !svgTranslatable(svg);
+        }
+        var root = null;
+        if (o.type === "text") root = el.querySelector(".sl-text-in");
+        else if (o.type === "shape") root = el.querySelector(".sl-shape-text");
+        else if (o.type === "table") root = el;
+        else return false;
+        if (!root) return false;
+        var runs = collectRuns(root, { left: 0, top: 0, x: 0, y: 0 });
+        return runs.length > 0 && !canDrawAsText(runs);
+    }
+
+    function drawObject(pg, o, el, ctx) {
+        var raster = !!(el && needsRaster(o, el));
+        return withRotation(pg, o, el, function () {
+            return raster ? drawAsRaster(pg, o, el, ctx)
+                : drawObjectBody(pg, o, el, ctx);
+        });
+    }
+
+    function drawObjectBody(pg, o, el, ctx) {
+        var origin = null;
+        if (el) {
+            var r = el.getBoundingClientRect();
+            origin = { left: r.left, top: r.top, x: o.x, y: o.y };
+        }
+
+        switch (o.type) {
+            case "image":
+            case "video":
+            case "audio":
+                return drawImageObject(pg, o, el, ctx);
+            case "shape":
+                return drawShapeObject(pg, o, el, origin, ctx);
+            case "line":
+                drawLineObject(pg, o);
+                return Promise.resolve();
+            case "table":
+                return drawTableObject(pg, o, el, ctx);
+            case "chart":
+                return drawChartObject(pg, o, el, ctx);
+            case "text":
+                return drawTextObject(pg, o, el, origin, ctx);
+        }
+        return Promise.resolve();
+    }
+
+    /* ---- text object ---- */
+    function drawTextObject(pg, o, el, origin, ctx) {
+        var inner = el ? el.querySelector(".sl-text-in") : null;
+        if (!inner) return Promise.resolve();
+        var runs = collectRuns(inner, origin);
+        if (runs.length) drawRuns(pg, runs);
+        return Promise.resolve();
+    }
+
+    /* ---- image object ---- */
+    function drawImageObject(pg, o, el, ctx) {
+        var embedImage = ctx.embed;
+        var p = o.props || {};
+        var src = p.src;
+        if (o.type === "video" || o.type === "audio") src = p.png || "";
+        if (!src) return Promise.resolve();
+        var filter = (window.SlidesImageTools ? SlidesImageTools.imageFilter(p) : "");
+        var prep = filter ? filteredImageData(src, filter) : Promise.resolve(src);
+        return prep.then(function (finalSrc) {
+            return embedImage(finalSrc || src);
+        }).then(function (img) {
+            if (!img) return;
+            var crop = p.crop;
+            var kw = 1, kh = 1, cl = 0, ct = 0;
+            if (crop && crop.length === 4) {
+                cl = Number(crop[0]) || 0; ct = Number(crop[1]) || 0;
+                kw = 1 - cl - (Number(crop[2]) || 0);
+                kh = 1 - ct - (Number(crop[3]) || 0);
+                if (!(kw > 0.001) || !(kh > 0.001)) { kw = kh = 1; cl = ct = 0; }
+            }
+            // the visible window is the object box; the whole picture is
+            // that box scaled up by the crop, shifted so the kept part lines
+            // up - the same identity the editor and the pptx reader use
+            var fullW = o.w / kw, fullH = o.h / kh;
+            var fullX = o.x - cl * fullW, fullY = o.y - ct * fullH;
+
+            pg.save();
+            // clip to the frame, in the shape the picture is masked to
+            var clipOps = shapePathOps(p.mask || "rect", o.x, o.y, o.w, o.h, p.radius);
+            clipOps.push(PDFLib.clip(), PDFLib.endPath());
+            pg.ops(clipOps);
+            if (p.opacity && p.opacity < 1) {
+                pg.ops([PDFLib.setGraphicsState(pg.alpha(clamp(p.opacity, 0, 1)))]);
+            }
+            // flips are a negative scale about the picture's own centre
+            var sx = p.flipH ? -1 : 1, sy = p.flipV ? -1 : 1;
+            var drawX = fullX, drawY = fullY;
+            if (sx < 0 || sy < 0) {
+                var cx = px(fullX + fullW / 2), cy = pg.y(fullY + fullH / 2);
+                pg.ops([PDFLib.concatTransformationMatrix(sx, 0, 0, sy,
+                    cx - sx * cx, cy - sy * cy)]);
+            }
+            pg.p.drawImage(img, {
+                x: px(drawX), y: pg.y(drawY + fullH),
+                width: px(fullW), height: px(fullH)
+            });
+            pg.restore();
+        });
+    }
+
+    /* ---- shape object ---- */
+    function drawShapeObject(pg, o, el, origin, ctx) {
+        var p = o.props || {};
+        var fill = (p.fill && p.fill !== "none") ? parseColor(p.fill) : null;
+        var stroke = (p.strokeW > 0 && p.stroke && p.stroke !== "none") ? parseColor(p.stroke) : null;
+        if (fill || stroke) {
+            var ops = shapePathOps(p.kind || "rect", o.x, o.y, o.w, o.h, p.radius);
+            if (fill) ops.unshift(PDFLib.setFillingColor(fill));
+            if (stroke) {
+                ops.unshift(PDFLib.setStrokingColor(stroke));
+                ops.unshift(PDFLib.setLineWidth(px(p.strokeW)));
+                if (p.dash) ops.unshift(PDFLib.setDashPattern([px(p.strokeW * 3), px(p.strokeW * 2.4)], 0));
+            }
+            ops.push(fill && stroke ? PDFLib.fillAndStroke() : (fill ? PDFLib.fill() : PDFLib.stroke()));
+            pg.save();
+            pg.ops(ops);
+            pg.restore();
+        }
+        // the caption rides on the same text rules as a text box
+        var inner = el ? el.querySelector(".sl-shape-text") : null;
+        if (!inner) return Promise.resolve();
+        var runs = collectRuns(inner, origin || { left: 0, top: 0, x: o.x, y: o.y });
+        if (runs.length) drawRuns(pg, runs);
+        return Promise.resolve();
+    }
+
+    /* ---- line object ---- */
+    function drawLineObject(pg, o) {
+        var p = o.props || {};
+        var pts = (p.points && p.points.length >= 2)
+            ? p.points.map(function (pt) { return [o.x + pt[0], o.y + pt[1]]; })
+            : [[o.x, o.y], [o.x + o.w, o.y + o.h]];
+        var col = parseColor(p.stroke) || PDFLib.rgb(0.13, 0.13, 0.14);
+        var sw = Number(p.strokeW) || 2;
+
+        var trimmed = pts.slice();
+        var heads = [];
+        var arrow = function (tipIdx, fromIdx) {
+            var tip = pts[tipIdx], from = pts[fromIdx];
+            var ang = Math.atan2(tip[1] - from[1], tip[0] - from[0]);
+            var s = 6 + sw * 2.4;
+            var bx = tip[0] - s * Math.cos(ang), by = tip[1] - s * Math.sin(ang);
+            var ox = s * 0.45 * -Math.sin(ang), oy = s * 0.45 * Math.cos(ang);
+            heads.push([tip, [bx + ox, by + oy], [bx - ox, by - oy]]);
+            return [tip[0] - s * 0.6 * Math.cos(ang), tip[1] - s * 0.6 * Math.sin(ang)];
+        };
+        if (p.arrowEnd) trimmed[trimmed.length - 1] = arrow(pts.length - 1, pts.length - 2);
+        if (p.arrowStart) trimmed[0] = arrow(0, 1);
+
+        var ops = [PDFLib.setStrokingColor(col), PDFLib.setLineWidth(px(sw)),
+            PDFLib.setLineCap(PDFLib.LineCapStyle.Round),
+            PDFLib.setLineJoin(PDFLib.LineJoinStyle.Round)];
+        if (p.dash) ops.push(PDFLib.setDashPattern([px(sw * 3), px(sw * 2.4)], 0));
+        trimmed.forEach(function (pt, i) {
+            ops.push(i === 0 ? PDFLib.moveTo(px(pt[0]), pg.y(pt[1])) : PDFLib.lineTo(px(pt[0]), pg.y(pt[1])));
+        });
+        ops.push(PDFLib.stroke());
+        pg.save();
+        pg.ops(ops);
+        pg.restore();
+
+        heads.forEach(function (tri) {
+            var hops = [PDFLib.setFillingColor(col)];
+            tri.forEach(function (pt, i) {
+                hops.push(i === 0 ? PDFLib.moveTo(px(pt[0]), pg.y(pt[1])) : PDFLib.lineTo(px(pt[0]), pg.y(pt[1])));
+            });
+            hops.push(PDFLib.closePath(), PDFLib.fill());
+            pg.save();
+            pg.ops(hops);
+            pg.restore();
+        });
+    }
+
+    /* ---- table object ---- */
+    function drawTableObject(pg, o, el, ctx) {
+        if (!el) return Promise.resolve();
+        var host = el.getBoundingClientRect();
+        var origin = { left: host.left, top: host.top, x: o.x, y: o.y };
+        var cells = el.querySelectorAll("td, th");
+        var jobs = [];
+        for (var i = 0; i < cells.length; i++) {
+            (function (cell) {
+                var cs = window.getComputedStyle(cell);
+                var cr = cell.getBoundingClientRect();
+                var cx = o.x + (cr.left - host.left), cy = o.y + (cr.top - host.top);
+                var bg = parseFill(cs.backgroundColor);
+                if (bg) pg.rect(cx, cy, cr.width, cr.height, { fill: bg.c, fillOpacity: bg.a });
+                var bd = parseFill(cs.borderTopColor);
+                var bw = parseFloat(cs.borderTopWidth) || 0;
+                if (bd && bw > 0) {
+                    pg.rect(cx, cy, cr.width, cr.height,
+                        { stroke: bd.c, strokeW: bw, strokeOpacity: bd.a });
+                }
+                var runs = collectRuns(cell, origin);
+                if (runs.length) drawRuns(pg, runs);
+            })(cells[i]);
+        }
+        return Promise.all(jobs);
+    }
+
+    /* ---- chart object ---- */
+    function drawChartObject(pg, o, el, ctx) {
+        var svg = el ? el.querySelector("svg") : null;
+        if (svg) drawSvg(pg, svg, o.x, o.y, o.w, o.h);
+        return Promise.resolve();
+    }
+
+    /* ---------------- the build ---------------- */
+
+    /* stage renders one slide offscreen at exactly 960x540 so every
+       measurement below is taken at scale 1, whatever zoom the editor is at */
+    function withStage(slide, fn) {
+        var holder = document.createElement("div");
+        holder.style.cssText = "position:fixed;left:-20000px;top:0;width:960px;height:540px;" +
+            "overflow:hidden;contain:layout;";
+        var el = document.createElement("div");
+        el.className = "sl-slidebase";
+        el.style.cssText = "width:960px;height:540px;position:relative;overflow:hidden;";
+        holder.appendChild(el);
+        document.body.appendChild(holder);
+        SlidesApp.renderSlideContent(el, slide);
+        return waitForImages(el).then(function () {
+            return fn(el);
+        }).then(function (r) {
+            holder.remove();
+            return r;
+        }, function (e) {
+            holder.remove();
+            throw e;
+        });
+    }
+
+    function waitForImages(root) {
+        var imgs = Array.prototype.slice.call(root.querySelectorAll("img"));
+        var pending = imgs.filter(function (im) { return !im.complete; });
+        var fonts = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
+        if (!pending.length) return fonts;
+        return Promise.all([fonts].concat(pending.map(function (im) {
+            return new Promise(function (res) {
+                var done = function () { res(); };
+                im.addEventListener("load", done);
+                im.addEventListener("error", done);
+                setTimeout(done, 6000);
+            });
+        })));
+    }
+
+    function build(body, opts) {
+        opts = opts || {};
+        if (typeof PDFLib === "undefined") {
+            return Promise.reject(new Error("the PDF library failed to load"));
+        }
+        var slides = (body && body.slides) || [];
+        if (!slides.length) return Promise.reject(new Error("the presentation has no slides"));
+
+        return PDFLib.PDFDocument.create().then(function (pdfDoc) {
+            var fontFor = makeFontCache(pdfDoc);
+            var embedImage = makeImageEmbedder(pdfDoc);
+            var theme = SlidesApp.themeOf();
+            var chain = Promise.resolve();
+            slides.forEach(function (slide, idx) {
+                chain = chain.then(function () {
+                    var page = pdfDoc.addPage([px(SLIDE_W), px(SLIDE_H)]);
+                    var pg = new Page(page, pdfDoc, fontFor);
+                    var bg = parseColor(slide.bg || theme.bg);
+                    if (bg) pg.rect(0, 0, SLIDE_W, SLIDE_H, { fill: bg });
+                    return withStage(slide, function (stageEl) {
+                        var ctx = { stage: stageEl, embed: embedImage };
+                        var objs = slide.objects || [];
+                        var seq = Promise.resolve();
+                        objs.forEach(function (o, i) {
+                            seq = seq.then(function () {
+                                return drawObject(pg, o, stageEl.children[i], ctx);
+                            });
+                        });
+                        return seq;
+                    });
+                }).then(function () {
+                    if (opts.onProgress) opts.onProgress(idx + 1, slides.length);
+                });
+            });
+            return chain.then(function () { return pdfDoc.save(); });
+        });
+    }
+
+    return {
+        build: build,
+        // exposed for the tests and for anyone reusing the SVG translator
+        _stdFamilyOf: stdFamilyOf,
+        _isWinAnsi: isWinAnsi
+    };
+})();

+ 7 - 0
src/web/Terminal/docs/api.json

@@ -997,6 +997,13 @@
      "desc": "Build a real-text PDF from a serialized Slides body JSON: one page per slide at the deck canvas size. Text and tables stay selectable; images/charts embed from data URLs; video/audio render their captured poster frame (props.png) or a generic placeholder.",
      "ret": "boolean",
      "example": "requirelib(\"office\");\nvar ok = office.presentationToPdf(data, \"user:/Desktop/deck.pdf\");\nsendResp(ok ? \"OK\" : \"failed\");"
+    },
+    {
+     "name": "office.writeBinaryFile",
+     "sig": "office.writeBinaryFile(base64, destVpath)",
+     "desc": "Write a file the web client produced: the payload is the file's bytes, base64 encoded, and destVpath gets the same write permission check as every exporter. Used by exports that can only be rendered in the browser, such as the Slides PDF.",
+     "ret": "boolean",
+     "example": "requirelib(\"office\");\nvar ok = office.writeBinaryFile(base64Payload, \"user:/Desktop/deck.pdf\");\nsendResp(ok ? \"OK\" : \"failed\");"
     }
    ]
   },