Переглянути джерело

Improve Office startup and PDF pagination

Adds a new Office startup splash system and wires it into Docs/OfficeApp so document loading reports progress, avoids covering dialogs/errors, and opens Docs windows at splash size before growing to working size. Docs now paginates multi-column layouts via layout-only column sections instead of dotted page guides, including column overflow splitting behavior used by normal pagination and PDF export.

Refactors PDF execution paths so Docs and Slides both use OfficePdfDraw.run with worker fallback logic centralized in common/pdfdraw.js. Slides PDF export now records pdf-lib drawing calls and replays/assembles in the worker, reducing main-thread stalls on large decks, with improved staged progress reporting.
Toby Chui 2 тижнів тому
батько
коміт
f378a0e24b

+ 42 - 11
src/web/Office/README.md

@@ -223,8 +223,7 @@ model allows) in its `.docx`, because there is only one layout:
 - The paper is **one `.doc-sheet` per page** in `#pageSheets`, behind the
   transparent `#page`. The gap between two sheets is empty space, not a
   band painted over one long sheet, and since nothing in the flow sits
-  there, nothing can show through. (A multi-column document is not
-  paginated and keeps a single sheet.) The status bar's "Page N of M"
+  there, nothing can show through. The status bar's "Page N of M"
   follows the caret, or the middle of the view after a scroll.
 - [`docs/docs_pdf.js`](docs/docs_pdf.js) (`DocsPdf`) reads each sheet of that
   DOM: text runs at the browser's baselines, fills and borders (a collapsed
@@ -244,9 +243,22 @@ model allows) in its `.docx`, because there is only one layout:
   document can be edited meanwhile without changing the file that comes
   out. Font resolution against the shipped Noto faces, text runs and the
   raster fallback live in [`common/pdfcore.js`](common/pdfcore.js), also
-  used by Slides (whose exporter still assembles in the page). `docs/backend/docx.agi`'s `export-pdf` and
+  used by Slides. `docs/backend/docx.agi`'s `export-pdf` and
   `mod/office/pdf_doc.go` remain for AGI callers (`office.documentToPdf`),
   but the editor no longer uses them.
+- **Columns** (`page.columns` > 1) paginate the same way. Before each
+  layout, every run of text between two full-width blocks (`.col-span-all`,
+  a page break) is wrapped in a layout-only `div.doc-colsec` that carries
+  the CSS columns (`--doc-cols`, `--doc-colgap` on `#editor`). A section
+  that fits its page balances; one that runs past the page bottom is given
+  the height that is left and filled column by column
+  (`.doc-colsec-fill`), and `splitColumns` finds the first content that
+  spilled into a column past the last one (`columnOverflow`: a paragraph at
+  that character, a table at that row) and cuts there - so the copy of the
+  section carries the rest to the next page like any other split.
+  Sections are unwrapped with the spacers on everything saved. A columned
+  document is always laid out whole (no incremental relayout), which is
+  fine for the short papers the layout is for.
 - The DOCX reader/writer (next section) map that same model to
   WordprocessingML and back.
 
@@ -262,6 +274,18 @@ 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.
 
+It draws each slide onto a **recording**, not a PDF:
+`OfficePdfDraw.recorder()` ([`common/pdfdraw.js`](common/pdfdraw.js))
+hands it stand-ins for pdf-lib's page, whose calls (`drawText`,
+`drawImage`, `pushOperators`, ...) are written down as data - operator
+arguments as the text they put in the content stream, fonts by the file
+they come from (the fonts are still parsed in the page, for their glyph
+widths), pictures by source. `OfficePdfDraw.run()` replays the recording
+in the same Web Worker Docs uses, where the pictures are embedded and
+deflated, the fonts subset and the file written. On a 30-slide deck of
+large pictures that is the difference between an 11s freeze and a longest
+stall of about a tenth of a second; the drawing code itself did not change.
+
 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
@@ -611,6 +635,18 @@ payload to `user:/.appdata/Office/tmp/` through the system upload endpoint
 low-memory boards) and passes `dataFile` to the backend script, which reads
 and deletes it. Raising the server-side form limit is *not* an option here.
 
+### Docs: start-up splash
+
+Opening a large document takes a moment (fetching and unpacking it, a server
+or WebAssembly conversion for `.docx`, laying out every page), so Docs starts
+behind a splash that says what is happening — [`common/splash.js`](common/splash.js),
+contract in `CONTRACT.md`. In a desktop float window it is the classic office
+splash (the window opens small, `InitFWSize` 400x240, and grows to 1080x700
+around its centre once the document is on screen); in a browser tab it is a
+white page with the Docs icon and the status under it. `docs/index.html` loads
+its stylesheets and scripts in `<body>`, after the splash, so the splash paints
+while they arrive.
+
 ### Header / footer
 
 The header and footer are one editable pair per **simulated** page: the
@@ -785,8 +821,6 @@ the path that honours every mode exactly.
       margins, cell padding, list indent) must be mirrored by the
       constants at the top of `pdf_doc.go`, or the two page counts drift
       apart.
-    - Multi-column page layout (`page.columns`) is **not** implemented in
-      the PDF exporter — those documents export as a single column.
   - Embedding a Unicode font was deliberately rejected (megabytes on the
     binary); CJK text transliterates/degrades. That's the top candidate
     if someone asks for CJK PDF export.
@@ -873,12 +907,9 @@ sh ../scripts/check-conventions.sh --diff origin/master
 - 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).
-- Docs: section breaks (one page setup per document), and **pagination of
-  multi-column documents** — `page.columns` renders as CSS columns with
-  dotted page guides only, so text runs across the sheet boundaries in the
-  page view and in the browser PDF (which draws that view); the `.docx`
-  export writes real Word columns. Paginating columns means giving
-  `DocsLayout.paginate` a column-balancing pass.
+- Docs: section breaks (one page setup per document; a document cannot mix
+  column counts), and keep-with-next / widow control inside columns (a
+  column cut goes exactly where the text reaches the last column).
 - Sheets PDF: merged-cell rendering in the print model.
 - Slides: shape text with per-run styling in pptx (currently
   object-level bold/italic/color only).

+ 24 - 4
src/web/Office/common/CONTRACT.md

@@ -719,10 +719,13 @@ downstream has to know about them.
   objects (`slides/slides_pdf.js`, `docs/docs_pdf.js`) on the shared
   `common/pdfcore.js` (`OfficePdfCore`: font resolution against the shipped
   faces, text runs, clip paths, raster fallback) — load it after pdf-lib.
-  Docs hands the actual PDF assembly to a Web Worker: `common/pdfworker.js`
-  (which `importScripts` pdf-lib, fontkit and `common/pdfdraw.js`) turns a
-  plain-data display list into the file; `pdfdraw.js` is also loaded in the
-  page as the fallback when no worker can start.
+  Docs and Slides hand the actual PDF assembly to a Web Worker:
+  `common/pdfworker.js` (which `importScripts` pdf-lib, fontkit and
+  `common/pdfdraw.js`) turns a plain-data job into the file - Docs writes a
+  display list, Slides a recording of its pdf-lib calls
+  (`OfficePdfDraw.recorder()`). Load `pdfdraw.js` in the page too: it
+  starts the worker (`OfficePdfDraw.run`) and is the fallback when none
+  can start.
 - `fontkit.umd.min.js` — `@pdf-lib/fontkit`, which is what lets `pdf-lib`
   embed a font of our own. **Loaded on demand, not from the page**: it is
   the largest script here and only an export needs it (`loadFontkit` in
@@ -734,6 +737,23 @@ downstream has to know about them.
   SVG `<foreignObject>` so the browser lays out its own content, and only
   falls back here if that fails outright.
 
+## Start-up splash (common/splash.js)
+
+An app can show a splash while it starts and opens its document (Docs does):
+`<script src="../common/splash.js" data-app="Docs" data-icon="../img/docs.svg"
+data-size="1080x700"></script>` as the **first element of `<body>`**, with the
+app's stylesheets and scripts after it rather than in `<head>` (they would hold
+the first paint back). Inside a web desktop float window it is a coloured card
+in a small window that grows to `data-size` about its own centre when ready
+(register the app with an `InitFWSize` of 400x240 so the window opens at that
+size); in a full tab it is a plain page with the icon and a status line. It
+styles itself. `OfficeApp` drives it: `setStatus` / `showBusy` text becomes its
+status line, and it goes away when the opened document is on screen, a dialog
+opens, or an error is reported. An app with an asynchronous importer calls
+`OfficeApp.documentLoaded()` when the document is in, and
+`OfficeApp.splashStep(msg, fn)` to show a step before synchronous work (it runs
+`fn` straight away when there is no splash).
+
 ## Testing without a full ArozOS server
 
 `python -m http.server 8123 --directory src/web` then open

+ 88 - 17
src/web/Office/common/office.js

