From e6c9f8f5c93c4ea55f365c91f713f73062972d97 Mon Sep 17 00:00:00 2001
From: Local Dev
Date: Mon, 21 Sep 2026 03:35:55 +0200
Subject: [PATCH] =?UTF-8?q?feat(docx-editor):=20Save=20as=E2=80=A6,=20PDF?=
=?UTF-8?q?=20export,=20and=20a=20mark=20of=20our=20own?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
---
addon-build/docx-editor/make-icons.mjs | 101 +++++++++++
addon-build/docx-editor/package.json | 3 +-
extensions/docx-editor/README.md | 24 ++-
extensions/docx-editor/addon.json | 9 +-
extensions/docx-editor/editor.css | 52 +-----
extensions/docx-editor/editor.html | 10 ++
extensions/docx-editor/editor.js | 228 ++++++++++++++++++++++---
extensions/docx-editor/icon-128.png | Bin 0 -> 3937 bytes
extensions/docx-editor/icon-16.png | Bin 0 -> 695 bytes
extensions/docx-editor/icon-256.png | Bin 0 -> 8140 bytes
extensions/docx-editor/icon-32.png | Bin 0 -> 1248 bytes
extensions/docx-editor/icon-48.png | Bin 0 -> 1710 bytes
extensions/docx-editor/icon.svg | 42 +++++
extensions/docx-editor/index.js | 140 ++++++++++++++-
extensions/docx-editor/lib/doc-css.js | 109 ++++++++++++
extensions/docx-editor/panel.html | 7 +-
16 files changed, 648 insertions(+), 77 deletions(-)
create mode 100644 addon-build/docx-editor/make-icons.mjs
create mode 100644 extensions/docx-editor/icon-128.png
create mode 100644 extensions/docx-editor/icon-16.png
create mode 100644 extensions/docx-editor/icon-256.png
create mode 100644 extensions/docx-editor/icon-32.png
create mode 100644 extensions/docx-editor/icon-48.png
create mode 100644 extensions/docx-editor/icon.svg
create mode 100644 extensions/docx-editor/lib/doc-css.js
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() {
Save puts a copy in your downloads until you have given the document a home
+ with Save as…, which is a real file dialog — type a .docx name for a
+ Word document or a .pdf one for a PDF. After that, Save writes there
+ directly. PDF is the one-click version: the page is rendered on the paper size the
+ document itself specifies, by the same engine behind Ctrl+P, so it matches what you were
+ looking at. Nothing here ever overwrites the file you opened.
${found.length ? `
In this document
${found.map(line).join("")}
` : ""}
@@ -588,6 +607,10 @@ function syncRibbon() {
el.disabled = locked || (el.id === "list-format" && !ol);
});
$("file-save").disabled = locked;
+ $("file-save").title = savedTarget
+ ? `Save to ${savedTarget.name} (Ctrl+S)`
+ : "Save a .docx to your downloads (Ctrl+S)";
+ $("file-saveas").disabled = locked;
}
function wireRibbon() {
@@ -663,6 +686,8 @@ function wireRibbon() {
$("file-new").onclick = () => newDocument();
$("file-open").onclick = () => $("file-input").click();
$("file-save").onclick = () => save();
+ $("file-saveas").onclick = () => saveAs();
+ $("file-pdf").onclick = () => exportPdf();
$("file-print").onclick = () => window.print();
$("about").onclick = openAbout;
$("discard").onclick = () => closeTab();
@@ -683,6 +708,48 @@ function wireRibbon() {
};
}
+// ------------------------------------------------------------- document css ---
+
+// The page's own typography is shared verbatim with the PDF export, so it
+// lives in lib/doc-css.js rather than editor.css. Injecting it here (rather
+// than linking a second stylesheet) keeps the export and the screen reading
+// from one string.
+function installDocCss() {
+ const el = document.createElement("style");
+ el.id = "doc-css";
+ el.textContent = DE().docCss.DOC_CSS;
+ document.head.append(el);
+}
+
+// Ctrl+P should use the document's paper size too, not the browser default.
+function syncPageRule() {
+ let el = document.getElementById("page-rule");
+ if (!el) {
+ el = document.createElement("style");
+ el.id = "page-rule";
+ document.head.append(el);
+ }
+ el.textContent = DE().docCss.pageRule(docSetup);
+}
+
+// The document as a standalone HTML page: ProseMirror's own DOM serialisation
+// (so what you see is what gets rendered), with images already inline as data
+// URLs and the editor's chrome left behind.
+function printableHtml() {
+ const pm = document.querySelector(".ProseMirror");
+ if (!pm) throw new Error("nothing to export");
+ const clone = pm.cloneNode(true);
+ // Editing scaffolding that has no business in a finished document.
+ clone.querySelectorAll(".ProseMirror-separator, .ProseMirror-trailingBreak, .column-resize-handle")
+ .forEach((n) => n.remove());
+ clone.querySelectorAll("[contenteditable]").forEach((n) => n.removeAttribute("contenteditable"));
+ return DE().docCss.printableHtml({
+ title: docName.replace(/\.docx$/i, ""),
+ bodyHtml: clone.innerHTML,
+ setup: docSetup,
+ });
+}
+
// ------------------------------------------------------------------ editor ---
function buildPlugins() {
@@ -699,6 +766,7 @@ function buildPlugins() {
"Mod-u": tm(schema.marks.underline),
"Mod-k": () => { openLinkDialog(); return true; },
"Mod-s": () => { save(); return true; },
+ "Shift-Mod-s": () => { saveAs(); return true; },
"Mod-o": () => { $("file-input").click(); return true; },
"Mod-p": () => { window.print(); return true; },
"Mod-Enter": (state, dispatch) => {
@@ -806,6 +874,7 @@ function newDocument() {
docMeta = {};
docSetup = null;
scratchId = "";
+ savedTarget = null;
docName = "Untitled document";
report = { features: [] };
locked = false;
@@ -820,9 +889,11 @@ async function loadBytes(bytes, name, id) {
status("Reading document…");
const res = await DE().read.docxToDoc(bytes, schema);
originalBytes = bytes;
+ savedTarget = null;
docMeta = res.meta || {};
docSetup = res.setup || null;
report = res.report || { features: [] };
+ syncPageRule();
docName = name || "document.docx";
scratchId = id || "";
locked = report.blocked && report.blocked.length > 0;
@@ -871,31 +942,75 @@ function downloadName() {
return `${base}-edited.docx`;
}
+// Build the .docx bytes for whatever is on screen.
+async function buildDocx() {
+ const res = await DE().write.docToDocx(view.state.doc, {
+ originalBytes, setup: docSetup, meta: docMeta,
+ });
+ if (res.warnings && res.warnings.length) console.warn("[docx-editor] save warnings:", res.warnings);
+ return res;
+}
+
+// Render the document to PDF. Chromium's own print pipeline does the work,
+// in a hidden window on the add-on side — the same engine as Ctrl+P, so the
+// PDF matches the preview instead of being a second opinion about it.
+async function buildPdf() {
+ if (!window.silentmode?.invoke) throw new Error("PDF export needs the add-on host");
+ const res = await window.silentmode.invoke("renderPdf", {
+ html: printableHtml(),
+ name: docName,
+ });
+ if (!res || !res.base64) throw new Error("the renderer returned nothing");
+ return base64ToBytes(res.base64);
+}
+
+function downloadBytes(bytes, filename, mime) {
+ const blob = new Blob([bytes], { type: mime });
+ const url = URL.createObjectURL(blob);
+ const a = $("download-link");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 8000);
+ return filename;
+}
+
+const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+
+// Save. Once the document has a home — set by Save as… — this writes there
+// and says so. Before that it does what the other add-ons do and puts a copy
+// in Downloads, because the file picker never told us where the original
+// came from.
async function save() {
if (!view || locked) return;
try {
status("Saving…");
- const res = await DE().write.docToDocx(view.state.doc, {
- originalBytes, setup: docSetup, meta: docMeta,
- });
- const blob = new Blob([res.bytes], {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- });
- const url = URL.createObjectURL(blob);
- const a = $("download-link");
- a.href = url;
- a.download = downloadName();
- a.click();
- setTimeout(() => URL.revokeObjectURL(url), 8000);
+ const res = await buildDocx();
+ const kb = (res.bytes.length / 1024).toFixed(0);
+ const carried = res.carried && res.carried.length ? ` — carried over ${res.carried.join(", ")}` : "";
+ if (savedTarget) {
+ const written = await window.silentmode.invoke("writeChosen", {
+ token: savedTarget.token,
+ base64: DE().read.bytesToBase64(res.bytes),
+ });
+ // A token is good for one write, so take a fresh one for the next save
+ // to the same place.
+ savedTarget = await reserveSameTarget(written.path, written.name);
+ docName = written.name;
+ setDirty(false);
+ lastSavedAt = Date.now();
+ await autosave(true);
+ status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
+ return;
+ }
+
+ const name = downloadBytes(res.bytes, downloadName(), DOCX_MIME);
setDirty(false);
lastSavedAt = Date.now();
await autosave(true);
-
- const carried = res.carried && res.carried.length ? ` — carried over ${res.carried.join(", ")}` : "";
- status(`Saved ${a.download} (${(res.bytes.length / 1024).toFixed(0)} KB)${carried}`, "ok");
+ status(`Saved ${name} (${kb} KB)${carried}`, "ok");
if (res.warnings && res.warnings.length) {
- console.warn("[docx-editor] save warnings:", res.warnings);
status(`Saved with warnings: ${res.warnings.join("; ")}`, "err");
}
} catch (e) {
@@ -904,6 +1019,73 @@ async function save() {
}
}
+// Ask the add-on to re-reserve a path the user already chose, so the next
+// plain Save can write to it without another dialog.
+async function reserveSameTarget(path, name) {
+ try {
+ const r = await window.silentmode.invoke("reserveSavePath", { path });
+ return r && r.token ? { token: r.token, name: name || r.name } : null;
+ } catch { return null; }
+}
+
+// Save as… — a real file picker, and the extension the user types decides
+// whether they get a Word document or a PDF.
+async function saveAs() {
+ if (!view || locked) return;
+ if (!window.silentmode?.invoke) { status("Save as… needs the add-on host", "err"); return; }
+ try {
+ const pick = await window.silentmode.invoke("pickSavePath", { name: downloadName() });
+ if (!pick || pick.cancelled) return;
+
+ status(pick.format === "pdf" ? "Rendering PDF…" : "Saving…");
+ let bytes, carried = "";
+ if (pick.format === "pdf") {
+ bytes = await buildPdf();
+ } else {
+ const res = await buildDocx();
+ bytes = res.bytes;
+ if (res.carried && res.carried.length) carried = ` — carried over ${res.carried.join(", ")}`;
+ }
+
+ const written = await window.silentmode.invoke("writeChosen", {
+ token: pick.token,
+ base64: DE().read.bytesToBase64(bytes),
+ });
+ const kb = (written.bytes / 1024).toFixed(0);
+
+ if (pick.format === "pdf") {
+ // A PDF is an export, not the document's home: the editor still edits
+ // the .docx, so Save keeps pointing wherever it pointed before.
+ status(`Exported ${written.name} (${kb} KB)`, "ok");
+ return;
+ }
+ savedTarget = await reserveSameTarget(written.path, written.name);
+ docName = written.name;
+ setDirty(false);
+ lastSavedAt = Date.now();
+ await autosave(true);
+ status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
+ } catch (e) {
+ console.error(e);
+ status("Save as failed: " + (e && e.message || e), "err");
+ }
+}
+
+// PDF straight to Downloads — the one-click version of Save as… › .pdf.
+async function exportPdf() {
+ if (!view) return;
+ try {
+ status("Rendering PDF…");
+ const bytes = await buildPdf();
+ const name = downloadName().replace(/\.docx$/i, "") + ".pdf";
+ downloadBytes(bytes, name, "application/pdf");
+ status(`Saved ${name} (${(bytes.length / 1024).toFixed(0)} KB)`, "ok");
+ } catch (e) {
+ console.error(e);
+ status("PDF export failed: " + (e && e.message || e), "err");
+ }
+}
+
// Autosave into the add-on's recent ring — not to the user's file. It exists
// so a closed tab or a crash doesn't cost the session's work: the sidebar
// lists the last few documents and can hand them back.
@@ -951,6 +1133,8 @@ async function boot() {
return;
}
schema = DE().schema.build();
+ installDocCss();
+ syncPageRule();
wireRibbon();
// Whole-window drop target, so dropping a file anywhere works and not
diff --git a/extensions/docx-editor/icon-128.png b/extensions/docx-editor/icon-128.png
new file mode 100644
index 0000000000000000000000000000000000000000..8444ed57f58c03f63410fc8809f471a5fddf341b
GIT binary patch
literal 3937
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
literal 0
HcmV?d00001
diff --git a/extensions/docx-editor/icon-16.png b/extensions/docx-editor/icon-16.png
new file mode 100644
index 0000000000000000000000000000000000000000..557e864425f3d6aa8ec67ccd3250d123d90c681d
GIT binary patch
literal 695
zcmV;o0!aOdP)_k_P4VdK$1$rW&AZ16CR}$VAbWUwu`O
za=oV8LUfy~RBIPNPZ$tANH`8N7`kZ#U9VvoG7bdxTbgoh7Pr*2AC%T4}Iq~4+0h&tmC1aM)ut&&<~$oSp9v|GjjdZ
zUz$7#E=kaV+KN#1IpFu12K{VQkWJ@+g;mg_A#igGD%%Oy`>s$G$e{zZt$?B^n4Qly
zR{_}LaU&QB0=<5ASsVo3X0+O{O6GHbWtBt`u`|cfI*-9DAUNdcLN3~`7v`5ma1=Au
zc_D
n?1RW
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
literal 0
HcmV?d00001
diff --git a/extensions/docx-editor/icon-32.png b/extensions/docx-editor/icon-32.png
new file mode 100644
index 0000000000000000000000000000000000000000..7400ae48b0a21f92934985aab5196850d6ac8517
GIT binary patch
literal 1248
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
Ef?`Y}eE
+ Word editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/docx-editor/index.js b/extensions/docx-editor/index.js
index 82bb65d7..10ef6aad 100644
--- a/extensions/docx-editor/index.js
+++ b/extensions/docx-editor/index.js
@@ -22,17 +22,28 @@ const MAX_BYTES = 64 * 1024 * 1024; // refuse absurd inputs early
module.exports = {
activate(api) {
+ // The dock button takes the panel's icon, the catalogue card takes the
+ // manifest's. Reading it back out of addon.json keeps those the same
+ // drawing — and addon.json's copy is itself generated from icon.svg by
+ // addon-build/docx-editor/make-icons.mjs, so there is exactly one.
+ let icon = "";
+ try { icon = JSON.parse(fs.readFileSync(path.join(api.folder, "addon.json"), "utf8")).icon || ""; }
+ catch (e) { api.log("couldn't read the icon from addon.json:", e?.message); }
+
api.registerSidebarPanel({
id: "main",
title: "Word editor",
+ icon,
page: "panel.html",
});
- // Per-add-on scratch dir under /addons-data/, same arrangement
- // as the screenshot add-on: never write inside the add-on folder itself,
- // where it would confuse anyone reading the shipped source.
+ // Per-extension scratch dir under /extensions-data/, same
+ // arrangement as the screenshot add-on: never write inside the extension
+ // folder itself, where it would confuse anyone reading the shipped source.
+ // api.dataDir is the host-provided folder; the fallback rebuilds it for
+ // hosts that predate that field.
const dataParent = path.dirname(path.join(api.folder, ".."));
- const scratchDir = path.join(dataParent, "addons-data", SCRATCH_DIR);
+ const scratchDir = path.join(api.dataDir || path.join(dataParent, "extensions-data"), SCRATCH_DIR);
try { fs.mkdirSync(scratchDir, { recursive: true }); }
catch (e) { api.log("scratch mkdir failed:", e?.message); }
@@ -175,6 +186,127 @@ module.exports = {
return { ok: true };
});
+ // --- Save as… -------------------------------------------------------
+ //
+ // Two calls rather than one, because the format depends on the extension
+ // the user types into the dialog, and we'd rather not build a .docx and
+ // a .pdf speculatively just to throw one away.
+ //
+ // The renderer never gets to name a path. `pickSavePath` hands back an
+ // opaque token for the path the USER chose in a native dialog, and
+ // `writeChosen` will only write to a path that came out of that dialog.
+ // An extension page is the least trusted thing in this add-on, and
+ // "write these bytes to any path you like" is not a capability it needs.
+ const chosenPaths = new Map(); // token -> absolute path
+ const everChosen = new Set(); // every path a dialog has ever returned
+ let tokenSeq = 0;
+
+ api.onMessage("pickSavePath", async (payload) => {
+ const { dialog } = api.require("electron");
+ const p = payload || {};
+ const suggested = String(p.name || "document.docx").replace(/[/\\]/g, "_");
+ const res = await dialog.showSaveDialog({
+ title: "Save as",
+ defaultPath: path.join(app_downloads(), suggested),
+ filters: [
+ { name: "Word document", extensions: ["docx"] },
+ { name: "PDF document", extensions: ["pdf"] },
+ ],
+ properties: ["createDirectory", "showOverwriteConfirmation"],
+ });
+ if (res.canceled || !res.filePath) return { cancelled: true };
+ const token = `t${++tokenSeq}-${Date.now()}`;
+ chosenPaths.set(token, res.filePath);
+ everChosen.add(res.filePath);
+ // One token, one write; nothing accumulates across a session.
+ if (chosenPaths.size > 8) chosenPaths.delete(chosenPaths.keys().next().value);
+ const ext = path.extname(res.filePath).toLowerCase().replace(".", "") || "docx";
+ api.log(`save-as target chosen (.${ext})`);
+ return { token, format: ext === "pdf" ? "pdf" : "docx", name: path.basename(res.filePath) };
+ });
+
+ // Re-reserve a path the user already picked, so a second Ctrl+S can write
+ // to the same file without a second dialog. Only a path this add-on has
+ // already handed out can be re-reserved — it is not a way to name a new
+ // one from the renderer.
+ api.onMessage("reserveSavePath", (payload) => {
+ const target = String(payload && payload.path || "");
+ if (!everChosen.has(target)) throw new Error("that path was never chosen in a dialog");
+ const token = `t${++tokenSeq}-${Date.now()}`;
+ chosenPaths.set(token, target);
+ return { token, name: path.basename(target) };
+ });
+
+ api.onMessage("writeChosen", (payload) => {
+ const p = payload || {};
+ const target = chosenPaths.get(String(p.token || ""));
+ if (!target) throw new Error("no such save target — pick a location first");
+ chosenPaths.delete(String(p.token));
+ const buf = Buffer.from(String(p.base64 || ""), "base64");
+ if (!buf.length) throw new Error("nothing to write");
+ fs.writeFileSync(target, buf);
+ api.log(`wrote ${buf.length} bytes to a user-chosen path`);
+ return { ok: true, path: target, name: path.basename(target), bytes: buf.length };
+ });
+
+ function app_downloads() {
+ try { return api.require("electron").app.getPath("downloads"); }
+ catch { return scratchDir; }
+ }
+
+ // --- PDF ------------------------------------------------------------
+ //
+ // The editor hands over a self-contained HTML page (its own document
+ // CSS, images already inlined as data URLs) and the page geometry it
+ // read out of the .docx. We load that in a hidden window and let
+ // Chromium's own print pipeline make the PDF — the same engine behind
+ // Ctrl+P, so what the user previewed is what they get.
+ //
+ // A temp file rather than a data: URL: a document with a few photographs
+ // in it runs to megabytes, and long data: URLs get truncated.
+ api.onMessage("renderPdf", async (payload) => {
+ const { BrowserWindow } = api.require("electron");
+ const p = payload || {};
+ const html = String(p.html || "");
+ if (!html) throw new Error("no document to render");
+
+ const tmp = path.join(scratchDir, `print-${Date.now()}.html`);
+ fs.writeFileSync(tmp, html, "utf8");
+ let win = null;
+ try {
+ win = new BrowserWindow({
+ show: false,
+ webPreferences: {
+ // Nothing in the print page needs to run, reach the network or
+ // talk to the add-on — it is markup and inline styles.
+ javascript: false,
+ sandbox: true,
+ contextIsolation: true,
+ nodeIntegration: false,
+ webSecurity: true,
+ },
+ });
+ await win.loadFile(tmp);
+ // loadFile resolves on did-finish-load; give layout a beat to settle
+ // before asking for the PDF, or the first page can come out short.
+ await new Promise((r) => setTimeout(r, 250));
+ const opts = {
+ printBackground: true,
+ // The page box comes from the @page rule the editor wrote out of
+ // the document's own sectPr, so Chromium uses the real paper size
+ // rather than defaulting to Letter.
+ preferCSSPageSize: true,
+ generateTaggedPDF: true,
+ };
+ const pdf = await win.webContents.printToPDF(opts);
+ api.log(`rendered a ${(pdf.length / 1024).toFixed(0)} KB PDF`);
+ return { ok: true, base64: Buffer.from(pdf).toString("base64"), bytes: pdf.length };
+ } finally {
+ try { if (win && !win.isDestroyed()) win.destroy(); } catch {}
+ try { fs.unlinkSync(tmp); } catch {}
+ }
+ });
+
api.onMessage("openFolder", (payload) => {
const { shell } = api.require("electron");
const id = payload && payload.id ? String(payload.id) : "";
diff --git a/extensions/docx-editor/lib/doc-css.js b/extensions/docx-editor/lib/doc-css.js
new file mode 100644
index 00000000..a8459462
--- /dev/null
+++ b/extensions/docx-editor/lib/doc-css.js
@@ -0,0 +1,109 @@
+// How a document looks — the typography of the page itself, as opposed to
+// the editor's furniture around it (that lives in editor.css).
+//
+// It is a string rather than a stylesheet because two different documents
+// need exactly these rules: the editor's own page, and the standalone HTML
+// handed to Chromium when exporting a PDF. Loading a shared .css file into
+// the export isn't possible — it is rendered in a throwaway window from a
+// temp file, with no add-on folder to resolve relative URLs against — and
+// keeping two copies in step by hand is how a PDF starts quietly disagreeing
+// with what the user was looking at.
+//
+// Defaults follow Word's: an 11pt serif body with a little space after each
+// paragraph, so an untouched document looks about the same in both places.
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === "object" && module.exports) module.exports = api;
+ root.DocxEditor = Object.assign(root.DocxEditor || {}, { docCss: api });
+})(typeof globalThis !== "undefined" ? globalThis : this, function () {
+ "use strict";
+
+ const DOC_CSS = `
+.sheet { font: 11pt/1.5 Georgia, "Times New Roman", serif; color: #14161a; }
+.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-note-ref { color: #0563c1; font-size: .7em; padding: 0 1px;
+ border-bottom: 1px dotted #0563c1; }
+
+/* A page break is a labelled dashed rule on screen and a real break on
+ paper. printToPDF renders with print media, so this covers the PDF
+ export and Ctrl+P both. */
+.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: #fff; padding: 0 8px; position: relative;
+ top: -6pt; letter-spacing: .04em; }
+@media print {
+ .sheet .docx-page-break { border: 0; margin: 0; break-after: page; page-break-after: always; }
+ .sheet .docx-page-break span { display: none; }
+ .sheet table, .sheet tr, .sheet img { break-inside: avoid; }
+ .sheet h1, .sheet h2, .sheet h3, .sheet h4, .sheet h5, .sheet h6 { break-after: avoid; }
+}
+`;
+
+ // Twips are Word's unit (1440 to the inch); CSS wants inches. Rounded to
+ // thousandths, which is finer than any printer cares about and keeps the
+ // @page rule readable.
+ const inches = (twips, fallback) => {
+ const n = Number(twips);
+ return (Number.isFinite(n) && n > 0 ? n / 1440 : fallback).toFixed(3);
+ };
+
+ // An @page rule from the document's own section setup, so the PDF comes
+ // out on the paper the author chose instead of Chromium's default Letter.
+ function pageRule(setup) {
+ const page = (setup && setup.page) || {};
+ const size = page.size || {};
+ const m = page.margin || {};
+ const w = inches(size.width, 8.27); // A4 unless the file says otherwise
+ const h = inches(size.height, 11.69);
+ const top = inches(m.top, 1);
+ const right = inches(m.right, 1);
+ const bottom = inches(m.bottom, 1);
+ const left = inches(m.left, 1);
+ return `@page { size: ${w}in ${h}in; margin: ${top}in ${right}in ${bottom}in ${left}in; }`;
+ }
+
+ /**
+ * A standalone HTML page of the document, ready to be handed to Chromium's
+ * print pipeline. Everything is inline: no stylesheet to resolve, no
+ * scripts, and images are already data URLs by the time they reach here.
+ */
+ function printableHtml({ title, bodyHtml, setup }) {
+ const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
+ return `
+${esc(title || "Document")}
+
+