// Word editor — the add-on half. All the document work happens in the
// editor tab; this side owns the sidebar panel, the scratch folder and the
// ring of recently-opened documents.
//
// Flow:
// panel picks a file (, or a drop) → invokes "stash" with
// the bytes → we write them to the scratch dir and push them onto the
// recent ring → panel invokes "openEditor" → we park a pointer under
// storage.__pending and openTab("editor.html") → the editor drains
// __pending on its first paint and pulls the bytes with "getBytes".
//
// Only a pointer goes through storage, never the document: add-on storage is
// a single JSON file rewritten in full on every set, and a few megabytes of
// base64 in there would make every unrelated write expensive.
const fs = require("node:fs");
const path = require("node:path");
const MAX_RECENT = 8; // documents kept in the ring
const SCRATCH_DIR = "docx-scratch";
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-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(api.dataDir || path.join(dataParent, "extensions-data"), SCRATCH_DIR);
try { fs.mkdirSync(scratchDir, { recursive: true }); }
catch (e) { api.log("scratch mkdir failed:", e?.message); }
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
// A scratch filename that can't escape the folder however the original
// was named. The display name is kept separately in the ring.
function scratchName(displayName) {
const base = String(displayName || "document")
.replace(/\.docx$/i, "")
.replace(/[^\w.\- ]+/g, "_")
.replace(/\s+/g, " ")
.trim()
.slice(0, 60) || "document";
return `${stamp()}-${base}.docx`;
}
function pruneRecent(recent) {
const alive = recent.filter((r) => { try { fs.accessSync(r.path); return true; } catch { return false; } });
const keep = alive.slice(0, MAX_RECENT);
for (const gone of alive.slice(MAX_RECENT)) { try { fs.unlinkSync(gone.path); } catch {} }
return keep;
}
function readRecent() {
let recent = api.storage.get("recent", []);
return Array.isArray(recent) ? recent : [];
}
function findRecent(id) {
return readRecent().find((r) => r.id === id) || null;
}
function pathOf(id) {
// Belt and braces: the id came from a renderer, so re-derive the path
// from the ring rather than joining whatever string arrived.
const hit = findRecent(id);
if (!hit) throw new Error(`no document "${id}" in the recent list`);
const abs = path.resolve(hit.path);
if (path.dirname(abs) !== path.resolve(scratchDir)) {
throw new Error("document path escapes the scratch folder");
}
return abs;
}
// Write bytes to the scratch folder and put them at the head of the ring.
// `kind` distinguishes what the user opened from what the editor saved
// back, so the panel can say which is which.
function stash({ name, base64, kind, replaces }) {
const buf = Buffer.from(String(base64 || ""), "base64");
if (!buf.length) throw new Error("no document bytes");
if (buf.length > MAX_BYTES) throw new Error(`document is too large (${(buf.length / 1048576).toFixed(0)} MB)`);
// Every .docx is a zip; catching this here beats a confusing parse
// error three layers deeper in the editor.
if (!(buf[0] === 0x50 && buf[1] === 0x4b)) {
throw new Error("that doesn't look like a .docx file");
}
const id = scratchName(name);
const file = path.join(scratchDir, id);
fs.writeFileSync(file, buf);
let recent = readRecent();
if (replaces) recent = recent.filter((r) => r.id !== replaces);
recent.unshift({
id,
name: String(name || "document.docx"),
path: file,
bytes: buf.length,
at: Date.now(),
kind: kind === "saved" ? "saved" : "opened",
});
recent = pruneRecent(recent);
api.storage.set("recent", recent);
api.log(`stashed ${id} (${buf.length} bytes, ${kind || "opened"})`);
return { id, name: String(name || "document.docx"), bytes: buf.length };
}
api.onMessage("stash", (payload) => stash(payload || {}));
// --- handing a document to the editor -------------------------------
//
// The editor holds several documents at once, so a second file should
// become a tab inside the editor that is already open rather than a
// second browser tab full of ribbon. There is no way to ask "is my page
// still alive?", so the page says so itself: it announces on load and
// acknowledges each document it accepts. If no acknowledgement arrives
// the editor is gone (crashed, closed, reloaded) and we open a tab as
// before — the failure mode is the old behaviour, not a lost document.
let editorLive = false;
const acks = new Map(); // document id -> resolve()
api.onMessage("editorHello", () => { editorLive = true; api.log("an editor tab is open"); return { ok: true }; });
api.onMessage("editorBye", () => { editorLive = false; api.log("the editor tab is gone"); return { ok: true }; });
api.onMessage("docOpened", (payload) => {
const id = String(payload && payload.id || "");
const done = acks.get(id);
if (done) { acks.delete(id); done(); }
return { ok: true };
});
function handOff(id, name) {
return new Promise((resolve) => {
const timer = setTimeout(() => { acks.delete(id); resolve(false); }, 900);
acks.set(id, () => { clearTimeout(timer); resolve(true); });
api.emit("open-doc", { id, name, at: Date.now() });
});
}
api.onMessage("openEditor", async (payload) => {
const id = payload && payload.id ? String(payload.id) : "";
const hit = id ? findRecent(id) : null;
if (id && !hit) throw new Error(`no document "${id}" in the recent list`);
if (editorLive && id) {
if (await handOff(id, hit.name)) {
api.log(`handed ${id} to the open editor`);
return { ok: true, id, reused: true };
}
editorLive = false; // it didn't answer; assume it's gone
api.log("the open editor didn't answer — opening a new tab");
}
// The pointer under __pending is what a fresh editor drains on its
// first paint; the query string carries the same id so a reload of the
// tab still finds its document.
api.storage.set("__pending", id ? { id, name: hit.name, at: Date.now() } : null);
api.openTab("editor.html", id ? { query: { doc: id } } : undefined);
api.log(id ? `opening editor for ${id}` : "opening editor with a blank document");
return { ok: true, id, reused: false };
});
// "Open in default app" from a document tab's menu — the scratch copy is
// what the user opened (or the last autosave of it), which is the nearest
// thing to "the file" that this add-on has.
api.onMessage("openExternally", (payload) => {
const { shell } = api.require("electron");
const id = String(payload && payload.id || "");
const hit = findRecent(id);
if (!hit) throw new Error("that document is no longer in the recent list");
shell.openPath(pathOf(id));
return { ok: true, name: hit.name };
});
api.onMessage("listRecent", () => {
const recent = pruneRecent(readRecent());
api.storage.set("recent", recent);
return recent.map((r) => ({ id: r.id, name: r.name, bytes: r.bytes, at: r.at, kind: r.kind }));
});
api.onMessage("getBytes", (payload) => {
const id = String(payload && payload.id || "");
const hit = findRecent(id);
if (!hit) throw new Error(`no document "${id}" in the recent list`);
const buf = fs.readFileSync(pathOf(id));
return { id, name: hit.name, base64: buf.toString("base64"), bytes: buf.length, at: hit.at, kind: hit.kind };
});
// Autosave. The editor calls this as the user works; each document keeps
// one autosave entry rather than filling the ring with its own history.
api.onMessage("autosave", (payload) => {
const p = payload || {};
const res = stash({
name: String(p.name || "document.docx"),
base64: p.base64,
kind: "saved",
replaces: p.replaces ? String(p.replaces) : "",
});
return res;
});
api.onMessage("clearRecent", (payload) => {
const id = payload && payload.id ? String(payload.id) : "";
let recent = readRecent();
if (id) {
const hit = recent.find((r) => r.id === id);
if (hit) { try { fs.unlinkSync(hit.path); } catch {} }
recent = recent.filter((r) => r.id !== id);
} else {
for (const r of recent) { try { fs.unlinkSync(r.path); } catch {} }
recent = [];
}
api.storage.set("recent", recent);
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.
// The bundled webfonts, as @font-face rules with the woff2 inlined.
// The print page is loaded from a temp folder, so a relative url() would
// resolve to nothing there — and a PDF whose text falls back to a
// different typeface is exactly the drift the shared stylesheet was
// meant to prevent. Read once, kept for the session.
let inlinedFontCss = null;
function fontCss() {
if (inlinedFontCss !== null) return inlinedFontCss;
try {
const css = fs.readFileSync(path.join(api.folder, "fonts.css"), "utf8");
inlinedFontCss = css.replace(/url\('fonts\/([^']+)'\)/g, (whole, file) => {
try {
const b64 = fs.readFileSync(path.join(api.folder, "fonts", file)).toString("base64");
return `url('data:font/woff2;base64,${b64}')`;
} catch { return whole; }
});
} catch (e) {
api.log("no bundled fonts to inline:", e?.message);
inlinedFontCss = "";
}
return inlinedFontCss;
}
api.onMessage("fontCss", () => ({ css: fontCss() }));
api.onMessage("renderPdf", async (payload) => {
const { BrowserWindow } = api.require("electron");
const p = payload || {};
let html = String(p.html || "");
if (!html) throw new Error("no document to render");
const fonts = fontCss();
if (fonts) html = html.replace("", ``);
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) : "";
if (id) {
const hit = findRecent(id);
if (hit) { shell.showItemInFolder(hit.path); return { ok: true, path: hit.path }; }
}
shell.openPath(scratchDir);
return { ok: true, path: scratchDir };
});
api.log("registered docx-editor sidebar panel");
},
};