@@ -277,7 +277,61 @@ var OfficeApp = (function () {
         // the document's identity only ever changes alongside its title
         updateForeignBanner();
     }
+    /* ---------- start-up splash (common/splash.js, when the app has one) ----------
+       While it shows, status messages and busy messages are what it says;
+       it goes away when the document is on screen (documentLoaded), when
+       something needs the person (a dialog), or when something failed. */
+    function splashActive() {
+        return !!(window.OfficeSplash && OfficeSplash.active());
+    }
+    function splashDone() {
+        if (!splashActive()) return;
+        OfficeSplash.done();
+        showFromTop();
+    }
+    /* The document comes out from behind the splash at its beginning. The
+       window grows and the layout settles for a moment after that (a float
+       window resizing, pictures and fonts arriving), and any of it can
+       leave the view scrolled part-way down the first page - so the top is
+       asked for again until it has settled, unless the person has started
+       scrolling themselves. */
+    function showFromTop() {
+        var $ws = $(".of-workspace");
+        if (!$ws.length) return;
+        var touched = false;
+        var mark = function () { touched = true; };
+        $ws.one("wheel touchstart keydown mousedown", mark);
+        var top = function () {
+            if (touched) return;
+            $ws.scrollTop(0).scrollLeft(0);
+        };
+        top();
+        requestAnimationFrame(top);
+        [120, 350, 800].forEach(function (ms) { setTimeout(top, ms); });
+        setTimeout(function () { $ws.off("wheel touchstart keydown mousedown", mark); }, 900);
+    }
+    // the document an app was asked to open is on screen
+    function documentLoaded() {
+        splashDone();
+    }
+    // say msg on the splash and let it paint before fn does synchronous work
+    // (laying out a long document); without a splash fn just runs
+    function splashStep(msg, fn) {
+        if (!splashActive()) { fn(); return; }
+        OfficeSplash.status(msg);
+        // a frame to paint it in - or a moment, where frames do not come (a
+        // tab or window in the background)
+        var ran = false;
+        var go = function () { if (!ran) { ran = true; fn(); } };
+        requestAnimationFrame(function () { setTimeout(go, 0); });
+        setTimeout(go, 150);
+    }
+
     function setStatus(msg, type, timeout) {
+        if (splashActive()) {
+            if (type === "error") splashDone();
+            else OfficeSplash.status(msg);
+        }
         var $m = $(".of-status-msg");
         $m.text(msg || "").removeClass("error");
         if (type === "error") $m.addClass("error");
@@ -416,6 +470,7 @@ var OfficeApp = (function () {
             markClean();
             setStatus("New " + cfg.fileTypeName.toLowerCase() +
                 " from the " + pretty + " template");
+            documentLoaded();
             return;
         }
         filepath = fp; filename = fn;
@@ -423,6 +478,7 @@ var OfficeApp = (function () {
         markClean();
         addRecent(fp, fn);
         setStatus("Opened " + fn);
+        documentLoaded();
         checkDraft();
     }
     function loadNativeText(text, fp, fn, opts) {
@@ -460,6 +516,7 @@ var OfficeApp = (function () {
         dirty = false; updateTitle();
         if (filepath) addRecent(filepath, filename);
         setStatus(importedStatus(fn));
+        documentLoaded();
     }
     function openPath(fp, fn, opts) {
         fn = fn || basename(fp);
@@ -481,30 +538,34 @@ var OfficeApp = (function () {
         // side in ArozOS, by OfficeContainer in the standalone build
         if (cfg.packed && extOf(fn) === cfg.extension) {
             OfficePlatform.containerLoad(fp, function (envelope) {
-                var env = envelope;
-                if (typeof env === "string") {
-                    try { env = JSON.parse(env); } catch (e) { env = null; }
-                }
-                try {
-                    loadNativeEnvelope(env, fp, fn, opts);
-                } catch (err) {
-                    setStatus("Cannot open " + fn + ": " + err.message, "error");
-                }
+                splashStep("Preparing " + fn + "...", function () {
+                    var env = envelope;
+                    if (typeof env === "string") {
+                        try { env = JSON.parse(env); } catch (e) { env = null; }
+                    }
+                    try {
+                        loadNativeEnvelope(env, fp, fn, opts);
+                    } catch (err) {
+                        setStatus("Cannot open " + fn + ": " + err.message, "error");
+                    }
+                });
             }, function (msg) {
                 setStatus("Failed to open " + fn + ": " + (msg || "unknown error"), "error");
             });
             return;
         }
         vfsLoad(fp, function (text) {
-            try {
-                if (extOf(fn) === cfg.extension) {
-                    loadNativeText(text, fp, fn, opts);
-                } else {
-                    loadImportText(text, fp, fn);
+            splashStep("Preparing " + fn + "...", function () {
+                try {
+                    if (extOf(fn) === cfg.extension) {
+                        loadNativeText(text, fp, fn, opts);
+                    } else {
+                        loadImportText(text, fp, fn);
+                    }
+                } catch (err) {
+                    setStatus("Cannot open " + fn + ": " + err.message, "error");
                 }
-            } catch (err) {
-                setStatus("Cannot open " + fn + ": " + err.message, "error");
-            }
+            });
         }, function () {
             setStatus("Failed to load " + fn, "error");
         });
@@ -1227,6 +1288,7 @@ var OfficeApp = (function () {
 
     /* ---------- dialogs / toasts / context menu ---------- */
     function dialog(opt) {
+        splashDone();   // a question for the person: nothing may cover it
         var $ov = $('<div class="of-dialog-overlay"></div>');
         var $dl = $('<div class="of-dialog"></div>');
         if (opt.wide) $dl.addClass("wide");
@@ -1292,6 +1354,7 @@ var OfficeApp = (function () {
         return d;
     }
     function toast(msg, type, ms) {
+        if (type === "error") splashDone();
         var $h = $(".of-toast-holder");
         if (!$h.length) { $h = $('<div class="of-toast-holder"></div>'); $("body").append($h); }
         var $t = $('<div class="of-toast"></div>').text(msg);
@@ -1313,6 +1376,11 @@ var OfficeApp = (function () {
     }
     function showBusy(msg) {
         hideBusy();
+        // the splash already covers the app and says what is happening
+        if (splashActive()) {
+            OfficeSplash.status(msg || "Working...");
+            return;
+        }
         var $o = $('<div class="of-busy-overlay"><div class="of-spinner"></div><div class="of-busy-msg"></div></div>');
         $o.find(".of-busy-msg").text(msg || "Working...");
         $("body").append($o);
@@ -1460,6 +1528,7 @@ var OfficeApp = (function () {
         } else {
             cfg.create();
             markClean();
+            splashDone();
             if (cfg.packed) {
                 trySessionRestore();   // falls back to checkDraft() itself
             } else {
@@ -1594,6 +1663,8 @@ var OfficeApp = (function () {
         showContextMenu: showContextMenu,
         showBusy: showBusy,
         hideBusy: hideBusy,
+        documentLoaded: documentLoaded,
+        splashStep: splashStep,
         showProgress: showProgress,
         closeAllMenus: closeAllMenus,
         updateMenus: updateMenus,

+ 18 - 3
src/web/Office/common/pdfcore.js

@@ -153,7 +153,11 @@ var OfficePdfCore = (function () {
         function stdFont(family, bold, italic) {
             var names = STD_VARIANTS[family] || STD_VARIANTS.Helvetica;
             var key = names[(bold ? 1 : 0) + (italic ? 2 : 0)];
-            if (!std[key]) std[key] = pdfDoc.embedStandardFont(PDFLib.StandardFonts[key]);
+            if (!std[key]) {
+                std[key] = pdfDoc.embedStandardFont(PDFLib.StandardFonts[key]);
+                // what a recorded drawing names the font by (pdfdraw.js)
+                std[key].__ref = "k:" + key;
+            }
             return std[key];
         }
 
@@ -180,7 +184,10 @@ var OfficePdfCore = (function () {
             var rec = faces[face.url];
             if (!rec || rec.font || rec.embedding) return;
             rec.embedding = pdfDoc.embedFont(rec.bytes, { subset: true })
-                .then(function (font) { rec.font = font; });
+                .then(function (font) {
+                    font.__ref = "f:" + absoluteUrl(face.url);
+                    rec.font = font;
+                });
             wanted.push(rec.embedding);
         }
 
@@ -215,6 +222,10 @@ var OfficePdfCore = (function () {
         };
     }
 
+    function absoluteUrl(u) {
+        try { return new URL(u, document.baseURI).href; } catch (e) { return u; }
+    }
+
     /* resolveChar walks a font stack the way the browser does and says what
        the PDF can put this one character in:
 
@@ -426,7 +437,11 @@ var OfficePdfCore = (function () {
     // 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]) {
+        if (!this.gsCache[key] && this.p.recordAlpha) {
+            // a recording page (pdfdraw.js) makes the state where it is replayed
+            this.p.recordAlpha(key, a);
+            this.gsCache[key] = true;
+        } else if (!this.gsCache[key]) {
             var ref = this.doc.context.register(this.doc.context.obj({
                 Type: "ExtGState", ca: a, CA: a
             }));

+ 213 - 4
src/web/Office/common/pdfdraw.js

@@ -29,8 +29,20 @@
         ["poly", [x, y, ...], rgb, strokeW]
         ["frame", x, y, w, h, rgb, strokeW]
 
+    A page may instead be a recording, { size: [w, h] (pt), calls: [...] },
+    made by recorder() below: the pdf-lib page calls an exporter made (Slides
+    draws with pdf-lib directly), written down as data and made again here.
+    A font is named "k:<standard font>" or "f:<url of a shipped face>", a
+    picture by its id in job.images.
+
     Usage:
         OfficePdfDraw.render(job, { fontkit, onProgress }) -> Promise<Uint8Array>
+        OfficePdfDraw.run(job, { onProgress, loadFontkit })  (in the page:
+            renders in a worker, or here when no worker can start)
+        var rec = OfficePdfDraw.recorder();   (in the page)
+            rec.addPage([w, h]) -> a stand-in for a pdf-lib PDFPage
+            rec.embed(src)      -> Promise<picture handle> for drawImage
+            rec.job(title)      -> the job
 */
 
 var OfficePdfDraw = (function () {
@@ -71,7 +83,9 @@ var OfficePdfDraw = (function () {
         function loadFont(ref) {
             if (fontCache[ref]) return fontCache[ref];
             var p;
-            if (ref.indexOf("s:") === 0) {
+            if (ref.indexOf("k:") === 0) {
+                p = Promise.resolve(pdfDoc.embedStandardFont(PDFLib.StandardFonts[ref.substring(2)]));
+            } else if (ref.indexOf("s:") === 0) {
                 var parts = ref.split(":");
                 var names = STD_VARIANTS[parts[1]] || STD_VARIANTS.Helvetica;
                 var key = names[(parts[2] === "1" ? 1 : 0) + (parts[3] === "1" ? 2 : 0)];
@@ -113,8 +127,12 @@ var OfficePdfDraw = (function () {
                 var kind = sniff(bytes);
                 if (kind === "jpg") return pdfDoc.embedJpg(bytes);
                 if (kind === "png") return pdfDoc.embedPng(bytes);
-                return null;
-            }).catch(function () { return null; });
+                // a recording names what it could not check: try it as a PNG
+                return spec.guess ? pdfDoc.embedPng(bytes) : null;
+            }).catch(function () { return null; }).then(function (img) {
+                imageReady[id] = img;
+                return img;
+            });
             return imageCache[id];
         }
 
@@ -250,6 +268,65 @@ var OfficePdfDraw = (function () {
             }
         }
 
+        /* ---- recorded pages ---- */
+
+        // what a recorded argument refers to: fonts and pictures by name
+        function refsIn(v, fonts, images) {
+            if (!v || typeof v !== "object") return;
+            if (v.__font) { fonts.push(v.__font); return; }
+            if (v.__img) { images.push(v.__img); return; }
+            if (Array.isArray(v)) { v.forEach(function (x) { refsIn(x, fonts, images); }); return; }
+            for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) refsIn(v[k], fonts, images);
+        }
+        function preloadCalls(calls) {
+            var fonts = [], images = [];
+            calls.forEach(function (c) { refsIn(c, fonts, images); });
+            return Promise.all(fonts.map(loadFont).concat(images.map(loadImage)));
+        }
+        function decode(v) {
+            if (!v || typeof v !== "object") return v;
+            if (v.__font) return fontReady[v.__font];
+            if (v.__img) return imageReady[v.__img];
+            if (Array.isArray(v)) return v.map(decode);
+            var out = {};
+            for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) out[k] = decode(v[k]);
+            return out;
+        }
+        function replay(page, calls) {
+            var gs = {};
+            calls.forEach(function (c) {
+                switch (c[0]) {
+                    case "ops":
+                        // operator arguments were kept as the text they write
+                        page.pushOperators.apply(page, c[1].map(function (o) {
+                            return PDFLib.PDFOperator.of(o[0], o[1]);
+                        }));
+                        break;
+                    case "alpha":
+                        if (!gs[c[1]]) {
+                            var ref = pdfDoc.context.register(pdfDoc.context.obj({ Type: "ExtGState", ca: c[2], CA: c[2] }));
+                            page.node.setExtGState(PDFLib.PDFName.of(c[1]), ref);
+                            gs[c[1]] = true;
+                        }
+                        break;
+                    case "drawImage":
+                        var img = imageReady[c[1].__img];
+                        if (img) page.drawImage(img, decode(c[2]));
+                        break;
+                    case "drawText":
+                        var o = decode(c[2]);
+                        if (o.font) page.drawText(c[1], o);
+                        break;
+                    case "drawSvgPath":
+                        page.drawSvgPath(c[1], decode(c[2]));
+                        break;
+                    default:
+                        if (typeof page[c[0]] === "function") page[c[0]](decode(c[1]));
+                }
+            });
+        }
+        var imageReady = {};
+
         function tick() {
             return new Promise(function (res) { setTimeout(res, 0); });
         }
@@ -262,6 +339,16 @@ var OfficePdfDraw = (function () {
             doc.setProducer("ArozOS Office");
             var chain = Promise.resolve();
             job.pages.forEach(function (ops, i) {
+                if (!Array.isArray(ops)) {
+                    chain = chain.then(function () {
+                        return preloadCalls(ops.calls);
+                    }).then(function () {
+                        replay(pdfDoc.addPage(ops.size), ops.calls);
+                        if (opts.onProgress) opts.onProgress(i + 1, total, "page");
+                        return tick();
+                    });
+                    return;
+                }
                 chain = chain.then(function () {
                     return preload(ops);
                 }).then(function () {
@@ -282,5 +369,127 @@ var OfficePdfDraw = (function () {
         });
     }
 
-    return { render: render };
+    /* ---------------- recording (in the page) ---------------- */
+
+    function encodeArg(v) {
+        if (v === undefined || v === null || typeof v !== "object") return v;
+        if (v.__ref) return { __font: v.__ref };
+        if (v.__img) return { __img: v.__img };
+        if (Array.isArray(v)) return v.map(encodeArg);
+        var out = {};
+        for (var k in v) {
+            if (!Object.prototype.hasOwnProperty.call(v, k) || v[k] === undefined) continue;
+            out[k] = encodeArg(v[k]);
+        }
+        return out;
+    }
+
+    function recorder() {
+        var pages = [];
+        var images = {};
+        var bySrc = {};
+        var seq = 0;
+        function RecPage(calls) {
+            this.calls = calls;
+            this.node = {};
+        }
+        ["drawRectangle", "drawLine", "drawCircle", "drawEllipse", "drawSquare"].forEach(function (m) {
+            RecPage.prototype[m] = function (o) { this.calls.push([m, encodeArg(o)]); };
+        });
+        RecPage.prototype.drawText = function (text, o) { this.calls.push(["drawText", String(text), encodeArg(o)]); };
+        RecPage.prototype.drawImage = function (img, o) {
+            if (img && img.__img) this.calls.push(["drawImage", { __img: img.__img }, encodeArg(o)]);
+        };
+        RecPage.prototype.drawSvgPath = function (d, o) { this.calls.push(["drawSvgPath", String(d), encodeArg(o)]); };
+        RecPage.prototype.pushOperators = function () {
+            var list = [];
+            for (var i = 0; i < arguments.length; i++) {
+                var op = arguments[i];
+                // PDFOperator writes each argument with String(); keeping
+                // that text keeps the content stream byte for byte
+                list.push([op.name, (op.args || []).map(function (a) { return String(a); })]);
+            }
+            this.calls.push(["ops", list]);
+        };
+        RecPage.prototype.recordAlpha = function (key, a) { this.calls.push(["alpha", key, a]); };
+
+        return {
+            addPage: function (size) {
+                var calls = [];
+                pages.push({ size: size, calls: calls });
+                return new RecPage(calls);
+            },
+            // a picture is named, not embedded: the worker embeds it once
+            embed: function (src) {
+                if (!src) return Promise.resolve(null);
+                if (bySrc[src]) return Promise.resolve(bySrc[src]);
+                var id = "p" + (seq++);
+                var abs = src;
+                if (src.indexOf("data:") !== 0) {
+                    try { abs = new URL(src, document.baseURI).href; } catch (e) { abs = src; }
+                }
+                images[id] = { src: abs, guess: true };
+                bySrc[src] = { __img: id };
+                return Promise.resolve(bySrc[src]);
+            },
+            job: function (title) {
+                return { title: title || "", images: images, pages: pages };
+            }
+        };
+    }
+
+    /* ---------------- running a job (in the page) ---------------- */
+
+    var WORKER_URL = "../common/pdfworker.js";
+
+    /* The worker is where the time goes (pictures, fonts, compression).
+       When one cannot be started at all - an old browser, a page opened
+       from disk - the same code runs in the page instead, just less
+       politely. */
+    function run(job, o) {
+        o = o || {};
+        return new Promise(function (resolve, reject) {
+            var worker;
+            var started = false;
+            function inPage() {
+                var fk = o.loadFontkit ? o.loadFontkit() : Promise.resolve(self.fontkit);
+                fk.then(function (kit) {
+                    return render(job, { fontkit: kit, onProgress: o.onProgress });
+                }).then(resolve, reject);
+            }
+            try {
+                worker = new Worker(WORKER_URL);
+            } catch (e) {
+                inPage();
+                return;
+            }
+            worker.onmessage = function (e) {
+                var m = e.data || {};
+                started = true;
+                if (m.type === "progress") {
+                    if (o.onProgress) o.onProgress(m.done, m.total, m.stage);
+                } else if (m.type === "done") {
+                    worker.terminate();
+                    resolve(m.bytes);
+                } else if (m.type === "error") {
+                    worker.terminate();
+                    reject(new Error(m.message || "the PDF could not be written"));
+                }
+            };
+            worker.onerror = function (e) {
+                if (e && e.preventDefault) e.preventDefault();
+                worker.terminate();
+                if (!started) inPage();
+                else reject(new Error((e && e.message) || "the PDF worker failed"));
+            };
+            var transfer = [];
+            Object.keys(job.images || {}).forEach(function (id) {
+                var b = job.images[id].bytes;
+                if (b && b.buffer && transfer.indexOf(b.buffer) < 0) transfer.push(b.buffer);
+            });
+            worker.postMessage({ job: job }, transfer);
+        });
+    }
+
+    return { render: render, recorder: recorder, run: run };
 })();

+ 349 - 0
src/web/Office/common/splash.js

@@ -0,0 +1,349 @@
+/*
+    ArozOS Office Suite - start-up splash
+    =====================================
+    What an app shows while it starts and opens its document, so the wait
+    has a face and says what is happening ("Opening report.docx...",
+    "Laying out the pages...").
+
+    Two looks, chosen by where the app runs:
+
+      window    inside a web desktop float window: the window starts small,
+                a coloured card with the app name, moving dots and the
+                status line in the corner - and grows to its working size,
+                about its own centre, once the document is ready
+      page      a full browser tab (or the mobile desktop, whose windows
+                fill the screen): a plain page with the app icon in the
+                middle and the status under it
+
+    Include it as the FIRST element of <body>, so it covers the page before
+    anything else has drawn:
+
+        <script src="../common/splash.js" data-app="Docs"
+                data-icon="../img/docs.svg" data-size="1080x700"></script>
+
+    data-size is the window's working size in window mode. The splash brings
+    its own styles: it paints before any stylesheet of the app has arrived,
+    so an app should load its stylesheets and scripts after this tag (in
+    <body>) rather than in <head>, where they would hold the first paint
+    back. The framework (office.js) feeds it status text and
+    calls done() when the document is on screen, when a dialog needs the
+    person, or when something failed; a start that never finishes is let go
+    after a while so the app is never locked behind it.
+
+        OfficeSplash.status("Opening report.docx...")
+        OfficeSplash.done()
+        OfficeSplash.active()   -> true while it is showing
+*/
+
+var OfficeSplash = (function () {
+    "use strict";
+
+    var SPLASH_W = 400, SPLASH_H = 240;   // the window while it starts
+    var GIVE_UP_MS = 60000;
+
+    var script = document.currentScript;
+    var appName = (script && script.getAttribute("data-app")) || "Office";
+    var icon = (script && script.getAttribute("data-icon")) || "";
+    var sizeAttr = ((script && script.getAttribute("data-size")) || "1080x700").split("x");
+    var workW = parseInt(sizeAttr[0], 10) || 1080, workH = parseInt(sizeAttr[1], 10) || 700;
+
+    var root = null, statusEl = null, finished = false, giveUp = null;
+
+    // each app's colour for the window card
+    var APP_COLOURS = { docs: "#1f6fbf", sheets: "#1e8a3c", slides: "#cf6c16" };
+
+    var CSS = [
+        ".of-splash {",
+        "    position: fixed;",
+        "    inset: 0;",
+        "    z-index: 5000;",
+        "    box-sizing: border-box;",
+        "    font-family: \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif;",
+        "    user-select: none;",
+        "    -webkit-user-select: none;",
+        "    transition: opacity 0.22s ease;",
+        "}",
+        ".of-splash.of-splash-out { opacity: 0; pointer-events: none; }",
+        ".of-splash-mark { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; }",
+        ".of-splash-window {",
+        "    background: var(--of-splash-bg, #1f6fbf);",
+        "    color: #ffffff;",
+        "}",
+        ".of-splash-brand {",
+        "    position: absolute;",
+        "    left: 12px;",
+        "    top: 9px;",
+        "    display: flex;",
+        "    align-items: center;",
+        "    gap: 6px;",
+        "    font-size: 12px;",
+        "    opacity: 0.95;",
+        "}",
+        ".of-splash-mid {",
+        "    position: absolute;",
+        "    left: 0;",
+        "    right: 0;",
+        "    top: 50%;",
+        "    transform: translateY(-58%);",
+        "    text-align: center;",
+        "}",
+        ".of-splash-name {",
+        "    font-size: 46px;",
+        "    font-weight: 300;",
+        "    line-height: 1.1;",
+        "    letter-spacing: 0.5px;",
+        "}",
+        ".of-splash-window .of-splash-status {",
+        "    position: absolute;",
+        "    left: 12px;",
+        "    right: 12px;",
+        "    bottom: 9px;",
+        "    font-size: 12px;",
+        "    white-space: nowrap;",
+        "    overflow: hidden;",
+        "    text-overflow: ellipsis;",
+        "    opacity: 0.95;",
+        "}",
+        ".of-splash-dots {",
+        "    position: relative;",
+        "    width: 90px;",
+        "    height: 5px;",
+        "    margin: 12px auto 0;",
+        "}",
+        ".of-splash-dots span {",
+        "    position: absolute;",
+        "    top: 0;",
+        "    left: 0;",
+        "    width: 4px;",
+        "    height: 4px;",
+        "    border-radius: 50%;",
+        "    background: currentColor;",
+        "    opacity: 0;",
+        "    animation: of-splash-dot 2.4s infinite cubic-bezier(0.4, 0, 0.6, 1);",
+        "}",
+        ".of-splash-dots span:nth-child(2) { animation-delay: 0.14s; }",
+        ".of-splash-dots span:nth-child(3) { animation-delay: 0.28s; }",
+        ".of-splash-dots span:nth-child(4) { animation-delay: 0.42s; }",
+        ".of-splash-dots span:nth-child(5) { animation-delay: 0.56s; }",
+        "@keyframes of-splash-dot {",
+        "    0% { left: 0; opacity: 0; }",
+        "    15% { opacity: 1; }",
+        "    35% { left: 45%; }",
+        "    65% { left: 55%; }",
+        "    85% { opacity: 1; }",
+        "    100% { left: 100%; opacity: 0; }",
+        "}",
+        ".of-splash-page {",
+        "    background: #ffffff;",
+        "    color: #5f6368;",
+        "}",
+        ".of-splash-page.of-splash-dark { background: #1f2328; color: #b8bec6; }",
+        ".of-splash-center {",
+        "    position: absolute;",
+        "    left: 0;",
+        "    right: 0;",
+        "    top: 50%;",
+        "    transform: translateY(-60%);",
+        "    text-align: center;",
+        "}",
+        ".of-splash-icon {",
+        "    width: 88px;",
+        "    height: 88px;",
+        "    display: block;",
+        "    margin: 0 auto 22px;",
+        "}",
+        ".of-splash-page .of-splash-status {",
+        "    font-size: 13px;",
+        "    min-height: 1.4em;",
+        "    padding: 0 16px;",
+        "}",
+        ".of-splash-foot {",
+        "    position: absolute;",
+        "    left: 0;",
+        "    right: 0;",
+        "    bottom: 22px;",
+        "    display: flex;",
+        "    align-items: center;",
+        "    justify-content: center;",
+        "    gap: 7px;",
+        "    font-size: 13px;",
+        "}",
+        "@media (prefers-reduced-motion: reduce) {",
+        "    .of-splash-dots span { animation: none; opacity: 0.8; }",
+        "    .of-splash-dots span:nth-child(1) { left: 30%; }",
+        "    .of-splash-dots span:nth-child(2) { left: 40%; }",
+        "    .of-splash-dots span:nth-child(3) { left: 50%; }",
+        "    .of-splash-dots span:nth-child(4) { left: 60%; }",
+        "    .of-splash-dots span:nth-child(5) { left: 70%; }",
+        "    .of-splash { transition: none; }",
+        "}",
+        "@media print {",
+        "    .of-splash { display: none !important; }",
+        "}"
+    ].join("\n");
+
+    function injectStyle() {
+        var st = document.createElement("style");
+        st.setAttribute("data-of-splash", "");
+        var app = document.body ? document.body.getAttribute("data-officeapp") : "";
+        st.textContent = ".of-splash{--of-splash-bg:" + (APP_COLOURS[app] || "#1f6fbf") + ";}\n" + CSS;
+        (document.head || document.documentElement).appendChild(st);
+    }
+
+    // the web desktop's float window this page sits in, when it can be sized
+    // (the mobile desktop has float windows too, but they fill the screen)
+    function floatWindow() {
+        try {
+            var fe = window.frameElement;
+            if (!fe || typeof fe.closest !== "function") return null;
+            var fw = fe.closest(".floatWindow");
+            if (!fw || !/px$/.test(fw.style.width || "")) return null;
+            if (!window.parent || typeof window.parent.setFloatWindowSize !== "function") return null;
+            return fw;
+        } catch (e) {
+            return null;   // not same-origin: not a desktop window
+        }
+    }
+
+    /* The window is sized through ao_module.js once it has loaded; the
+       splash comes up before it, and then asks the desktop directly with
+       the same calls ao_module makes. */
+    function setWindowSize(fw, w, h) {
+        if (typeof ao_module_setWindowSize === "function") ao_module_setWindowSize(w, h);
+        else window.parent.setFloatWindowSize(fw.getAttribute("windowId"), w, h);
+    }
+    function setResizable(fw, on) {
+        if (typeof ao_module_setFixedWindowSize === "function") {
+            if (on) ao_module_setResizableWindowSize();
+            else ao_module_setFixedWindowSize();
+        } else if (typeof window.parent.setFloatWindowResizePolicy === "function") {
+            window.parent.setFloatWindowResizePolicy(fw.getAttribute("windowId"), !!on);
+        }
+    }
+
+    function el(tag, cls, text) {
+        var n = document.createElement(tag);
+        if (cls) n.className = cls;
+        if (text) n.textContent = text;
+        return n;
+    }
+    // the suite mark: four rounded squares
+    function mark(cls) {
+        var ns = "http://www.w3.org/2000/svg";
+        var svg = document.createElementNS(ns, "svg");
+        svg.setAttribute("viewBox", "0 0 24 24");
+        svg.setAttribute("class", cls);
+        svg.setAttribute("aria-hidden", "true");
+        [[3.5, 3.5], [13.5, 3.5], [3.5, 13.5], [13.5, 13.5]].forEach(function (p) {
+            var r = document.createElementNS(ns, "rect");
+            r.setAttribute("x", p[0]);
+            r.setAttribute("y", p[1]);
+            r.setAttribute("width", 7);
+            r.setAttribute("height", 7);
+            r.setAttribute("rx", 1.5);
+            svg.appendChild(r);
+        });
+        return svg;
+    }
+    function dots() {
+        var d = el("div", "of-splash-dots");
+        for (var i = 0; i < 5; i++) d.appendChild(el("span"));
+        return d;
+    }
+
+    function build() {
+        injectStyle();
+        var fw = floatWindow();
+        root = el("div", "of-splash of-noprint " + (fw ? "of-splash-window" : "of-splash-page"));
+        root.setAttribute("role", "status");
+        root.setAttribute("aria-live", "polite");
+        var dark = false;
+        try { dark = localStorage.getItem("office_theme") === "dark"; } catch (e) { }
+        if (dark) root.classList.add("of-splash-dark");
+
+        if (fw) {
+            var brand = el("div", "of-splash-brand");
+            brand.appendChild(mark("of-splash-mark"));
+            brand.appendChild(el("span", "", "ArozOS Office"));
+            root.appendChild(brand);
+            var mid = el("div", "of-splash-mid");
+            mid.appendChild(el("div", "of-splash-name", appName));
+            mid.appendChild(dots());
+            root.appendChild(mid);
+            statusEl = el("div", "of-splash-status", "Loading...");
+            root.appendChild(statusEl);
+            // start small, like a splash; grown back in done()
+            try {
+                setResizable(fw, false);
+                setWindowSize(fw, SPLASH_W, SPLASH_H);
+            } catch (e) { }
+        } else {
+            var center = el("div", "of-splash-center");
+            if (icon) {
+                var img = el("img", "of-splash-icon");
+                img.src = icon;
+                img.alt = "";
+                center.appendChild(img);
+            }
+            statusEl = el("div", "of-splash-status", "Loading " + appName + "...");
+            center.appendChild(statusEl);
+            root.appendChild(center);
+            var foot = el("div", "of-splash-foot");
+            foot.appendChild(mark("of-splash-mark"));
+            foot.appendChild(el("span", "", "ArozOS Office"));
+            root.appendChild(foot);
+        }
+        document.body.insertBefore(root, document.body.firstChild);
+        giveUp = setTimeout(done, GIVE_UP_MS);
+    }
+
+    function status(msg) {
+        if (finished || !statusEl || !msg) return;
+        statusEl.textContent = msg;
+    }
+
+    // grow the window to its working size around the splash's centre, kept
+    // on the desktop
+    function growWindow() {
+        var fw = floatWindow();
+        if (!fw) return;
+        // the person already made it bigger (maximized it) while it started
+        if (fw.offsetWidth > SPLASH_W + 40 || fw.offsetHeight > SPLASH_H + 40) {
+            setResizable(fw, true);
+            return;
+        }
+        try {
+            var pw = window.parent.innerWidth, ph = window.parent.innerHeight;
+            var w = Math.min(workW, pw), h = Math.min(workH, ph);
+            var left = parseFloat(fw.style.left) || 0, top = parseFloat(fw.style.top) || 0;
+            var cx = left + fw.offsetWidth / 2, cy = top + fw.offsetHeight / 2;
+            fw.style.left = Math.max(0, Math.min(pw - w, Math.round(cx - w / 2))) + "px";
+            fw.style.top = Math.max(0, Math.min(ph - h, Math.round(cy - h / 2))) + "px";
+            setWindowSize(fw, w, h);
+            setResizable(fw, true);
+        } catch (e) { }
+    }
+
+    function done() {
+        if (finished || !root) return;
+        finished = true;
+        clearTimeout(giveUp);
+        if (root.classList.contains("of-splash-window")) growWindow();
+        root.classList.add("of-splash-out");
+        var r = root;
+        setTimeout(function () {
+            if (r.parentNode) r.parentNode.removeChild(r);
+        }, 260);
+        // the layout changed size under everything that measured it
+        try { window.dispatchEvent(new Event("resize")); } catch (e) { }
+    }
+
+    function active() {
+        return !!root && !finished;
+    }
+
+    if (document.body) build();
+    else document.addEventListener("DOMContentLoaded", build);
+
+    return { status: status, done: done, active: active };
+})();

+ 12 - 29
src/web/Office/docs/docs.css

@@ -347,34 +347,16 @@ span.doc-tab[data-leader="hyphen"], span.doc-tab[data-leader="underscore"] {
 /* Multi-column layout: title/author blocks marked via
    Format > Page layout > Span all columns stretch across every column */
 #editor .col-span-all { column-span: all; }
-
-/* Page guides: dashed lines showing where printed pages break */
-#pageGuides {
-    position: absolute;
-    inset: 0;
-    pointer-events: none;
-    z-index: 5;
-}
-.doc-pageguide {
-    position: absolute;
-    left: 0;
-    right: 0;
-    border-top: 2px dashed #b7c8e4;
-}
-.doc-pageguide::after {
-    content: attr(data-label);
-    position: absolute;
-    right: 2px;
-    top: -9px;
-    font-size: 9px;
-    line-height: 16px;
-    padding: 0 6px;
-    color: #6a86b8;
-    background: #eef3fb;
-    border: 1px solid #b7c8e4;
-    border-radius: 8px;
-    font-family: "Segoe UI", Arial, sans-serif;
-}
+/* A run of text between full-width blocks in a multi-column document: the
+   paginator wraps it (layout only, never saved) and gives the part that
+   runs past a page bottom the height that is left, filled column by
+   column; everything else balances. */
+#editor .doc-colsec {
+    column-count: var(--doc-cols, 2);
+    column-gap: var(--doc-colgap, 8mm);
+    column-fill: balance;
+}
+#editor .doc-colsec.doc-colsec-fill { column-fill: auto; }
 
 /* ============ Page breaks (explicit + automatic pagination) ============
    An explicit break is a real block in the flow, stretched by the
@@ -430,7 +412,8 @@ span.doc-tab[data-leader="hyphen"], span.doc-tab[data-leader="underscore"] {
 /* Layout boxes: visualize which regions span all columns (orange) and
    which flow inside the columns (blue) - View > Layout boxes */
 #page { position: relative; }
-body.doc-show-boxes #editor > * {
+body.doc-show-boxes #editor > *:not(.doc-colsec):not(.doc-autobreak),
+body.doc-show-boxes #editor > .doc-colsec > * {
     outline: 1px dashed rgba(66, 133, 244, 0.55);
     outline-offset: 1px;
 }

+ 31 - 70
src/web/Office/docs/docs.js

@@ -59,7 +59,8 @@
         { v: "h3",    label: "Heading 3" },
         { v: "h4",    label: "Heading 4" }
     ];
-    var BLOCK_SEL = "p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,div";
+    // (a column section and a page spacer are layout, not blocks of the text)
+    var BLOCK_SEL = "p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,div:not(.doc-colsec):not(.doc-autobreak)";
 
     /* Special characters palette - every glyph is generated at runtime from
        code points (never a literal emoji in this source file). */
@@ -1533,14 +1534,17 @@
         pageEl.style.padding = m.top + "mm " + m.right + "mm " + m.bottom + "mm " + m.left + "mm";
         // multi-column text layout (2 = IEEE-paper style); blocks marked
         // with .col-span-all (title, authors) stretch across every column
+        // the columns themselves are the column sections the paginator
+        // wraps the text in (docs_layout.js); they read these
+        editor.style.columnCount = "";
+        editor.style.columnGap = "";
+        editor.style.columnFill = "";
         if (pageConf.columns > 1) {
-            editor.style.columnCount = pageConf.columns;
-            editor.style.columnGap = pageConf.colGap + "mm";
-            editor.style.columnFill = "balance";
+            editor.style.setProperty("--doc-cols", String(pageConf.columns));
+            editor.style.setProperty("--doc-colgap", pageConf.colGap + "mm");
         } else {
-            editor.style.columnCount = "";
-            editor.style.columnGap = "";
-            editor.style.columnFill = "";
+            editor.style.removeProperty("--doc-cols");
+            editor.style.removeProperty("--doc-colgap");
         }
         updatePrintStyle();
         updatePageGuides();
@@ -1591,7 +1595,6 @@
     var PAGE_GAP_PX = 24;        // visual gap between two sheets
     var HEADER_MIN_PX = 7;       // Google Docs never starts a header higher
     var lastPages = [];          // page records of the current layout
-    function pageGuidesOn() { return OfficeApp.getSetting("pageGuides", true); }
     // element top in layout px relative to #page (CSS zoom safe - unlike
     // getBoundingClientRect, offsetTop is not scaled)
     function offsetTopInPage(el) {
@@ -1838,8 +1841,7 @@
        there is nothing at all, which is what makes the pages really
        separate: the flow has no content there (a split puts the rest of a
        paragraph or a table row on the next sheet), so no border or shading
-       can show in the gap. A multi-column document is not paginated, and
-       keeps one long sheet. */
+       can show in the gap. */
     function renderSheets(pages, geo) {
         var holder = document.getElementById("pageSheets");
         if (!holder) {
@@ -1849,14 +1851,7 @@
             holder.setAttribute("aria-hidden", "true");
             pageEl.insertBefore(holder, pageEl.firstChild);
         }
-        var want = [];
-        var gapless = pages.length > 1 && pages[1].sheetTop - pages[0].sheetTop <= geo.sheetH + 0.5;
-        if (gapless) {
-            var last = pages[pages.length - 1];
-            want.push({ top: 0, h: last.sheetTop + geo.sheetH });
-        } else {
-            pages.forEach(function (pg) { want.push({ top: pg.sheetTop, h: geo.sheetH }); });
-        }
+        var want = pages.map(function (pg) { return { top: pg.sheetTop, h: geo.sheetH }; });
         while (holder.children.length > want.length) holder.removeChild(holder.lastChild);
         while (holder.children.length < want.length) {
             var sheet = document.createElement("div");
@@ -2002,14 +1997,6 @@
         }
     }
     function layoutPages() {
-        var holder = document.getElementById("pageGuides");
-        if (!holder) {
-            holder = document.createElement("div");
-            holder.id = "pageGuides";
-            holder.className = "of-noprint";
-            pageEl.appendChild(holder);
-        }
-        holder.innerHTML = "";
         var geo = pageGeometry();
         // park the bands first: a copy still sitting at the bottom of a
         // longer previous layout would stretch #page and conjure a phantom
@@ -2027,37 +2014,16 @@
         runLayoutPasses(editor);
         fnOrder = DocsLayout.numberFootnotes(editor);
 
-        var pages;
-        if (pageConf.columns > 1) {
-            // CSS columns reflow around anything inserted, so a columned
-            // document keeps dotted guides at each page height
-            pages = [];
-            var innerH = Math.max(60, geo.sheetH - geo.mTop - geo.mBot);
-            var total = Math.max(1, Math.ceil((editor.offsetHeight + 1) / innerH));
-            for (var p = 0; p < total; p++) {
-                pages.push({
-                    index: p, sheetTop: p * innerH, contentTop: geo.mTop + p * innerH,
-                    contentBottom: geo.mTop + (p + 1) * innerH, footnotes: [], footnoteTop: 0
-                });
-                if (p > 0 && pageGuidesOn()) {
-                    var g = document.createElement("div");
-                    g.className = "doc-pageguide";
-                    g.style.top = (geo.mTop + p * innerH) + "px";
-                    g.setAttribute("data-label", "Page " + (p + 1));
-                    holder.appendChild(g);
-                }
-            }
-        } else {
-            var refs = editor.querySelectorAll("sup.doc-fnref");
-            pages = DocsLayout.paginate({
-                editor: editor, pageEl: pageEl,
-                sheetH: geo.sheetH, gap: PAGE_GAP_PX, mTop: geo.mTop, mBot: geo.mBot,
-                fnRefs: refs,
-                measureFootnotes: refs.length ? function (ids) { return measureFootnotes(ids, geo); } : null,
-                fromY: incremental ? fromY : undefined,
-                prevPages: incremental ? lastPages : null
-            });
-        }
+        var refs = editor.querySelectorAll("sup.doc-fnref");
+        var pages = DocsLayout.paginate({
+            editor: editor, pageEl: pageEl,
+            sheetH: geo.sheetH, gap: PAGE_GAP_PX, mTop: geo.mTop, mBot: geo.mBot,
+            columns: pageConf.columns > 1 ? pageConf.columns : 1,
+            fnRefs: refs,
+            measureFootnotes: refs.length ? function (ids) { return measureFootnotes(ids, geo); } : null,
+            fromY: incremental ? fromY : undefined,
+            prevPages: incremental ? lastPages : null
+        });
         lastPages = pages;
         var last = pages[pages.length - 1];
         pageEl.style.minHeight = (last.sheetTop + geo.sheetH + pageEl.clientTop * 2) + "px";
@@ -3055,11 +3021,14 @@
                 OfficeApp.toast("Import failed: unexpected response", "error");
                 return;
             }
-            loadBody(b);
-            undo.reset(snapshot());
-            // the framework kept us attached to the source file, so Save
-            // writes straight back to it in its own format
-            OfficeApp.setStatus("Opened " + fn);
+            OfficeApp.splashStep("Laying out the pages...", function () {
+                loadBody(b);
+                undo.reset(snapshot());
+                // the framework kept us attached to the source file, so Save
+                // writes straight back to it in its own format
+                OfficeApp.setStatus("Opened " + fn);
+                OfficeApp.documentLoaded();
+            });
         }, function (msg) {
             OfficeApp.hideBusy();
             OfficeApp.toast("Import failed: " + msg, "error");
@@ -3872,14 +3841,6 @@
             ],
 
             viewMenuExtras: [
-                {
-                    label: "Page guides",
-                    checked: pageGuidesOn,
-                    action: function () {
-                        OfficeApp.setSetting("pageGuides", !pageGuidesOn());
-                        updatePageGuides();
-                    }
-                },
                 {
                     label: "Layout boxes",
                     checked: function () { return document.body.classList.contains("doc-show-boxes"); },

+ 177 - 3
src/web/Office/docs/docs_layout.js

@@ -769,6 +769,12 @@ var DocsLayout = (function () {
         c.classList.remove("doc-split-head", "doc-split-tail");
         if (!c.getAttribute("class")) c.removeAttribute("class");
         if (el.tagName === "OL") c.removeAttribute("start");
+        if (el.classList.contains("doc-colsec")) {
+            // the rest of a column section is laid out afresh on its page
+            c.style.height = "";
+            c.classList.remove("doc-colsec-fill");
+            if (!c.getAttribute("style")) c.removeAttribute("style");
+        }
         if (el.tagName === "TR") {
             c.style.height = "";
             c.removeAttribute("height");
@@ -840,6 +846,7 @@ var DocsLayout = (function () {
        being copied, so no half is ever left empty. */
     function splitTree(start, boundary) {
         var cur = start;
+        if (!cur || cur === boundary || !boundary.contains(cur)) return null;
         while (cur && cur.parentNode && cur.parentNode !== boundary) {
             var parent = cur.parentNode;
             if (!prevMeaningful(cur)) {
@@ -901,6 +908,7 @@ var DocsLayout = (function () {
         }
         if (tail.parentNode) tail.parentNode.removeChild(tail);
         unmark(head, "head");
+        if (head.classList.contains("doc-colsec")) resetColumns(head);
     }
     function mergeChildren(head, tail) {
         var c, nx;
@@ -1001,6 +1009,7 @@ var DocsLayout = (function () {
         try {
             removeSpacerList(root.querySelectorAll(".doc-autobreak"), root);
             repairSplits(root, true);
+            unwrapColumns(root);
             var breaks = root.querySelectorAll(".doc-pagebreak");
             for (var i = 0; i < breaks.length; i++) breaks[i].style.height = "0px";
         } finally {
@@ -1091,6 +1100,166 @@ var DocsLayout = (function () {
         return true;
     }
 
+    /* ---- columns ----
+
+       A multi-column page is laid out in column sections: every run of
+       text that flows in columns (everything between two full-width blocks,
+       .col-span-all) is wrapped in a div.doc-colsec with CSS columns, and a
+       full-width block stays outside, across the page. A section that fits
+       on its page balances its columns; one that runs past the page bottom
+       is given the height that is left and filled column by column, and it
+       splits - like any other block - at the first content that spilled
+       into a column past the last one. The copy of the section carries the
+       rest to the next page, where it is laid out the same way.
+
+       Sections are layout only, like spacers: made before every layout of a
+       columned document and taken out again on everything that is saved. */
+    function isSpanning(el) {
+        return el.classList.contains("col-span-all") || el.classList.contains("doc-pagebreak") ||
+            el.getAttribute("data-page-break-before") === "1";
+    }
+    function resetColumns(sec) {
+        sec.classList.remove("doc-colsec-fill");
+        sec.style.height = "";
+        if (!sec.getAttribute("style")) sec.removeAttribute("style");
+    }
+    function unwrapColumns(root) {
+        var secs = root.querySelectorAll(".doc-colsec");
+        for (var i = secs.length - 1; i >= 0; i--) {
+            var sec = secs[i];
+            var parent = sec.parentNode;
+            if (!parent) continue;
+            while (sec.firstChild) parent.insertBefore(sec.firstChild, sec);
+            parent.removeChild(sec);
+            noteMoved();
+        }
+    }
+    function wrapColumns(root) {
+        var sec = null;
+        for (var n = root.firstChild; n; ) {
+            var next = n.nextSibling;
+            var breaks = n.nodeType === 1 && (isSpacer(n) || isSpanning(n) || n.classList.contains("doc-colsec"));
+            if (breaks) {
+                sec = null;
+            } else if (n.nodeType === 1 || (n.nodeType === 3 && /\S/.test(n.nodeValue))) {
+                if (!sec) {
+                    sec = document.createElement("div");
+                    sec.className = "doc-colsec";
+                    root.insertBefore(sec, n);
+                }
+                sec.appendChild(n);
+                noteMoved();
+            } else if (sec) {
+                sec.appendChild(n);
+            }
+            n = next;
+        }
+    }
+
+    /* the first content of a column section that sits in a column past the
+       last one the page has: { before: el } or { block, node, offset } /
+       { block, beforeEl } for a line inside a paragraph */
+    Paginator.prototype.columnOverflow = function (sec) {
+        var n = Math.max(1, this.o.columns || 1);
+        var cs = window.getComputedStyle(sec);
+        var gap = parseFloat(cs.columnGap) || 0;
+        var colW = (sec.clientWidth - gap * (n - 1)) / n;
+        var scale = this._scale || 1;
+        var limit = sec.getBoundingClientRect().left + (n * colW + (n - 0.5) * gap) * scale;
+        function past(rect) { return rect.left >= limit - 0.5; }
+        function anyPast(rects) {
+            for (var i = 0; i < rects.length; i++) if (past(rects[i])) return true;
+            return false;
+        }
+        var range = document.createRange();
+        function charRect(node, i, len) {
+            for (; i < len; i++) {
+                range.setStart(node, i);
+                range.setEnd(node, i + 1);
+                var rr = range.getClientRects();
+                if (rr.length && rr[0].height > 0) return { rect: rr[rr.length - 1], at: i };
+            }
+            return null;
+        }
+        function inLine(block) {
+            var walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null);
+            var node;
+            while ((node = walker.nextNode())) {
+                if (node.nodeType === 1) {
+                    if (node.tagName === "IMG" && anyPast(node.getClientRects())) return { block: block, beforeEl: node };
+                    continue;
+                }
+                if (!node.nodeValue) continue;
+                range.selectNodeContents(node);
+                if (!anyPast(range.getClientRects())) continue;
+                var len = node.nodeValue.length;
+                var lo = 0, hi = len - 1, ans = len;
+                while (lo <= hi) {
+                    var mid = (lo + hi) >> 1;
+                    var cr = charRect(node, mid, len);
+                    if (!cr) { hi = mid - 1; continue; }
+                    if (past(cr.rect)) { ans = Math.min(ans, cr.at); hi = mid - 1; }
+                    else lo = cr.at + 1;
+                }
+                return { block: block, node: node, offset: Math.min(ans, len) };
+            }
+            return null;
+        }
+        function find(container) {
+            for (var c = container.firstElementChild; c; c = c.nextElementSibling) {
+                if (isSpacer(c)) continue;
+                var rects = c.getClientRects();
+                if (!rects.length) continue;
+                if (past(rects[0])) return { before: c };
+                if (!anyPast(rects)) continue;
+                if (c.tagName === "TABLE") {
+                    var rows = rowsOf(c);
+                    for (var r = 0; r < rows.length; r++) {
+                        if (anyPast(rows[r].getClientRects())) return { before: r === 0 ? c : rows[r] };
+                    }
+                    return { before: c };
+                }
+                if (c.tagName === "IMG" || c.tagName === "HR" || c.tagName === "VIDEO" || c.tagName === "IFRAME") {
+                    return { before: c };
+                }
+                if (hasBlockChild(c)) {
+                    var inner = find(c);
+                    if (inner) return inner;
+                    return { before: c };
+                }
+                return inLine(c) || { before: c };
+            }
+            return null;
+        };
+        return find(sec);
+    };
+
+    Paginator.prototype.splitColumns = function (sec, B, C, boundary) {
+        var top = this.top(sec);
+        var avail = B - top;
+        if (avail < 2) return top > C + 1 ? this.beforeCut(sec, C, boundary) : { kind: "overflow", y: top };
+        sec.classList.add("doc-colsec-fill");
+        sec.style.height = avail + "px";
+        var hit = this.columnOverflow(sec);
+        if (!hit) return { kind: "fit" };
+        // nothing of the section fits: it moves whole, or spills when it
+        // already starts the page
+        var startNode = hit.before ? hit.before.parentNode : (hit.beforeEl ? hit.beforeEl.parentNode : hit.node);
+        var startOffset = hit.before ? Array.prototype.indexOf.call(hit.before.parentNode.childNodes, hit.before) :
+            (hit.beforeEl ? Array.prototype.indexOf.call(hit.beforeEl.parentNode.childNodes, hit.beforeEl) : hit.offset);
+        if (edgeEmpty(sec, startNode, startOffset, true)) {
+            resetColumns(sec);
+            return top > C + 1 ? this.beforeCut(sec, C, boundary) : { kind: "overflow", y: top };
+        }
+        // the page is full: its footnotes are the references above B in the
+        // columns that stay
+        if (hit.before) return { kind: "before", el: hit.before, col: true, y: B + 0.5, boundary: boundary };
+        return {
+            kind: "line", el: hit.block, node: hit.node, offset: hit.offset, beforeEl: hit.beforeEl,
+            y: B + 0.5, boundary: boundary
+        };
+    };
+
     function blockKids(container) {
         var out = [];
         for (var c = container.firstElementChild; c; c = c.nextElementSibling) {
@@ -1161,6 +1330,7 @@ var DocsLayout = (function () {
             return this.beforeCut(el, C, boundary);
         }
         if (el.classList.contains("doc-pagebreak")) return null;
+        if (el.classList.contains("doc-colsec")) return this.splitColumns(el, B, C, boundary);
         if (isList(el) || tag === "TBODY") {
             var inner = this.findCut(el, B, C, boundary);
             return this.hoist(inner, el, C, boundary);
@@ -1251,8 +1421,9 @@ var DocsLayout = (function () {
             var cut = hasBlockChild(td) ? this.findCut(td, B - padB, C, td)
                 : this.splitLines(td, B - padB, C, td);
             if (cut && cut.kind === "fit") cut = null;
-            if (cut && cut.kind === "overflow") {
-                // this cell cannot give anything up: move the row if we can
+            if (cut && (cut.kind === "overflow" || (cut.kind === "before" && cut.el === td))) {
+                // this cell cannot give anything up (or all of it moves):
+                // move the row if we can
                 cut = { kind: "cellstart", td: td, y: rowTop };
             }
             if (cut) {
@@ -1447,7 +1618,7 @@ var DocsLayout = (function () {
                 return;
             case "before":
                 var el = cut.el;
-                if (el.tagName === "TR") {
+                if (el.tagName === "TR" && !cut.col) {
                     var cols = 0;
                     for (var c = 0; c < el.cells.length; c++) cols += el.cells[c].colSpan;
                     var sp = makeSpacer("row", cols);
@@ -1538,6 +1709,8 @@ var DocsLayout = (function () {
             var r = refs[i];
             var rect = r.getBoundingClientRect();
             if (!rect.height) continue;
+            // a reference in a column past the page's last one is not here
+            if (this.o.columns > 1 && rect.left > this.editor.getBoundingClientRect().right + 1) continue;
             var y = this.rectTop(rect);
             if (y >= y0 - 1 && y < y1) {
                 var id = r.getAttribute("data-fn");
@@ -1597,6 +1770,7 @@ var DocsLayout = (function () {
             });
         } else {
             removeSpacers(this.editor);
+            if (o.columns > 1) wrapColumns(this.editor);
             pages = [];
         }
         var contentEnd = function (self) { return self.bottom(self.editor); };

+ 2 - 52
src/web/Office/docs/docs_pdf.js

@@ -39,7 +39,7 @@ var DocsPdf = (function () {
     var PT = 96 / 72;
 
     // subtrees that are editor chrome, not page content
-    var SKIP_SELECTOR = "#pageSheets, #pageGuides, .doc-fn-measure, .of-img-handle, .doc-autobreak";
+    var SKIP_SELECTOR = "#pageSheets, .doc-fn-measure, .of-img-handle, .doc-autobreak";
 
     function skipped(el) {
         return !!(el && el.closest && el.closest(SKIP_SELECTOR));
@@ -546,56 +546,6 @@ var DocsPdf = (function () {
         });
     }
 
-    /* ---------------- running the job ---------------- */
-
-    var WORKER_URL = "../common/pdfworker.js";
-
-    /* The worker is where the time goes (pictures, fonts, compression).
-       When one cannot be started at all - an old browser, a page opened
-       from disk - the same code runs here instead, just less politely. */
-    function renderJob(job, onProgress) {
-        return new Promise(function (resolve, reject) {
-            var worker;
-            var started = false;
-            function inPage() {
-                C.loadFontkit().then(function (fk) {
-                    return OfficePdfDraw.render(job, { fontkit: fk, onProgress: onProgress });
-                }).then(resolve, reject);
-            }
-            try {
-                worker = new Worker(WORKER_URL);
-            } catch (e) {
-                inPage();
-                return;
-            }
-            worker.onmessage = function (e) {
-                var m = e.data || {};
-                started = true;
-                if (m.type === "progress") {
-                    if (onProgress) onProgress(m.done, m.total, m.stage);
-                } else if (m.type === "done") {
-                    worker.terminate();
-                    resolve(m.bytes);
-                } else if (m.type === "error") {
-                    worker.terminate();
-                    reject(new Error(m.message || "the PDF could not be written"));
-                }
-            };
-            worker.onerror = function (e) {
-                if (e && e.preventDefault) e.preventDefault();
-                worker.terminate();
-                if (!started && typeof OfficePdfDraw !== "undefined") inPage();
-                else reject(new Error((e && e.message) || "the PDF worker failed"));
-            };
-            var transfer = [];
-            Object.keys(job.images).forEach(function (id) {
-                var b = job.images[id].bytes;
-                if (b && b.buffer && transfer.indexOf(b.buffer) < 0) transfer.push(b.buffer);
-            });
-            worker.postMessage({ job: job }, transfer);
-        });
-    }
-
     function waitForImages(root) {
         var imgs = Array.prototype.slice.call(root.querySelectorAll("img"));
         var pending = imgs.filter(function (im) { return !im.complete; });
@@ -661,7 +611,7 @@ var DocsPdf = (function () {
             return displayList(snap, data, fonts);
         }).then(function (job) {
             data = null;
-            return renderJob(job, progress);
+            return OfficePdfDraw.run(job, { onProgress: progress, loadFontkit: C.loadFontkit });
         });
     }
 

+ 10 - 6
src/web/Office/docs/index.html

@@ -5,6 +5,16 @@
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>Docs</title>
     <link rel="icon" type="image/png" href="docs.png">
+    <!-- Dynamic print stylesheet: docs.js writes the @page rule (size,
+         orientation, margins, optional page-number margin box) in here
+         whenever the page setup changes. Static print rules live in docs.css. -->
+    <style id="pagePrintStyle"></style>
+</head>
+<body data-officeapp="docs">
+    <!-- start-up splash: first, so it covers the page before anything draws -->
+    <script src="../common/splash.js" data-app="Docs" data-icon="../img/docs.svg" data-size="1080x700"></script>
+    <!-- the app's stylesheets and scripts come after the splash, so it can
+         paint while they load -->
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
     <link rel="stylesheet" href="../common/office.css">
     <link rel="stylesheet" href="../common/fonts/fonts.css">
@@ -23,12 +33,6 @@
     <script src="../common/clipboard.js"></script>
     <script src="../common/textedit.js"></script>
     <script src="../common/lib/marked.min.js"></script>
-    <!-- Dynamic print stylesheet: docs.js writes the @page rule (size,
-         orientation, margins, optional page-number margin box) in here
-         whenever the page setup changes. Static print rules live in docs.css. -->
-    <style id="pagePrintStyle"></style>
-</head>
-<body data-officeapp="docs">
 
     <!-- Toolbar (buttons are built by docs.js) -->
     <div class="of-toolbar of-noprint" id="toolbar"></div>

+ 4 - 2
src/web/Office/init.agi

@@ -24,8 +24,10 @@ registerModule(JSON.stringify({
     LaunchFWDir: "Office/docs/index.html",
     SupportEmb: true,
     LaunchEmb: "Office/docs/index.html",
-    InitFWSize: [1080, 700],
-    InitEmbSize: [1080, 700],
+    // the window opens at the size of the start-up splash and grows to
+    // 1080x700 once the document is ready (common/splash.js, data-size)
+    InitFWSize: [400, 240],
+    InitEmbSize: [400, 240],
     SupportedExt: [".doca", ".docx"]
 }));
 

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

@@ -55,6 +55,7 @@
     <script src="slides_shapes.js"></script>
     <script src="slides_image.js"></script>
     <script src="../common/pdfcore.js"></script>
+    <script src="../common/pdfdraw.js"></script>
     <script src="slides_pdf.js"></script>
     <script src="slides.js"></script>
     <script src="present.js"></script>

+ 18 - 4
src/web/Office/slides/slides.js

@@ -3697,10 +3697,24 @@ var SlidesApp = (function () {
             return;
         }
         SlidesPdf.build(snapshot, {
-            onProgress: function (n, total) {
-                if (prog) prog.set(n, total, "Exporting " + n + " / " + total +
-                    (total === 1 ? " page" : " pages"));
-                else OfficeApp.setStatus("Exporting PDF... slide " + n + " of " + total);
+            title: OfficeApp.stripExt(OfficeApp.getFileName() || "Presentation"),
+            onProgress: function (n, total, stage) {
+                // measuring the slides is the first half, writing the file
+                // (in a worker) the second
+                var f = total > 0 ? n / total : 0;
+                var pct, msg;
+                if (stage === "save") {
+                    pct = 97;
+                    msg = "Writing the file...";
+                } else if (stage === "page") {
+                    pct = 50 + 45 * f;
+                    msg = "Writing " + n + " / " + total + (total === 1 ? " page" : " pages");
+                } else {
+                    pct = 50 * f;
+                    msg = "Exporting " + n + " / " + total + (total === 1 ? " page" : " pages");
+                }
+                if (prog) prog.set(pct, 100, msg);
+                else OfficeApp.setStatus("Exporting PDF... " + msg);
             }
         }).then(function (bytes) {
             if (prog) prog.message("Writing " + OfficeApp.basename(fp) + "...");

+ 25 - 6
src/web/Office/slides/slides_pdf.js

@@ -50,9 +50,19 @@
     the width the browser gave it (Tz), so a substituted face cannot push a
     line out of shape.
 
+    Where it runs. Each slide is measured and drawn in the page, but onto a
+    recording (OfficePdfDraw.recorder, common/pdfdraw.js): every pdf-lib
+    call is written down as data, pictures are named rather than embedded,
+    and fonts are only parsed here for their glyph widths. The file itself -
+    embedding and deflating the pictures, subsetting the fonts, writing it
+    out - is made from that recording in a Web Worker (common/pdfworker.js),
+    so a large deck does not freeze the editor while it is written.
+
     Usage:
-        SlidesPdf.build(body, { onProgress: fn(done, total) })
+        SlidesPdf.build(body, { onProgress: fn(done, total, stage) })
             -> Promise<Uint8Array>
+        stage: "measure" per slide drawn, then "page" and "save" from the
+        worker
 */
 
 var SlidesPdf = (function () {
@@ -72,7 +82,7 @@ var SlidesPdf = (function () {
     var collectRuns = C.collectRuns, canDrawAsText = C.canDrawAsText;
     var drawFragment = C.drawFragment, drawRuns = C.drawRuns;
     var rasterizeElement = C.rasterizeElement, rasterizeFallback = C.rasterizeFallback;
-    var filteredImageData = C.filteredImageData, makeImageEmbedder = C.makeImageEmbedder;
+    var filteredImageData = C.filteredImageData;
     // a slide page: the core's drawing context at the slide's height
     function Page(page, pdfDoc, fonts) {
         return new C.Page(page, pdfDoc, fonts, SLIDE_H);
@@ -738,14 +748,17 @@ var SlidesPdf = (function () {
                 return { doc: pdfDoc, kit: fontkit };
             });
         }).then(function (made) {
+            // this document is never saved: it only lends its fonts their
+            // metrics - the drawing goes to the recording
             var pdfDoc = made.doc;
             var fonts = makeFonts(pdfDoc, made.kit);
-            var embedImage = makeImageEmbedder(pdfDoc);
+            var rec = OfficePdfDraw.recorder();
+            var embedImage = rec.embed;
             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 page = rec.addPage([px(SLIDE_W), px(SLIDE_H)]);
                     var pg = new Page(page, pdfDoc, fonts);
                     var bg = parseColor(slide.bg || theme.bg);
                     if (bg) pg.rect(0, 0, SLIDE_W, SLIDE_H, { fill: bg });
@@ -766,10 +779,16 @@ var SlidesPdf = (function () {
                         });
                     });
                 }).then(function () {
-                    if (opts.onProgress) opts.onProgress(idx + 1, slides.length);
+                    if (opts.onProgress) opts.onProgress(idx + 1, slides.length, "measure");
+                    // let the editor breathe between slides
+                    return new Promise(function (res) { setTimeout(res, 0); });
+                });
+            });
+            return chain.then(function () {
+                return OfficePdfDraw.run(rec.job(opts.title), {
+                    onProgress: opts.onProgress, loadFontkit: loadFontkit
                 });
             });
-            return chain.then(function () { return pdfDoc.save(); });
         });
     }