feat(docx-editor): Save as…, PDF export, and a mark of our own

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.
This commit is contained in:
Local Dev 2026-09-21 03:35:55 +02:00
parent adcbf3dd21
commit e6c9f8f5c9
16 changed files with 648 additions and 77 deletions

View file

@ -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`), `<!doctype html><meta charset="utf-8">
<style>html,body{margin:0;padding:0;background:transparent}
svg{width:${size}px;height:${size}px;display:block}</style>${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(/<!--[\s\S]*?-->/g, "")
.replace(/<title>[\s\S]*?<\/title>/g, "")
.replace(/\s+/g, " ")
.replace(/>\s+</g, "><")
.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");
}

View file

@ -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",

View file

@ -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

View file

@ -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"
}

View file

@ -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. */
}

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<title>Word editor</title>
<link rel="icon" href="icon.svg">
<link rel="stylesheet" href="editor.css">
</head>
<body>
@ -20,6 +21,14 @@
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13.5h11"/></svg>
<span>Save</span>
</button>
<button class="btn wide" id="file-saveas" title="Save as… — choose a name, a folder, and .docx or .pdf (Ctrl+Shift+S)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 3.5h7l4 4v6.5a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-9.5a1 1 0 0 1 1-1z"/><path d="M5 3.5v3.5h5"/><path d="M5 14.5v-4h6v4"/></svg>
<span>Save as…</span>
</button>
<button class="btn wide" id="file-pdf" title="Save a PDF to your downloads">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M4 1.5h5l3 3v10H4z"/><path d="M9 1.5v3h3"/></svg>
<span>PDF</span>
</button>
<button class="btn" id="file-print" title="Print (Ctrl+P)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M4.5 6V2.5h7V6"/><rect x="2.5" y="6" width="11" height="5"/><path d="M4.5 11v2.5h7V11"/></svg>
</button>
@ -201,6 +210,7 @@
<a id="download-link" style="display:none"></a>
<script src="vendor/docx-vendor.js"></script>
<script src="lib/doc-css.js"></script>
<script src="lib/pkg.js"></script>
<script src="lib/schema.js"></script>
<script src="lib/read.js"></script>

View file

@ -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 → <a download>. 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() {
<p>Saving rebuilds the document body from what you see, and carries the rest of the
original file across untouched: headers, footers, footnotes, endnotes, page size and
margins, the document's style catalogue and its theme. Saving never overwrites the
file you opened — it downloads a new one.</p>
margins, the document's style catalogue and its theme.</p>
<p><b>Save</b> puts a copy in your downloads until you have given the document a home
with <b>Save as…</b>, which is a real file dialog — type a <code>.docx</code> name for a
Word document or a <code>.pdf</code> one for a PDF. After that, Save writes there
directly. <b>PDF</b> 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.</p>
${found.length ? `<h3>In this document</h3><ul>${found.map(line).join("")}</ul>` : ""}
@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Word editor">
<title>Word editor</title>
<!--
Silent Mode's own mark for the Word editor.
It is deliberately NOT Microsoft's Word icon. That icon — and the blue
sheet with a white "W" that goes with it — is Microsoft's trademark, and
borrowing it to make our extension look official is exactly the kind of
thing a browser that talks about sovereignty should not do.
What it does instead is say "text document" in the only vocabulary that
survives 16 pixels: a portrait page with a turned corner, a coloured
heading rule, a few lines of body text, and a pilcrow — the paragraph
mark every word processor has used since the trade was done on paper.
The accent is Silent Mode's green, not Office blue.
-->
<defs>
<linearGradient id="dx-page" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#e9edf4"/>
</linearGradient>
</defs>
<!-- the sheet, with the top-right corner turned down -->
<path d="M6.5 2.5h12l7 7v20a1 1 0 0 1-1 1h-18a1 1 0 0 1-1-1v-26a1 1 0 0 1 1-1z"
fill="url(#dx-page)" stroke="#2a3242" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M18.5 2.5v7h7" fill="#cfd8e6" stroke="#2a3242" stroke-width="1.5" stroke-linejoin="round"/>
<!-- heading rule, then body lines -->
<rect x="9.5" y="13" width="9" height="2.2" rx="1.1" fill="#0ac18e"/>
<rect x="9.5" y="17.6" width="13" height="1.6" rx=".8" fill="#97a3b6"/>
<rect x="9.5" y="21" width="13" height="1.6" rx=".8" fill="#97a3b6"/>
<rect x="9.5" y="24.4" width="8.5" height="1.6" rx=".8" fill="#97a3b6"/>
<!-- pilcrow, bottom-right, overlapping the page edge so it reads as a mark
rather than as more body text -->
<g transform="translate(20.4 19.2)">
<circle cx="4.6" cy="4.6" r="5.4" fill="#0ac18e"/>
<path d="M6.6 1.9H3.9a1.85 1.85 0 0 0 0 3.7h.8M5.3 1.9v5.6M6.6 1.9v5.6"
fill="none" stroke="#0a0f16" stroke-width="1.15" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -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 <userData>/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 <userData>/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) : "";

View file

@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
return `<!doctype html>
<html><head><meta charset="utf-8"><title>${esc(title || "Document")}</title>
<style>
${pageRule(setup)}
html, body { margin: 0; padding: 0; background: #fff; }
${DOC_CSS}
/* The page box is the paper; the sheet must not add a second margin. */
.sheet { max-width: none; margin: 0; padding: 0; background: #fff; box-shadow: none; }
</style></head>
<body><div class="sheet">${bodyHtml}</div></body></html>`;
}
return { DOC_CSS, printableHtml, pageRule };
});

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<title>Word editor</title>
<link rel="icon" href="icon.svg">
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
@ -19,7 +20,7 @@ body { background: var(--bg); color: var(--ink);
h1 { font-size: 13px; font-weight: 600; margin: 0; }
.head { display: flex; align-items: center; gap: 8px; padding: 10px 12px;
border-bottom: 1px solid var(--line); background: var(--panel); }
.head .ico { font-size: 15px; }
.head .ico { width: 18px; height: 18px; display: block; }
.body { flex: 1; overflow: auto; padding: 12px; }
.lede { color: var(--mut); font-size: 12px; margin: 0 0 12px; }
.btn { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
@ -160,7 +161,9 @@ async function refresh() {
try { await SM().invoke("clearRecent", { id: d.id }); await refresh(); }
catch (err) { say("Couldn't remove it.", true); }
});
row.append(document.createTextNode("📄"), meta, x);
const thumb = document.createElement("img");
thumb.src = "icon.svg"; thumb.width = 16; thumb.height = 16; thumb.alt = "";
row.append(thumb, meta, x);
row.addEventListener("click", () => openEditor(d.id));
host.append(row);
}