diff --git a/addon-build/docx-editor/make-icons.mjs b/addon-build/docx-editor/make-icons.mjs
new file mode 100644
index 00000000..908b329a
--- /dev/null
+++ b/addon-build/docx-editor/make-icons.mjs
@@ -0,0 +1,101 @@
+// Rasterise extensions/docx-editor/icon.svg to PNGs.
+//
+// npm run icons (from addon-build/docx-editor/)
+//
+// SVG is what the dock and the catalogue use; the PNGs are for everywhere
+// that can't take one — a favicon fallback, a listing thumbnail, an OS file
+// association later on. There is no sharp/resvg on this machine, so the
+// rasteriser is Electron itself: a hidden window, the SVG scaled to fill it,
+// and capturePage(). Re-run it whenever icon.svg changes.
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(here, "../..");
+const EXT = path.resolve(ROOT, "extensions/docx-editor");
+const SIZES = [16, 32, 48, 128, 256];
+
+const electron = path.join(ROOT, "node_modules", "electron", "dist",
+ process.platform === "win32" ? "electron.exe" : "electron");
+if (!fs.existsSync(electron)) {
+ console.error(`electron not found at ${electron} — run npm install in TheseusNavigator first`);
+ process.exit(2);
+}
+
+// The main script runs inside Electron; it is written to a temp dir rather
+// than committed, because it is scaffolding for this script and nothing else.
+const work = fs.mkdtempSync(path.join(os.tmpdir(), "docx-icons-"));
+const svg = fs.readFileSync(path.join(EXT, "icon.svg"), "utf8");
+
+for (const size of SIZES) {
+ fs.writeFileSync(path.join(work, `page-${size}.html`), `
+${svg}`);
+}
+
+fs.writeFileSync(path.join(work, "main.js"), `
+const { app, BrowserWindow } = require("electron");
+const fs = require("fs"), path = require("path");
+const work = ${JSON.stringify(work)};
+const out = ${JSON.stringify(EXT)};
+const sizes = ${JSON.stringify(SIZES)};
+app.disableHardwareAcceleration();
+
+// One window, resized per icon. Creating and destroying an offscreen window
+// per size raced with itself: the next loadFile came back ERR_FAILED while
+// the previous one was still tearing down its compositor.
+app.whenReady().then(async () => {
+ const win = new BrowserWindow({
+ width: 256, height: 256, show: false, frame: false, transparent: true,
+ useContentSize: true, webPreferences: { offscreen: true },
+ });
+ for (const size of sizes) {
+ win.setContentSize(size, size);
+ await win.loadFile(path.join(work, "page-" + size + ".html"));
+ // Offscreen windows paint asynchronously and the first frame is often
+ // the blank one, so wait for a couple before capturing.
+ await new Promise((r) => {
+ let n = 0;
+ const onPaint = () => { if (++n >= 2) { win.webContents.off("paint", onPaint); r(); } };
+ win.webContents.on("paint", onPaint);
+ setTimeout(r, 1500);
+ });
+ const img = await win.webContents.capturePage();
+ fs.writeFileSync(path.join(out, "icon-" + size + ".png"), img.toPNG());
+ }
+ win.destroy();
+ app.quit();
+}).catch((e) => { console.error("icon render failed:", e); app.exit(1); });
+`);
+fs.writeFileSync(path.join(work, "package.json"), JSON.stringify({ name: "docx-icons", main: "main.js" }));
+
+execFileSync(electron, [work], { stdio: "inherit" });
+for (const size of SIZES) {
+ const f = path.join(EXT, `icon-${size}.png`);
+ console.log(`${path.relative(ROOT, f)} ${fs.existsSync(f) ? (fs.statSync(f).size / 1024).toFixed(1) + " KB" : "MISSING"}`);
+}
+fs.rmSync(work, { recursive: true, force: true });
+
+// The dock and the catalogue card read `icon` out of addon.json, and both
+// take a data URL (this is how Aegis ships its mark). Deriving it from
+// icon.svg here means there is one drawing, not two that drift apart.
+const minified = svg
+ .replace(//g, "")
+ .replace(/
[\s\S]*?<\/title>/g, "")
+ .replace(/\s+/g, " ")
+ .replace(/>\s+<")
+ .trim();
+const dataUrl = "data:image/svg+xml;utf8," + encodeURIComponent(minified);
+
+const manifestPath = path.join(EXT, "addon.json");
+const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
+if (manifest.icon !== dataUrl) {
+ manifest.icon = dataUrl;
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
+ console.log(`addon.json icon updated (${dataUrl.length} chars)`);
+} else {
+ console.log("addon.json icon already current");
+}
diff --git a/addon-build/docx-editor/package.json b/addon-build/docx-editor/package.json
index 72a6efb1..8a44a14e 100644
--- a/addon-build/docx-editor/package.json
+++ b/addon-build/docx-editor/package.json
@@ -6,7 +6,8 @@
"type": "module",
"scripts": {
"build": "node build.mjs",
- "pack": "node pack.mjs"
+ "pack": "node pack.mjs",
+ "icons": "node make-icons.mjs"
},
"devDependencies": {
"docx": "^9.5.1",
diff --git a/extensions/docx-editor/README.md b/extensions/docx-editor/README.md
index 31abec10..73fb8f44 100644
--- a/extensions/docx-editor/README.md
+++ b/extensions/docx-editor/README.md
@@ -1,6 +1,15 @@
# Word editor — a Theseus community extension
-Opens, edits and saves Word documents (`.docx`) in a full Theseus tab.
+Opens, edits and saves Word documents (`.docx`) in a full Theseus tab, and
+exports them as PDF.
+
+Saving comes in three shapes. **Save** drops a copy in your downloads until the
+document has a home; **Save as…** opens a real file dialog where the extension
+you type — `.docx` or `.pdf` — decides the format, and from then on Save writes
+straight to that file; **PDF** is the one-click export. PDFs are rendered by
+Chromium's own print pipeline from `lib/doc-css.js`, the same stylesheet the
+editor displays, on the paper size the document itself specifies — so the
+export matches the preview rather than being a second opinion about it.
This is **not** a bundled add-on. It isn't in `bundled-addons/`, it isn't in
the installer's `extraResources`, and a fresh Theseus profile doesn't have it.
@@ -12,6 +21,19 @@ Living outside the browser build is the point. A .docx editor is a big
dependency — a megabyte of vendored library — and nobody should carry it
because they wanted a browser.
+## The icon
+
+`icon.svg` is the one drawing. `npm run icons` rasterises it to
+`icon-16/32/48/128/256.png` and writes a minified copy into `addon.json`'s
+`icon` field, which is what the dock button and the catalogue card read — so
+there is never a second version to keep in step.
+
+It is deliberately not Microsoft's Word icon. That mark, and the blue sheet
+with a white W it belongs to, are Microsoft's trademarks; dressing our
+extension up as theirs would be both a legal problem and a dishonest one. The
+mark here says "text document" in its own words: a page with a turned corner,
+a heading rule, body lines, and a pilcrow badge in Silent Mode green.
+
## Building
The vendored libraries (mammoth, ProseMirror, docx, JSZip) are bundled by a
diff --git a/extensions/docx-editor/addon.json b/extensions/docx-editor/addon.json
index 73f62523..7e401005 100644
--- a/extensions/docx-editor/addon.json
+++ b/extensions/docx-editor/addon.json
@@ -2,10 +2,13 @@
"id": "docx-editor",
"name": "Word editor",
"version": "0.1.1",
- "description": "Open, edit and save Word documents (.docx) in a full Theseus tab. Ribbon-style formatting, tables, lists, images and links; headers, footers, footnotes, page setup and the document's own styles are carried through a save untouched.",
+ "description": "Open, edit and save Word documents (.docx) in a full Theseus tab, or export them as PDF. Ribbon-style formatting, tables, lists, images and links; headers, footers, footnotes, page setup and the document's own styles are carried through a save untouched.",
"author": "Silent Mode",
- "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjMjU2M2ViIi8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzFlNDBhZiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5ET0M8L3RleHQ+PC9zdmc+",
+ "icon": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%20role%3D%22img%22%20aria-label%3D%22Word%20editor%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22dx-page%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%220%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ffffff%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23e9edf4%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20d%3D%22M6.5%202.5h12l7%207v20a1%201%200%200%201-1%201h-18a1%201%200%200%201-1-1v-26a1%201%200%200%201%201-1z%22%20fill%3D%22url(%23dx-page)%22%20stroke%3D%22%232a3242%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M18.5%202.5v7h7%22%20fill%3D%22%23cfd8e6%22%20stroke%3D%22%232a3242%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3Crect%20x%3D%229.5%22%20y%3D%2213%22%20width%3D%229%22%20height%3D%222.2%22%20rx%3D%221.1%22%20fill%3D%22%230ac18e%22%2F%3E%3Crect%20x%3D%229.5%22%20y%3D%2217.6%22%20width%3D%2213%22%20height%3D%221.6%22%20rx%3D%22.8%22%20fill%3D%22%2397a3b6%22%2F%3E%3Crect%20x%3D%229.5%22%20y%3D%2221%22%20width%3D%2213%22%20height%3D%221.6%22%20rx%3D%22.8%22%20fill%3D%22%2397a3b6%22%2F%3E%3Crect%20x%3D%229.5%22%20y%3D%2224.4%22%20width%3D%228.5%22%20height%3D%221.6%22%20rx%3D%22.8%22%20fill%3D%22%2397a3b6%22%2F%3E%3Cg%20transform%3D%22translate(20.4%2019.2)%22%3E%3Ccircle%20cx%3D%224.6%22%20cy%3D%224.6%22%20r%3D%225.4%22%20fill%3D%22%230ac18e%22%2F%3E%3Cpath%20d%3D%22M6.6%201.9H3.9a1.85%201.85%200%200%200%200%203.7h.8M5.3%201.9v5.6M6.6%201.9v5.6%22%20fill%3D%22none%22%20stroke%3D%22%230a0f16%22%20stroke-width%3D%221.15%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E",
"main": "index.js",
- "capabilities": ["sidebar-panel", "open-tab"],
+ "capabilities": [
+ "sidebar-panel",
+ "open-tab"
+ ],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/community/docx-editor/updates.json"
}
diff --git a/extensions/docx-editor/editor.css b/extensions/docx-editor/editor.css
index fc317355..4a7a5fcc 100644
--- a/extensions/docx-editor/editor.css
+++ b/extensions/docx-editor/editor.css
@@ -110,48 +110,12 @@ input.rnum { width: 52px; cursor: text; text-align: center; }
.sheet:focus { outline: none; }
.sheet .ProseMirror { outline: none; min-height: 50vh; }
-/* Word's own defaults are a serif body at 11pt with a little space after
- each paragraph; matching them means what the editor shows is roughly
- what Word will show. */
-.sheet {
- font: 11pt/1.5 Georgia, "Times New Roman", serif;
-}
-.sheet p { margin: 0 0 8pt; }
-.sheet h1, .sheet h2, .sheet h3, .sheet h4, .sheet h5, .sheet h6 {
- font-family: "Segoe UI Semibold", "Segoe UI", Calibri, system-ui, sans-serif;
- color: #1f4e79; font-weight: 600; margin: 14pt 0 6pt; line-height: 1.25;
-}
-.sheet h1 { font-size: 20pt; } .sheet h2 { font-size: 16pt; }
-.sheet h3 { font-size: 13pt; } .sheet h4 { font-size: 12pt; }
-.sheet h5 { font-size: 11pt; } .sheet h6 { font-size: 10.5pt; color: #2e74b5; }
-.sheet blockquote { margin: 8pt 0 8pt 24pt; padding-left: 10pt;
- border-left: 3px solid rgba(0,0,0,.15); color: #404040; font-style: italic; }
-.sheet pre { font: 10pt/1.4 Consolas, "Courier New", monospace;
- background: rgba(0,0,0,.04); border: 1px solid rgba(0,0,0,.08);
- border-radius: 3px; padding: 8pt 10pt; margin: 8pt 0; white-space: pre-wrap; }
-.sheet hr { border: 0; border-top: 1px solid #808080; margin: 10pt 0; }
-.sheet ul, .sheet ol { margin: 0 0 8pt; padding-left: 28pt; }
-.sheet li { margin: 0 0 2pt; }
-.sheet li > p { margin: 0 0 2pt; }
-.sheet a { color: #0563c1; text-decoration: underline; }
-.sheet img { max-width: 100%; height: auto; vertical-align: baseline; }
-.sheet table { border-collapse: collapse; margin: 8pt 0; width: 100%; table-layout: fixed; }
-.sheet td, .sheet th { border: 1px solid #999; padding: 4pt 6pt; vertical-align: top;
- position: relative; min-width: 1em; }
-.sheet th { background: rgba(0,0,0,.04); font-weight: 600; text-align: left; }
-.sheet td > p:last-child, .sheet th > p:last-child { margin-bottom: 0; }
-.sheet .docx-page-break {
- border-top: 1px dashed #b00; margin: 14pt 0; text-align: center;
- user-select: none; position: relative;
-}
-.sheet .docx-page-break span {
- font: 9pt/1 system-ui, sans-serif; color: #b00; background: var(--paper);
- padding: 0 8px; position: relative; top: -6pt; letter-spacing: .04em;
-}
-.sheet .docx-note-ref {
- color: #0563c1; font-size: .7em; padding: 0 1px; cursor: default;
- border-bottom: 1px dotted #0563c1;
-}
+/* The document's own typography lives in lib/doc-css.js, injected at boot.
+ It is shared verbatim with the PDF export, which renders in a throwaway
+ window that can't reach this stylesheet — one copy means the PDF can't
+ drift away from what the editor showed. Only the "paper on a desk"
+ framing stays here. */
+
/* prosemirror-tables' own furniture */
.sheet .selectedCell:after {
@@ -229,6 +193,6 @@ input.rnum { width: 52px; cursor: text; text-align: center; }
html, body { height: auto; overflow: visible; background: #fff; }
.board { overflow: visible; padding: 0; background: #fff; }
.sheet { box-shadow: none; max-width: none; margin: 0; padding: 0; background: #fff; color: #000; }
- .sheet .docx-page-break { border: 0; margin: 0; break-after: page; page-break-after: always; }
- .sheet .docx-page-break span { display: none; }
+ /* Page breaks and widow control come from lib/doc-css.js, which carries
+ its own @media print block so Ctrl+P and the PDF export agree. */
}
diff --git a/extensions/docx-editor/editor.html b/extensions/docx-editor/editor.html
index 68058685..b9f3408e 100644
--- a/extensions/docx-editor/editor.html
+++ b/extensions/docx-editor/editor.html
@@ -3,6 +3,7 @@
Word editor
+
@@ -20,6 +21,14 @@
Save
+
+
@@ -201,6 +210,7 @@
+
diff --git a/extensions/docx-editor/editor.js b/extensions/docx-editor/editor.js
index 509547ae..2e8f7b28 100644
--- a/extensions/docx-editor/editor.js
+++ b/extensions/docx-editor/editor.js
@@ -7,10 +7,19 @@
// the file that this editor can't render
// → the report becomes the banners at the top of the page
//
-// Save path:
-// write.docToDocx() → bytes → . The original bytes are kept in
-// memory for the whole session because the save grafts the document's
-// headers, footers, notes, styles and page setup back out of them.
+// Save paths:
+// Save → write.docToDocx() → a copy in Downloads, or straight to the
+// file the user chose earlier with Save as…
+// Save as… → a native file dialog; the extension typed there decides
+// whether they get a .docx or a .pdf
+// PDF → the same PDF, one click, into Downloads
+//
+// The original bytes are kept in memory for the whole session because the
+// save grafts the document's headers, footers, notes, styles and page
+// setup back out of them. PDFs are rendered by Chromium's own print
+// pipeline in a hidden window on the add-on side, from the very CSS the
+// editor is displaying (lib/doc-css.js), so the export can't drift away
+// from the preview.
//
// The "blocked" state is worth understanding: a document with tracked
// changes or comments opens read-only, because mammoth silently renders
@@ -47,6 +56,10 @@ let locked = false; // tracked changes / comments, not yet accepted
let dirty = false;
let lastSavedAt = 0;
let autosaveTimer = null;
+// Where "Save" writes without asking, once the user has chosen a home for
+// this document with Save as… Until then Save behaves like every other
+// add-on here and drops the file in Downloads.
+let savedTarget = null; // {name} — the path itself stays in the add-on
// ---------------------------------------------------------------- status ---
@@ -475,8 +488,14 @@ function openAbout() {