فهرست منبع

Add foreign-format save flow to Office

Teach Docs and Slides to save imported files back into their original formats via shared `saveFormats`, including capability vetoes and autosave opt-outs for expensive converters. The Office shell now shows a foreign-format warning banner with a one-click convert-to-native action, adds host support to open the converted copy in a new window, and updates the Office docs/contracts to describe the new behavior.
Toby Chui 3 هفته پیش
والد
کامیت
f2c783fb74

+ 1 - 0
.gitignore

@@ -49,3 +49,4 @@ src/system/telegram_conf.json
 # must never be committed
 src/system/git/
 /.kilo
+/reference

+ 32 - 10
src/web/Office/README.md

@@ -249,20 +249,42 @@ styles at export time.
 
 ### Saving back into a foreign format
 
-Sheets declares `saveFormats` (see
-[`common/CONTRACT.md`](common/CONTRACT.md)), so a workbook opened from
-`.xlsx` / `.ods` / `.csv` / `.tsv` **stays that file**: `Ctrl+S` rewrites it
-in its own format instead of forcing a Save As to `.xlsa`, and File > Save as
+All three apps declare `saveFormats` (see
+[`common/CONTRACT.md`](common/CONTRACT.md)), so a document opened from a
+foreign format **stays that file**: `Ctrl+S` rewrites it in its own format
+instead of forcing a Save As to the native container, and File > Save as
 offers the whole list (plus PDF, which is one-way).
 
+| App | saved back into | declared in |
+|---|---|---|
+| Docs | .docx, .odt, .html/.htm, .md, .txt (+ .pdf one-way) | `SAVE_FORMATS` in [`docs/docs.js`](docs/docs.js) |
+| Sheets | .xlsx, .ods, .csv, .tsv (+ .pdf one-way) | `SAVE_FORMATS` in [`sheets/sheets_io.js`](sheets/sheets_io.js) |
+| Slides | .pptx, .odp (+ .pdf one-way) | `SAVE_FORMATS` in [`slides/slides.js`](slides/slides.js) |
+
 Each format vetoes what it cannot hold — `.csv`/`.tsv` reject formulas,
 charts, notes, merges and second sheets; `.ods` rejects charts
-(`ods_writer.go` cannot represent them); `.xlsx` takes everything. The veto
-lists what would be lost and offers `.xlsa` instead, so no save quietly drops
-content. Purely visual formatting is deliberately *not* a veto reason: it
-would fire on nearly every CSV edit. When a format's Go writer gains or loses
-a capability, update the matching `unsupported()` in
-[`sheets/sheets_io.js`](sheets/sheets_io.js).
+(`ods_writer.go` cannot represent them); `.odp` rejects video and audio
+objects (`odp_writer.go` emits no case for them); every Docs writer but the
+native one rejects comments and pending suggestions, because they are fed
+`resolvedHtml()` and would come back with insertions accepted and deletions
+applied; `.txt` additionally rejects images and tables; `.xlsx` and `.pptx`
+take everything. The veto lists what would be lost and offers the native
+extension instead, so no save quietly drops content. Purely visual formatting
+is deliberately *not* a veto reason: it would fire on nearly every CSV edit.
+When a format's Go writer gains or loses a capability, update the matching
+`unsupported()`.
+
+**The banner.** Living in a foreign file is the right default — somebody who
+opened a `.docx` wants a `.docx` back — but it also means every feature that
+format cannot hold is dropped on each save. So the framework shows a warning
+strip under the toolbar for as long as the open file is not native
+(`updateForeignBanner` in [`common/office.js`](common/office.js), styled
+`.of-fmtbanner` in [`common/office.css`](common/office.css)), with the one
+click out: **Convert to `<native ext>`** asks where to put a native copy,
+writes it, and opens it in a window of its own through
+`OfficePlatform.openDocument` — leaving this editor on the original file,
+because which of the two to go on working in is the person's call. Native
+documents never see any of it, and the strip can be dismissed per file.
 
 All that pre-baking makes the export payload big, and the AGI gateway reads
 its POST parameters with Go's `r.ParseForm`, which **drops every parameter

+ 26 - 5
src/web/Office/common/CONTRACT.md

