diff --git a/CHANGELOG.md b/CHANGELOG.md
index 516f1d0..874bea4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,23 @@
All notable changes to Jdot Utilities. Dates are YYYY-MM-DD.
+## 1.1.1 — 2026-07-24
+
+- **Word → PDF is now a true 1:1 conversion.** Converting a `.docx` to PDF used
+ to run it through a text-only converter that threw away tables, column widths,
+ cell colours, fonts, and rotated headers — so a form-style document (a shift
+ log, an inspection sheet) came out looking nothing like the original. It now
+ renders the real Word layout, so the PDF matches the document you see in Word:
+ borders, shaded cells, merged cells, sideways labels, coloured text, and the
+ same page breaks. Still fully offline, and it needs no LibreOffice install.
+- **New "PDF colour" option: Color or Black & White.** Leave it on Color to keep
+ the document's original colours, or choose Black & White to convert everything
+ to greyscale for black-and-white printing. (A B&W PDF is a larger file because
+ the page is flattened to greyscale — that's expected.) The option also applies
+ to Markdown/HTML/text → PDF.
+- If a particular `.docx` can't be rendered the new way, the converter quietly
+ falls back to the old path so the conversion still succeeds.
+
## 1.1.0 — 2026-07-24
- **Drag to reorder** in Merge PDFs and Images → PDF. Grab a file by the grip and
diff --git a/CLAUDE.md b/CLAUDE.md
index f6b2bf6..8bf3968 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,7 +28,7 @@ Two layers, because the PDF path needs Chromium:
`pdfshrink`, `bmp`/`ico`, `engines`, the registry (incl. kinds/validation),
and every non-PDF `document-convert` path. Runs in plain node.
- Electron-hosted smoke tests (run directly): `npx electron test/electron-pdf.js`
- (PDF output, pooled windows, ``, orientation),
+ (PDF output, pooled windows, ``, orientation, Word→PDF color+B&W),
`npx electron test/electron-ops.js` (collect/explode via the discovered tools),
and `npx electron test/electron-offline.js` (the render pool makes no remote
requests). `npm run test:pdf` runs the first. **Anything touching PDF output or
@@ -82,7 +82,7 @@ src/
ops.js Runners for collect (N->1) and explode (1->N) kinds
pagespec.js Parse "1-3,5,8-" page ranges (shared by split/delete/extract)
htmlutil.js HTML normalization: strip ";
+
+function withGrayscale(fullHtml, grayscale) {
+ if (!grayscale) return fullHtml;
+ // wrapDocument always emits a ; inject there so the filter is in place
+ // before first paint. Fall back to a prefix if a caller passed a bare fragment.
+ return fullHtml.includes("")
+ ? fullHtml.replace("", GRAYSCALE_STYLE + "")
+ : GRAYSCALE_STYLE + fullHtml;
}
/**
@@ -113,11 +151,15 @@ function release(win) {
* That lets the intermediate live in the temp dir instead of being written into
* the user's own folders.
*/
-async function renderPdf(fullHtml, outputPath, { pageSize = "Letter", landscape = false } = {}) {
+async function renderPdf(
+ fullHtml,
+ outputPath,
+ { pageSize = "Letter", landscape = false, grayscale = false } = {}
+) {
const tmp = path.join(os.tmpdir(), `jdot-render-${crypto.randomUUID()}.html`);
- await fs.promises.writeFile(tmp, fullHtml, "utf8");
+ await fs.promises.writeFile(tmp, withGrayscale(fullHtml, grayscale), "utf8");
- const win = await acquire();
+ const win = await htmlPool.acquire();
try {
await win.loadFile(tmp);
const pdf = await win.webContents.printToPDF({
@@ -141,18 +183,156 @@ async function renderPdf(fullHtml, outputPath, { pageSize = "Letter", landscape
} catch {
// Destroyed or already navigating away; release() handles a dead window.
}
- release(win);
+ htmlPool.release(win);
+ fs.promises.unlink(tmp).catch(() => {});
+ }
+}
+
+// ── High-fidelity Word (.docx) -> PDF ────────────────────────────────────────
+//
+// mammoth (used for docx -> md/txt/html) throws away all styling: table borders,
+// column widths, cell fills, fonts, rotated text. For a form-like document that
+// collapses the whole layout, so docx -> pdf produced something that looked
+// nothing like Word. This path instead renders the .docx faithfully with
+// docx-preview (pure JS, reads the OOXML directly) inside an offscreen Chromium
+// window, then prints that to PDF — a genuine 1:1 conversion, still fully offline.
+//
+// It runs in docxPool — JavaScript-enabled, unlike htmlPool (which stays
+// javascript:false to run untrusted converted HTML). Only our two bundled
+// libraries and a fixed bootstrap run here, and the render session refuses every
+// non-local request, so enabling scripts changes nothing about the offline
+// guarantee. Each render loads a fresh full page (libs + bootstrap), so a reused
+// window is fully replaced; the window is parked on about:blank before release,
+// the same discipline renderPdf's pool needs to avoid the reuse crash.
+
+let libCache = null;
+// Read the browser builds of docx-preview + jszip once and inline them into the
+// render page. docx-preview's package `exports` hides the dist subpath, so its
+// entry is resolved and the sibling .min.js read from disk. fs reads work inside
+// the packaged asar, so nothing here needs asarUnpack.
+function docxLibs() {
+ if (libCache) return libCache;
+ const jszipPath = require.resolve("jszip/dist/jszip.min.js");
+ const dpEntry = require.resolve("docx-preview"); // .../dist/docx-preview.js
+ const dpMin = path.join(path.dirname(dpEntry), "docx-preview.min.js");
+ // A inside a library's own string literals would otherwise close the
+ // inline
+
+
+