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 + +
+`; +} + +/** + * Render a Word (.docx) file to a PDF file with full visual fidelity. + * `grayscale: true` produces a Black & White PDF. + */ +async function renderDocxPdf(inputPath, outputPath, { grayscale = false } = {}) { + const buf = await fs.promises.readFile(inputPath); + const { widthIn, heightIn } = await docxPageSize(buf); + const { jszip, docx } = docxLibs(); + + const html = docxRenderPage({ + jszip, + docx, + docxBase64: buf.toString("base64"), + widthIn, + heightIn, + grayscale, + }); + const tmp = path.join(os.tmpdir(), `jdot-docx-${crypto.randomUUID()}.html`); + await fs.promises.writeFile(tmp, html, "utf8"); + + const win = await docxPool.acquire(); + try { + await win.loadFile(tmp); + // renderAsync resolves once the document is laid out; await it in the page. + await win.webContents.executeJavaScript("window.__renderDocx", true); + const pdf = await win.webContents.printToPDF({ + printBackground: true, + preferCSSPageSize: true, // honour the @page size derived from the .docx + margins: { marginType: "none" }, + }); + await fs.promises.writeFile(outputPath, pdf); + } finally { + // Park on about:blank (awaited) before release — same reuse discipline as + // renderPdf. Releasing with the navigation in flight races the next load. + try { + await win.loadURL("about:blank"); + } catch { + // Destroyed or already navigating; release() handles a dead window. + } + docxPool.release(win); fs.promises.unlink(tmp).catch(() => {}); } } // Called on app quit so hidden windows don't keep the process alive. function shutdown() { - shuttingDown = true; - for (const win of idle.splice(0)) { - try { win.destroy(); } catch {} - } - created = 0; + htmlPool.shutdown(); + docxPool.shutdown(); } -module.exports = { renderPdf, shutdown, POOL_SIZE, RENDER_PARTITION }; +module.exports = { renderPdf, renderDocxPdf, parseDocxPageSize, shutdown, POOL_SIZE, RENDER_PARTITION }; diff --git a/src/tools/document-convert.js b/src/tools/document-convert.js index 6dcdae0..7da99cd 100644 --- a/src/tools/document-convert.js +++ b/src/tools/document-convert.js @@ -69,6 +69,17 @@ module.exports = { type: "boolean", default: false, }, + { + key: "colorMode", + label: "PDF colour", + type: "select", + choices: ["Color", "Black & White"], + default: "Color", + choiceHelp: { + Color: "Keep the document's original colours.", + "Black & White": "Convert every colour to greyscale (for B&W printing).", + }, + }, ], async convert({ inputPath, outputPath, outputFormat, options, onProgress }) { @@ -78,8 +89,28 @@ module.exports = { if (!OUTPUTS.includes(to)) throw new Error(`Unsupported output: .${to}`); const baseDir = path.dirname(path.resolve(inputPath)); + const grayscale = options?.colorMode === "Black & White"; onProgress?.(0.15); + // Word -> PDF gets a dedicated high-fidelity path. mammoth (used for the + // HTML/text targets below) discards all layout — table borders, column + // widths, cell fills, rotated headers — which for a form-style document + // looks nothing like the original. renderDocxPdf renders the real OOXML, so + // it is a true 1:1 conversion. If it can't parse a given file we fall through + // to the mammoth intermediate so the conversion still succeeds. + if (from === "docx" && to === "pdf") { + try { + const { renderDocxPdf } = require("../main/pdfrender"); + await renderDocxPdf(inputPath, outputPath, { grayscale }); + onProgress?.(1); + return; + } catch { + // docx-preview couldn't handle this file — fall through to the mammoth + // intermediate below so the conversion still produces a PDF. + onProgress?.(0.5); + } + } + // ── 1) Normalize the input to an HTML body fragment ────────────── let body; let title = null; @@ -134,6 +165,7 @@ module.exports = { await renderPdf(htmlutil.wrapDocument(body, { baseDir, title }), outputPath, { pageSize: options?.pageSize, landscape: Boolean(options?.landscape), + grayscale, }); } onProgress?.(1); diff --git a/test/electron-pdf.js b/test/electron-pdf.js index 5565ea3..eeba2b8 100644 --- a/test/electron-pdf.js +++ b/test/electron-pdf.js @@ -90,6 +90,44 @@ app.whenReady().then(async () => { ); }); + await check("docx -> pdf renders the real document (color + B&W)", async () => { + // Build a .docx fixture at test time (html-to-docx is already a dependency), + // with a coloured table cell so the greyscale option is observable. This + // exercises the docx-preview render path end to end in Electron. + const htmlToDocx = require("html-to-docx"); + const d = work(); + const src = path.join(d, "form.docx"); + const html = + "" + + "

Shift Log

" + + "" + + "
Red headerValue
Green headerOther
" + + ""; + const buf = await htmlToDocx(html); + fs.writeFileSync(src, Buffer.isBuffer(buf) ? buf : Buffer.from(await buf.arrayBuffer())); + + const color = path.join(d, "color.pdf"); + const bw = path.join(d, "bw.pdf"); + await tool.convert({ inputPath: src, outputPath: color, outputFormat: "pdf", options: {} }); + await tool.convert({ + inputPath: src, outputPath: bw, outputFormat: "pdf", + options: { colorMode: "Black & White" }, + }); + + assert.ok(isPdf(color), "color output is not a PDF"); + assert.ok(isPdf(bw), "B&W output is not a PDF"); + // A faithful render of a table with fills is far larger than mammoth's + // text-only ~1KB output; a tiny file means the render path silently no-op'd. + assert.ok(fs.statSync(color).size > 3000, "color PDF too small: " + fs.statSync(color).size); + // The greyscale filter changes the rendered pixels, so the two PDFs differ. + assert.notStrictEqual( + fs.readFileSync(color).length === fs.readFileSync(bw).length && + Buffer.compare(fs.readFileSync(color), fs.readFileSync(bw)) === 0, + true, + "color and B&W output are byte-identical — grayscale had no effect" + ); + }); + await check("pageSize and landscape reach printToPDF", async () => { const d = work(); const src = path.join(d, "a.md"); diff --git a/test/pdfrender.test.js b/test/pdfrender.test.js new file mode 100644 index 0000000..b35e103 --- /dev/null +++ b/test/pdfrender.test.js @@ -0,0 +1,42 @@ +// Pure-logic tests for pdfrender helpers that do NOT need Electron/Chromium. +// The rendering itself (docx -> pdf via docx-preview, grayscale filter) needs a +// browser and is covered by test/electron-pdf.js. +// +// parseDocxPageSize decides the PDF page geometry for Word -> PDF, so a wrong +// value silently clips content or paginates differently from Word — worth +// pinning even though the render can't run here. + +const test = require("node:test"); +const assert = require("node:assert"); + +// Safe to require under plain node: pdfrender only reaches for electron/jszip +// inside its render functions, never at module load. +const { parseDocxPageSize } = require("../src/main/pdfrender"); + +test("parseDocxPageSize reads Letter geometry from pgSz twips", () => { + const xml = ''; + assert.deepStrictEqual(parseDocxPageSize(xml), { widthIn: 8.5, heightIn: 11 }); +}); + +test("parseDocxPageSize reads A4 geometry", () => { + // A4 is 11906 x 16838 twips. + const { widthIn, heightIn } = parseDocxPageSize(''); + assert.ok(Math.abs(widthIn - 8.27) < 0.01, `width ${widthIn}`); + assert.ok(Math.abs(heightIn - 11.69) < 0.01, `height ${heightIn}`); +}); + +test("parseDocxPageSize honours physical dimensions for landscape (w > h)", () => { + // Word bakes orientation into w:w / w:h, so a landscape Letter is 15840 x 12240. + const { widthIn, heightIn } = parseDocxPageSize( + '' + ); + assert.deepStrictEqual({ widthIn, heightIn }, { widthIn: 11, heightIn: 8.5 }); +}); + +test("parseDocxPageSize falls back to Letter when pgSz is missing or malformed", () => { + const letter = { widthIn: 8.5, heightIn: 11 }; + assert.deepStrictEqual(parseDocxPageSize(""), letter); + assert.deepStrictEqual(parseDocxPageSize(""), letter); + assert.deepStrictEqual(parseDocxPageSize(''), letter); + assert.deepStrictEqual(parseDocxPageSize(null), letter); +});