// 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"); }, };