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+</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"); +} 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 @@ <head> <meta charset="utf-8"> <title>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() {

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.

+ margins, the document's style catalogue and its theme.

+ +

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

` : ""} @@ -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 00000000..8444ed57 Binary files /dev/null and b/extensions/docx-editor/icon-128.png differ diff --git a/extensions/docx-editor/icon-16.png b/extensions/docx-editor/icon-16.png new file mode 100644 index 00000000..557e8644 Binary files /dev/null and b/extensions/docx-editor/icon-16.png differ diff --git a/extensions/docx-editor/icon-256.png b/extensions/docx-editor/icon-256.png new file mode 100644 index 00000000..1e0dd46a Binary files /dev/null and b/extensions/docx-editor/icon-256.png differ diff --git a/extensions/docx-editor/icon-32.png b/extensions/docx-editor/icon-32.png new file mode 100644 index 00000000..7400ae48 Binary files /dev/null and b/extensions/docx-editor/icon-32.png differ diff --git a/extensions/docx-editor/icon-48.png b/extensions/docx-editor/icon-48.png new file mode 100644 index 00000000..ead429dd Binary files /dev/null and b/extensions/docx-editor/icon-48.png differ diff --git a/extensions/docx-editor/icon.svg b/extensions/docx-editor/icon.svg new file mode 100644 index 00000000..410e3188 --- /dev/null +++ b/extensions/docx-editor/icon.svg @@ -0,0 +1,42 @@ + + 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")} + +
${bodyHtml}
`; + } + + return { DOC_CSS, printableHtml, pageRule }; +}); diff --git a/extensions/docx-editor/panel.html b/extensions/docx-editor/panel.html index df5fcc2a..0785dd08 100644 --- a/extensions/docx-editor/panel.html +++ b/extensions/docx-editor/panel.html @@ -3,6 +3,7 @@ Word editor +