@@ -239,6 +239,8 @@ readText writeText containerLoad containerSave
 sessionSave sessionLoad sessionDelete
 agirun agirunLarge prepareWorkdir mediaUrl blobToSrc
 loadInputFiles adoptDroppedFile setWindowTitle setWindowTheme
+openDocument     // open a document in a second window of this app;
+                 // false = nowhere to open it from (standalone downloads)
 ```
 
 **Adding a format conversion:** add the converter to
@@ -378,6 +380,12 @@ saveFormats: [{
     label: "CSV (.csv)",              // shown in the Save as submenu
     icon: "file alternate outline",   // semantic icon name
     oneWay: true,                     // optional; a rendering such as PDF
+    hidden: true,                     // optional; save-back only, kept out of
+                                      // the Save as list (a second extension
+                                      // for a format already listed, .htm)
+    noAutosave: true,                 // optional; too expensive to run on a
+                                      // timer - autosave skips it and keeps
+                                      // the session snapshot instead
     unsupported: function(){ return ["2 charts"]; },   // null/[] = fine
     save: function(fp, fn, done, fail){ … }            // fail(msg) on error
 }]
@@ -403,11 +411,24 @@ What the framework then does:
   nags on every save.
 - **`oneWay: true`** marks a rendering (PDF): it is written, but the document
   keeps its own path and stays dirty, because you cannot reopen it.
-- **Autosave** writes a foreign format only while `unsupported()` passes; when
-  it does not, autosave silently skips the file and falls back to the session
-  snapshot rather than popping a dialog.
-
-Sheets is the reference implementation (`sheets/sheets_io.js`, `SAVE_FORMATS`).
+- **Autosave** writes a foreign format only while `unsupported()` passes, and
+  never writes one marked `noAutosave`; in either case it silently skips the
+  file and falls back to the session snapshot rather than popping a dialog.
+  `noAutosave` is for writers whose *preparation* is the expensive part —
+  Slides rasterizes every chart and seeks each video for a poster frame, Docs
+  refetches and re-rasterizes every image — which is fine once on Ctrl+S and
+  wrong every 25 seconds.
+- **A warning strip appears under the toolbar** for as long as the open file
+  is not the app's own container, because living in a foreign file means
+  everything that format cannot hold is dropped on every save. Its **Convert
+  to `<native ext>`** button asks where to put a native copy, writes it, and
+  opens it in a window of its own (`OfficePlatform.openDocument`) — this
+  editor stays on the original file. The framework owns all of it
+  (`updateForeignBanner` / `convertToNative`, `.of-fmtbanner` in
+  `office.css`); apps need do nothing, and a native document never sees it.
+
+All three apps declare `saveFormats`; Sheets is the reference implementation
+(`sheets/sheets_io.js`, `SAVE_FORMATS`).
 
 ## Packed native files (zip container)
 

+ 64 - 0
src/web/Office/common/office.css

@@ -24,6 +24,9 @@
     --of-hover: rgba(60, 64, 67, 0.08);
     --of-active: rgba(60, 64, 67, 0.14);
     --of-scrollbar: #c3c7cc;
+    --of-warn-bg: #fdf3d8;
+    --of-warn-fg: #6b5210;
+    --of-warn-border: #e5cf94;
 }
 
 body.dark {
@@ -40,6 +43,9 @@ body.dark {
     --of-hover: rgba(232, 234, 237, 0.08);
     --of-active: rgba(232, 234, 237, 0.15);
     --of-scrollbar: #4d5157;
+    --of-warn-bg: #3a3423;
+    --of-warn-fg: #f0dda6;
+    --of-warn-border: #5e5433;
 }
 
 /* Per-app accent colors */
@@ -234,6 +240,64 @@ body.of-app {
     cursor: pointer;
 }
 
+/* ============ Foreign-format banner ============ */
+/* Sits between the toolbar and the workspace while the open document lives
+   in a format that is not the app's own container (.docx, .pptx, .csv, ...).
+   office.js builds and shows it; see updateForeignBanner(). */
+.of-fmtbanner {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex: 0 0 auto;
+    padding: 7px 10px 7px 12px;
+    background: var(--of-warn-bg);
+    color: var(--of-warn-fg);
+    border-bottom: 1px solid var(--of-warn-border);
+    font-size: 13px;
+    line-height: 1.35;
+}
+.of-fmtbanner > i.icon {
+    flex: 0 0 auto;
+    margin: 0;
+    opacity: 0.85;
+}
+.of-fmtbanner .of-fmtbanner-msg {
+    flex: 1 1 auto;
+    min-width: 0;
+}
+.of-fmtbanner .of-fmtbanner-btn {
+    flex: 0 0 auto;
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 4px 12px;
+    border: 1px solid var(--of-warn-border);
+    border-radius: 4px;
+    background: var(--of-chrome-bg);
+    color: var(--of-warn-fg);
+    font: inherit;
+    font-weight: 600;
+    cursor: pointer;
+    white-space: nowrap;
+}
+.of-fmtbanner .of-fmtbanner-btn:hover { background: var(--of-hover); }
+.of-fmtbanner .of-fmtbanner-btn i.icon { margin: 0; }
+.of-fmtbanner .of-fmtbanner-x {
+    flex: 0 0 auto;
+    width: 22px;
+    height: 22px;
+    padding: 0;
+    border: none;
+    border-radius: 3px;
+    background: transparent;
+    color: inherit;
+    font-size: 17px;
+    line-height: 1;
+    opacity: 0.6;
+    cursor: pointer;
+}
+.of-fmtbanner .of-fmtbanner-x:hover { background: var(--of-hover); opacity: 1; }
+
 /* ============ Workspace ============ */
 .of-workspace {
     flex: 1 1 auto;

+ 126 - 16
src/web/Office/common/office.js

@@ -274,6 +274,8 @@ var OfficeApp = (function () {
         OfficePlatform.setWindowTitle(t);
         var $dn = $(".of-docname");
         $dn.html((dirty ? '<span class="of-dirty-dot">• </span>' : "") + escapeHtml(name));
+        // the document's identity only ever changes alongside its title
+        updateForeignBanner();
     }
     function setStatus(msg, type, timeout) {
         var $m = $(".of-status-msg");
@@ -677,6 +679,26 @@ var OfficeApp = (function () {
             doSaveTo(file.filepath, file.filename, cb);
         });
     }
+    /*
+        Write the document into the app's own container at fp. Shared by Save
+        / Save As and by the convert-to-native action, which writes a copy
+        without moving the open document onto it - so this deliberately
+        touches nothing but the file. Returns false when the envelope could
+        not even be built.
+    */
+    function writeNative(fp, done, fail) {
+        var env;
+        try { env = buildEnvelope(); }
+        catch (e) { fail(e.message); return false; }
+        var payload = JSON.stringify(env);
+        if (cfg.packed) {
+            // native zip container: media data URLs become embedded assets
+            OfficePlatform.containerSave(fp, payload, done, fail);
+        } else {
+            vfsSave(fp, payload, done, fail);
+        }
+        return true;
+    }
     // returns false when the write was declined (format cannot hold the doc)
     function doSaveTo(fp, fn, cb, silent) {
         var ext = extOf(fn);
@@ -684,15 +706,18 @@ var OfficeApp = (function () {
             // one of the app's own foreign formats - including a document
             // opened from one and saved straight back with Ctrl+S
             var fmt = findSaveFormat(ext, false);
-            if (fmt) return doSaveForeign(fp, fn, fmt, cb, silent);
+            if (fmt) {
+                // a writer too expensive to run on a timer (rasterizing
+                // charts, seeking videos for a poster frame, refetching every
+                // image) stays out of autosave; the caller falls back to the
+                // session snapshot, which is what that safety net is for
+                if (silent && fmt.noAutosave) return false;
+                return doSaveForeign(fp, fn, fmt, cb, silent);
+            }
         }
         if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
         setStatus("Saving...", "info", 0);
-        var env;
-        try { env = buildEnvelope(); }
-        catch (e) { setStatus("Save failed: " + e.message, "error"); return false; }
-        var payload = JSON.stringify(env);
-        var done = function () {
+        return writeNative(fp, function () {
             filepath = fp; filename = fn;
             loadedFromImport = false;
             markClean();
@@ -700,17 +725,93 @@ var OfficeApp = (function () {
             setStatus("Saved " + fn);
             saveSession();   // keep the session snapshot in step with the file
             if (cb) cb();
-        };
-        var fail = function (err) {
+        }, function (err) {
             setStatus("Save failed: " + err, "error");
-        };
-        if (cfg.packed) {
-            // native zip container: media data URLs become embedded assets
-            OfficePlatform.containerSave(fp, payload, done, fail);
-        } else {
-            vfsSave(fp, payload, done, fail);
-        }
-        return true;
+        });
+    }
+
+    /* ---------- foreign-format banner ---------- */
+    /*
+        A document opened from .docx / .pptx / .csv / ... goes on living in
+        that file: Save rewrites it in its own format rather than quietly
+        turning it into a native container (see doSaveTo). That is the right
+        default - somebody who opened a .docx wants a .docx back - but it
+        also means every ArozOS Office feature the foreign format cannot hold
+        is being dropped on each save, which is worth saying out loud once
+        rather than only at the moment content is lost.
+
+        So the editor carries a banner under the toolbar for as long as the
+        open file is not native, offering the one-click way out: convert to
+        the app's own format. Native documents never see any of this.
+    */
+    var bannerDismissed = null;   // filepath the banner was dismissed for
+
+    // the format the open document currently lives in, or null when that is
+    // the app's own container (or nothing on disk yet)
+    function foreignFormat() {
+        if (!filepath || !filename) return null;
+        var ext = extOf(filename);
+        if (!ext || ext === cfg.extension) return null;
+        return { ext: ext, fmt: findSaveFormat(ext, true) };
+    }
+    function buildForeignBanner() {
+        var $b = $('<div class="of-fmtbanner of-noprint" style="display:none;"></div>');
+        $b.append('<i class="exclamation triangle icon"></i>');
+        $b.append('<span class="of-fmtbanner-msg"></span>');
+        $b.append($('<button type="button" class="of-fmtbanner-btn"></button>')
+            .html('<i class="exchange icon"></i>Convert to ' + escapeHtml(cfg.extension))
+            .attr("title", "Save a copy as an ArozOS Office " + cfg.fileTypeName.toLowerCase() +
+                " and open it")
+            .on("click", convertToNative));
+        $b.append($('<button type="button" class="of-fmtbanner-x" title="Dismiss">×</button>')
+            .on("click", function () {
+                bannerDismissed = filepath;
+                $(".of-fmtbanner").hide();
+            }));
+        return $b;
+    }
+    function updateForeignBanner() {
+        var $b = $(".of-fmtbanner");
+        if (!$b.length) return;
+        var f = foreignFormat();
+        if (!f || bannerDismissed === filepath) { $b.hide(); return; }
+        var what = f.fmt ? formatLabel(f.fmt) : (f.ext + " file");
+        $b.find(".of-fmtbanner-msg").html(
+            "This is a <b>" + escapeHtml(what) + "</b>, not an ArozOS Office " +
+            escapeHtml(cfg.fileTypeName.toLowerCase()) + ". Saving keeps that format, " +
+            "so anything " + escapeHtml(f.ext) + " cannot store is lost.");
+        $b.css("display", "");
+    }
+    /*
+        Write the document out as a native container and open that copy in a
+        window of its own. The editor stays on the original foreign file: the
+        converted document is a new file, and which of the two to go on
+        working in is the person's call, not ours.
+    */
+    function convertToNative() {
+        var defName = stripExt(filename || cfg.defaultFileName) + cfg.extension;
+        OfficePlatform.pickSave({
+            defaultName: defName,
+            ext: cfg.extension,
+            memoryKey: "document"
+        }, function (file) {
+            if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
+            setStatus("Converting to " + cfg.extension + "...", "info", 0);
+            writeNative(file.filepath, function () {
+                setStatus("Converted to " + file.filename);
+                addRecent(file.filepath, file.filename);
+                // the question has been answered for this file either way
+                bannerDismissed = filepath;
+                updateForeignBanner();
+                var opened = OfficePlatform.openDocument(file.filepath, file.filename,
+                    { appIcon: cfg.appIcon });
+                toast(opened ? ("Converted - " + file.filename + " opened in a new window")
+                    : ("Converted - saved as " + file.filename));
+            }, function (err) {
+                setStatus("Convert failed: " + err, "error");
+                toast("Convert failed: " + err, "error");
+            });
+        });
     }
 
     /* ---------- autosave ---------- */
@@ -913,6 +1014,9 @@ var OfficeApp = (function () {
             { sep: true }
         ];
         list.forEach(function (f) {
+            // hidden: a writer that only exists to save a document back into
+            // the file it came from (a second extension for the same format)
+            if (f.hidden) return;
             sub.push({
                 label: formatLabel(f), icon: f.icon || "file outline",
                 action: function () { saveAsFormat(f); }
@@ -1247,6 +1351,12 @@ var OfficeApp = (function () {
         var $menubar = buildMenubar(standardMenus());
         $("body").prepend($menubar);
         $("body").append(buildStatusbar());
+        // the foreign-format banner sits under the whole toolbar strip, so
+        // it reads as a note about the document rather than part of the
+        // chrome; apps with no toolbar get it straight under the menubar
+        var $banner = buildForeignBanner();
+        var $tb = $("body > .of-toolbar").last();
+        if ($tb.length) $tb.after($banner); else $menubar.after($banner);
 
         // shortcuts (standard)
         registerShortcut("Ctrl+S", function () { save(); }, { description: "Save" });

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

@@ -81,6 +81,32 @@ var OfficePlatform = (function () {
         if (window.OfficeApp && OfficeApp.toast) OfficeApp.toast(msg, type);
         else status(msg, type);
     }
+    /*
+        This page's path relative to the ArozOS web root ("Office/docs/
+        index.html"), which is the form newFloatWindow wants. ao_root is the
+        way back up to that root ("../../"), so the same number of trailing
+        path segments is the way back down to here - and the app keeps
+        working if the whole desktop is served from a sub-path.
+    */
+    function pagePath() {
+        var parts = String(window.location.pathname).split("/")
+            .filter(function (s) { return s.length > 0; });
+        var up = (String(typeof ao_root === "string" ? ao_root : "").match(/\.\.\//g) || []).length;
+        if (up > 0 && parts.length > up + 1) parts = parts.slice(parts.length - up - 1);
+        return parts.join("/");
+    }
+    // a path relative to this page ("../img/docs.svg") in the same web-root
+    // relative form ("Office/img/docs.svg")
+    function pageRelative(rel) {
+        var dir = pagePath().split("/");
+        dir.pop();
+        String(rel || "").split("/").forEach(function (seg) {
+            if (!seg || seg === ".") return;
+            if (seg === "..") { dir.pop(); return; }
+            dir.push(seg);
+        });
+        return dir.join("/");
+    }
 
     /* ================================================================
        ArozOS host
@@ -331,6 +357,25 @@ var OfficePlatform = (function () {
             if (entry) return entry;
             try { return ao_module_loadInputFiles(); } catch (e) { return null; }
         },
+        /* Open a document in a second window of this same app - the desktop
+           launches another floatWindow, a plain browser tab another tab. The
+           file is handed over the same way the desktop hands one to an
+           opened-with app: the hash carries the input file list. */
+        openDocument: function (filepath, filename, opts) {
+            opts = opts || {};
+            var hash = "#" + encodeURIComponent(JSON.stringify(
+                [{ filepath: filepath, filename: filename }]));
+            try {
+                ao_module_newfw({
+                    url: pagePath() + hash,
+                    title: filename,
+                    appicon: opts.appIcon ? pageRelative(opts.appIcon) : undefined,
+                    width: opts.width || 1080,
+                    height: opts.height || 700
+                });
+                return true;
+            } catch (e) { return false; }
+        },
         setWindowTitle: function (t) {
             try { ao_module_setWindowTitle(t); } catch (e) { document.title = t; }
         },
@@ -760,6 +805,9 @@ var OfficePlatform = (function () {
         loadInputFiles: entryPointFiles,
         setWindowTitle: function (t) { document.title = t; },
         setWindowTheme: function () { },
+        /* Saving here is a download, so there is no path a second window
+           could be pointed at - the caller says so instead. */
+        openDocument: function () { return false; },
 
         /* A File that arrived by drag and drop rather than through a picker:
            register it the same way pickOpen does, then hand the caller the
@@ -836,6 +884,9 @@ var OfficePlatform = (function () {
         blobToSrc: function (b, n, cb, errcb) { host.blobToSrc(b, n, cb, errcb); },
 
         loadInputFiles: function () { return host.loadInputFiles(); },
+        // open a document in a second window of this app; false = this host
+        // has nowhere to open it from (the standalone build saves by download)
+        openDocument: function (fp, fn, o) { return !!host.openDocument(fp, fn, o || {}); },
         adoptDroppedFile: function (f, cb) {
             if (host.adoptDroppedFile) host.adoptDroppedFile(f, cb);
         },

+ 127 - 9
src/web/Office/docs/docs.js

@@ -2466,7 +2466,10 @@
         "ul.of-checklist li.checked::before{content:\"\\2611  \";}" +
         "ul.of-checklist li.checked{text-decoration:line-through;color:#888;}" +
         ".hf{color:#777;font-size:9pt;margin:10px 0;}";
-    function exportHTML() {
+    /* The three text renderings, built once and used twice: File > Export
+       downloads them, and the matching saveFormats writer stores them - which
+       is what a document opened from .html / .txt / .md is saved back into. */
+    function buildHTML() {
         var body = currentBody();
         var title = exportBaseName();
         var out = "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n" +
@@ -2478,19 +2481,21 @@
         out += resolvedHtml() + "\n";
         if (hf && body.footer) out += '<div class="hf">' + esc(body.footer) + "</div>\n";
         out += "</body>\n</html>\n";
-        downloadFile(title + ".html", "text/html", out);
+        return out;
     }
-    function exportText() {
+    function buildText() {
         var div = document.createElement("div");
         div.innerHTML = resolvedHtml();
-        downloadFile(exportBaseName() + ".txt", "text/plain", div.innerText || "");
+        return div.innerText || "";
     }
-    function exportMarkdown() {
+    function buildMarkdown() {
         var div = document.createElement("div");
         div.innerHTML = resolvedHtml();
-        var md = htmlToMarkdown(div).replace(/\n{3,}/g, "\n\n").trim() + "\n";
-        downloadFile(exportBaseName() + ".md", "text/markdown", md);
+        return htmlToMarkdown(div).replace(/\n{3,}/g, "\n\n").trim() + "\n";
     }
+    function exportHTML() { downloadFile(exportBaseName() + ".html", "text/html", buildHTML()); }
+    function exportText() { downloadFile(exportBaseName() + ".txt", "text/plain", buildText()); }
+    function exportMarkdown() { downloadFile(exportBaseName() + ".md", "text/markdown", buildMarkdown()); }
 
     /* --- basic HTML -> Markdown conversion (hand-written, MVP scope) --- */
     function htmlToMarkdown(rootEl) {
@@ -2676,8 +2681,9 @@
             }
             loadBody(b);
             undo.reset(snapshot());
-            OfficeApp.markDirty();
-            OfficeApp.setStatus("Imported " + fn + " - use Save to store it as .doca");
+            // the framework kept us attached to the source file, so Save
+            // writes straight back to it in its own format
+            OfficeApp.setStatus("Opened " + fn);
         }, function (msg) {
             OfficeApp.hideBusy();
             OfficeApp.toast("Import failed: " + msg, "error");
@@ -2847,6 +2853,117 @@
     function exportDocx() { exportDocFile(".docx", "export", "Exporting Word file..."); }
     function exportOdt() { exportDocFile(".odt", "export-odf", "Exporting OpenDocument file..."); }
 
+    /* ================= saving back into a foreign format =================
+       A document opened from .docx / .odt / .html / .md / .txt goes on living
+       in that file: the framework keeps filepath/filename pointing at it and
+       Ctrl+S comes back here instead of forcing a Save As to .doca. These are
+       the same renderings the Export menu produces, reporting through the
+       framework's save callbacks rather than a toast of their own. */
+    function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }
+    function saveViaConverter(action, fp, done, fail) {
+        var b = currentBody();
+        // suggestions applied, comment anchors unwrapped
+        b.html = resolvedHtml();
+        // PDF core fonts have no emoji glyphs - rasterize them
+        if (action === "export-pdf") b.html = rasterizeEmojiForPdf(b.html);
+        inlineImagesForExport(b.html).then(function (inlined) {
+            b.html = inlined;
+            OfficePlatform.convertOut(CONVERT[action], fp, JSON.stringify(b),
+                function () { done(); }, fail);
+        }).catch(function (err) {
+            fail((err && err.message) ? err.message : "could not prepare the document");
+        });
+    }
+    function saveTextFile(build, fp, done, fail) {
+        var content;
+        try { content = build(); }
+        catch (e) { fail(e.message || "could not render the document"); return; }
+        OfficeApp.vfsSave(fp, content, done, fail);
+    }
+    /*
+        Comments and pending suggestions live only in the native container:
+        every other writer is fed resolvedHtml(), which unwraps the comment
+        anchors, keeps insertions and drops deletions. That is a change to the
+        text itself, so it is a veto rather than a quiet loss. Formatting a
+        format merely renders differently is deliberately not listed - it
+        would fire on nearly every save.
+    */
+    function reviewUnsupported() {
+        var out = [];
+        var c = (comments || []).length;
+        if (c) out.push(plural(c, "comment", "comments"));
+        var s = allSuggestions().length;
+        if (s) {
+            out.push(plural(s, "pending suggestion", "pending suggestions") +
+                " - insertions would be accepted and deletions applied");
+        }
+        return out;
+    }
+    // .txt holds no pictures and no table structure at all
+    function textUnsupported() {
+        var out = reviewUnsupported();
+        var imgs = editor.querySelectorAll("img").length;
+        if (imgs) out.push(plural(imgs, "image", "images"));
+        var tables = editor.querySelectorAll("table").length;
+        if (tables) {
+            out.push(plural(tables, "table", "tables") +
+                " - only " + (tables === 1 ? "its" : "their") + " text would be kept");
+        }
+        return out;
+    }
+    /*
+        The formats File > Save as offers besides .doca, and the ones a
+        document opened from one of them is saved back into. needsConvert
+        marks the writers that go through the Office format converters and
+        needsBackend the ones that need a server outright (the real-text PDF
+        renderer); OfficeApp drops whichever the running host cannot do,
+        leaving the three text renderings - built right here in the browser -
+        always available. PDF is oneWay: it is a rendering, so writing one
+        leaves the document on its own file.
+    */
+    var SAVE_FORMATS = [
+        {
+            ext: ".docx", label: "Word document (.docx)", icon: "file word outline",
+            needsConvert: true, noAutosave: true,
+            unsupported: reviewUnsupported,
+            save: function (fp, fn, done, fail) { saveViaConverter("export", fp, done, fail); }
+        },
+        {
+            ext: ".odt", label: "OpenDocument text (.odt)", icon: "file alternate outline",
+            needsConvert: true, noAutosave: true,
+            unsupported: reviewUnsupported,
+            save: function (fp, fn, done, fail) { saveViaConverter("export-odf", fp, done, fail); }
+        },
+        {
+            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); }
+        },
+        {
+            ext: ".html", label: "Web page (.html)", icon: "file code outline",
+            unsupported: reviewUnsupported,
+            save: function (fp, fn, done, fail) { saveTextFile(buildHTML, fp, done, fail); }
+        },
+        {
+            // the same writer under the other extension a web page may carry:
+            // save-back only, so Save as does not offer it twice
+            ext: ".htm", label: "Web page (.htm)", icon: "file code outline",
+            hidden: true,
+            unsupported: reviewUnsupported,
+            save: function (fp, fn, done, fail) { saveTextFile(buildHTML, fp, done, fail); }
+        },
+        {
+            ext: ".md", label: "Markdown (.md)", icon: "file alternate outline",
+            unsupported: reviewUnsupported,
+            save: function (fp, fn, done, fail) { saveTextFile(buildMarkdown, fp, done, fail); }
+        },
+        {
+            ext: ".txt", label: "Plain text (.txt)", icon: "file outline",
+            unsupported: textUnsupported,
+            save: function (fp, fn, done, fail) { saveTextFile(buildText, fp, done, fail); }
+        }
+    ];
+
     /* ================= floating selection format bar ================= */
     /* PowerPoint-style mini toolbar (shared OfficeTextEditBar) floating
        above the current text selection inside the editor. */
@@ -3265,6 +3382,7 @@
                 ".docx": importDocx,
                 ".odt": importOdt
             },
+            saveFormats: SAVE_FORMATS,
 
             onUndo: doUndo,
             onRedo: doRedo,

+ 67 - 2
src/web/Office/slides/slides.js

@@ -2809,8 +2809,9 @@ var SlidesApp = (function () {
             editingId = null;
             renderAll();
             undo.init(snap());
-            OfficeApp.markDirty();
-            OfficeApp.setStatus("Imported " + fn + " - use Save to store it as .ppta");
+            // the framework kept us attached to the source file, so Save
+            // writes straight back to it in its own format
+            OfficeApp.setStatus("Opened " + fn);
         }, function (msg) {
             OfficeApp.hideBusy();
             OfficeApp.toast("Import failed: " + msg, "error");
@@ -2978,6 +2979,69 @@ var SlidesApp = (function () {
     // captured poster frame (or a generic placeholder)
     function exportPdf() { exportSlidesFile(".pdf", "export-pdf", "Exporting PDF..."); }
 
+    /* ================= saving back into a foreign format =================
+       A deck opened from .pptx / .odp goes on living in that file: the
+       framework keeps filepath/filename pointing at it and Ctrl+S comes back
+       here instead of forcing a Save As to .ppta. These are the same
+       converters the Export menu uses, reporting through the framework's
+       save callbacks rather than a toast of their own. */
+    function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }
+    function saveViaConverter(action, fp, done, fail) {
+        endEdit(true);
+        prepareBodyForPptx().then(function (prepared) {
+            OfficePlatform.convertOut(CONVERT[action], fp, JSON.stringify(prepared),
+                function (res) {
+                    // .pptx keeps video and audio beside the file rather than
+                    // embedding them - say where they went
+                    if (res && res.mediaZip) {
+                        OfficeApp.toast("Video / audio files saved to " + res.mediaZip);
+                    }
+                    done();
+                }, fail);
+        }).catch(function (err) {
+            fail((err && err.message) ? err.message : "could not prepare the presentation");
+        });
+    }
+    /* .odp: the OpenDocument presentation writer emits text, images, charts,
+       shapes, lines and tables (mod/office/odp_writer.go) - a video or audio
+       object would simply vanish, so the save is refused instead. */
+    function odpUnsupported() {
+        var n = 0;
+        ((body && body.slides) || []).forEach(function (s) {
+            (s.objects || []).forEach(function (o) {
+                if (o.type === "video" || o.type === "audio") n++;
+            });
+        });
+        return n ? [plural(n, "video / audio object", "video / audio objects") +
+            " - the OpenDocument presentation writer cannot store them"] : [];
+    }
+    /*
+        The formats File > Save as offers besides .ppta, and the ones a deck
+        opened from .pptx / .odp is saved back into. needsConvert marks the
+        writers that go through the Office format converters and needsBackend
+        the ones that need a server outright (the real-text PDF renderer);
+        OfficeApp drops whichever the running host cannot do. PDF is oneWay -
+        it is a rendering, so writing one leaves the deck on its own file.
+    */
+    var SAVE_FORMATS = [
+        {
+            ext: ".pptx", label: "PowerPoint presentation (.pptx)", icon: "file powerpoint outline",
+            needsConvert: true, noAutosave: true,
+            save: function (fp, fn, done, fail) { saveViaConverter("export", fp, done, fail); }
+        },
+        {
+            ext: ".odp", label: "OpenDocument presentation (.odp)", icon: "file alternate outline",
+            needsConvert: true, noAutosave: true,
+            unsupported: odpUnsupported,
+            save: function (fp, fn, done, fail) { saveViaConverter("export-odf", fp, done, fail); }
+        },
+        {
+            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); }
+        }
+    ];
+
     /* ================= menus ================= */
     function insertMenuItems() {
         return [
@@ -3312,6 +3376,7 @@ var SlidesApp = (function () {
                 ".pptx": function (fp, fn) { importPptx(fp, fn); },
                 ".odp": importOdp
             },
+            saveFormats: SAVE_FORMATS,
             /*
                 .pptx / .odp need the Office converters - the AGI backend in
                 ArozOS, the WebAssembly module in the web edition. The