/** * The PDF, checked geometrically rather than by eye. * * With 26 fields selected the old writer gave every column 1/26th of A4 * landscape — 31pt — while a header like "Originator Reference" needs 69pt at * that size, so labels printed straight over their neighbours and values * truncated to junk. That's a measurable defect: parse the text-drawing * operators out of the generated PDF, measure each string with the same font * metrics jsPDF used, and assert nothing overlaps the column to its right. * * Run: npm i jsdom jspdf && node build/test-pdf.js */ const fs = require("fs"); const os = require("os"); const path = require("path"); const zlib = require("zlib"); const assert = require("assert"); const { JSDOM, VirtualConsole } = require("jsdom"); const { jsPDF } = require("jspdf"); const { build } = require("./make-sample.js"); const INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html"); const results = []; function check(name, fn) { try { fn(); results.push(["PASS", name]); } catch (e) { results.push(["FAIL", name + " — " + e.message]); } } function waitFor(fn, label, timeout = 20000) { return new Promise((resolve, reject) => { const started = Date.now(); (function poll() { let value; try { value = fn(); } catch (e) { return reject(e); } if (value) return resolve(value); if (Date.now() - started > timeout) return reject(new Error("timed out waiting for " + label)); setTimeout(poll, 40); })(); }); } /* ------------------------------------------------------------------ */ /* Reading a PDF back */ /* ------------------------------------------------------------------ */ function pageWidthOf(bytes) { const match = /\/MediaBox\s*\[([^\]]*)\]/.exec(bytes.toString("latin1")); assert(match, "no MediaBox in the PDF"); return parseFloat(match[1].trim().split(/\s+/)[2]); } /** Every text-drawing op on the first page, with its font size and position. */ function textOps(bytes) { const raw = bytes.toString("latin1"); const streams = []; const re = /stream\r?\n([\s\S]*?)endstream/g; let m; while ((m = re.exec(raw)) !== null) { const body = Buffer.from(m[1], "latin1"); try { streams.push(zlib.inflateSync(body).toString("latin1")); } catch (e) { streams.push(m[1]); } } const ops = []; streams.forEach((content) => { const opRe = /BT\s*\/F\d+\s+([\d.]+)\s+Tf[\s\S]*?([\d.]+)\s+([\d.]+)\s+Td\s*\((.*?)\)\s*Tj/g; let op; while ((op = opRe.exec(content)) !== null) { ops.push({ size: parseFloat(op[1]), x: parseFloat(op[2]), y: parseFloat(op[3]), text: op[4].replace(/\\([()\\])/g, "$1"), }); } }); return ops; } /** The header row: the widest band of ops sharing a y, below the title. */ function headerRow(ops) { const byY = new Map(); ops.forEach((op) => { if (op.size > 10) return; // title and its metadata line byY.set(op.y, (byY.get(op.y) || []).concat(op)); }); let best = []; byY.forEach((row) => { if (row.length > best.length) best = row; }); return best.slice().sort((a, b) => a.x - b.x); } function measure(text, size, bold) { const probe = new jsPDF({ orientation: "landscape", unit: "pt", format: "a4" }); probe.setFont("helvetica", bold ? "bold" : "normal"); probe.setFontSize(size); return probe.getTextWidth(text); } /* ------------------------------------------------------------------ */ /* Driving a real export */ /* ------------------------------------------------------------------ */ async function exportWith(fieldKeys, details) { const pdfBytes = { value: null }; const consoleErrors = []; const virtualConsole = new VirtualConsole(); virtualConsole.on("jsdomError", (e) => consoleErrors.push(e.message)); const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), { runScripts: "dangerously", url: "http://tauri.localhost/", pretendToBeVisual: true, virtualConsole, beforeParse(win) { const ctxStub = new Proxy({}, { get: (target, prop) => (prop === "canvas" ? null : () => {}), set: () => true, }); win.HTMLCanvasElement.prototype.getContext = () => ctxStub; win.URL.createObjectURL = () => "blob:stub"; win.URL.revokeObjectURL = () => {}; }, }); const { window } = dom; await new Promise((r) => window.addEventListener("load", r)); const doc = window.document; // jsPDF writes via an anchor; intercept at the instance instead. const RealJsPDF = window.jspdf.jsPDF; window.jspdf.jsPDF = function (options) { const instance = new RealJsPDF(options); instance.save = function () { pdfBytes.value = Buffer.from(new Uint8Array(this.output("arraybuffer"))); return this; }; return instance; }; Object.keys(RealJsPDF).forEach((key) => { window.jspdf.jsPDF[key] = RealJsPDF[key]; }); const files = [ ["A001_12A_T1.wav", { scene: "12A", take: 1, // A note with a line break in it, and the multi-line coding history // EBU 3285 actually describes: both used to be drawn as extra lines // straight through the rows below. note: "boom a little hot on the wide,\r\nwatch it on the close", codingHistory: "A=PCM,F=48000,W=24,M=stereo,T=833\r\n" + "A=ANALOGUE,M=stereo,T=Schoeps CMIT 5U\r\n", }], ["A002_12A_T2.wav", { scene: "12A", take: 2, note: "plane overhead from 00:12" }], ["A003_14B_T3.wav", { scene: "14B", take: 3, circled: true, description: "aSPEED=025.000-ND" }], ].map(([name, opts]) => { const bytes = build(opts); const view = new window.Uint8Array(bytes.length); view.set(bytes); return new window.File([view], name, { type: "audio/wav" }); }); const input = doc.querySelector("[data-bwfa-files-input]"); files.item = (i) => files[i]; Object.defineProperty(input, "files", { value: files, configurable: true }); input.dispatchEvent(new window.Event("change", { bubbles: true })); await waitFor(() => /Done/i.test(doc.querySelector("[data-bwfa-status]").textContent), "parse"); // Set the export selection through the picker, as a user would. doc.querySelector("[data-bwfa-export-fields-toggle]") .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); Array.from(doc.querySelectorAll("[data-bwfa-export-field]")).forEach((box) => { const wanted = fieldKeys === "all" || fieldKeys.includes(box.getAttribute("data-bwfa-export-field")); if (box.checked !== wanted) { box.checked = wanted; box.dispatchEvent(new window.Event("change", { bubbles: true })); } }); doc.querySelector("[data-bwfa-export-close]") .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); if (details) { // Through the report modal, the way a user gets here: fill in the // production details, leave the format on PDF, press Create Report. doc.querySelector("[data-bwfa-report-open]") .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); Object.keys(details).forEach((key) => { const input = doc.querySelector('[data-bwfa-report-field="' + key + '"]'); assert(input, "no report field called " + key); input.value = details[key]; }); doc.querySelector("[data-bwfa-report-format]").value = "pdf"; doc.querySelector("[data-bwfa-report-create]") .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); } else { doc.querySelector("[data-bwfa-export-pdf]") .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); } await waitFor(() => pdfBytes.value, "pdf"); assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | ")); window.close(); return pdfBytes.value; } /* ------------------------------------------------------------------ */ (async () => { const everything = await exportWith("all"); const withDetails = await exportWith( ["fileName", "scene", "take", "startTimecode"], { company: "Acme Films", project: "The Long Weekend", director: "R. Okonjo", mixer: "Vincent Rozenberg", phone: "+31 6 1234 5678", email: "vincent@example.com", note: "Day 4, ext. night", } ); const compact = await exportWith([ "fileName", "scene", "take", "startTimecode", "frameRate", "durationSeconds", ]); const wideHeader = headerRow(textOps(everything)); const compactHeader = headerRow(textOps(compact)); check("all 26 fields reach the page", () => { assert(wideHeader.length >= 24, "only " + wideHeader.length + " header cells: " + wideHeader.map((o) => o.text).join(" | ")); }); check("no header prints over the column to its right", () => { for (let i = 0; i < wideHeader.length - 1; i++) { const cell = wideHeader[i]; const width = measure(cell.text, cell.size, true); assert(cell.x + width <= wideHeader[i + 1].x + 0.5, '"' + cell.text + '" ends at ' + (cell.x + width).toFixed(1) + ' but "' + wideHeader[i + 1].text + '" starts at ' + wideHeader[i + 1].x.toFixed(1)); } }); check("the page grew sideways to hold them", () => { const width = pageWidthOf(everything); assert(width > 841.9, "still A4 landscape at " + width.toFixed(0) + "pt"); assert(width <= 2400, "page ran away to " + width.toFixed(0) + "pt"); }); check("every column is wide enough for its own header", () => { for (let i = 0; i < wideHeader.length - 1; i++) { const available = wideHeader[i + 1].x - wideHeader[i].x; assert(available > 8, '"' + wideHeader[i].text + '" got only ' + available.toFixed(1) + "pt"); } }); check("nothing anywhere is cut off", () => { // The ellipsis was the writer's own doing — nothing in a BWF file // contains one — so its presence is the defect, wherever it turns up. [["all 26 fields", everything], ["a short selection", compact], ["a report with details", withDetails]].forEach(([what, bytes]) => { const cut = textOps(bytes).filter((op) => /\u2026|\u0085/.test(op.text)); assert.deepStrictEqual(cut.map((op) => op.text), [], what + " came out clipped: " + cut.map((op) => op.text).join(" | ")); }); }); check("no value prints over the column to its right", () => { const ops = textOps(everything); // The first data row: every cell measured as drawn, against where the // next column starts. const row = ops.filter((op) => op.y === ops.filter((o) => /^A001_/.test(o.text))[0].y) .slice().sort((a, b) => a.x - b.x); assert(row.length >= 20, "expected a cell per column, got " + row.length); for (let i = 0; i < row.length - 1; i++) { const width = measure(row[i].text, row[i].size, false); assert(row[i].x + width <= row[i + 1].x + 0.5, '"' + row[i].text + '" runs into "' + row[i + 1].text + '"'); } }); check("a multi-line field is flattened, not spread over the rows below", () => { const ops = textOps(everything); ops.forEach((op) => { assert(!/[\r\n]/.test(op.text), "a drawn string still has a line break: " + op.text); }); const history = ops.filter((op) => /^A=PCM,F=48000,W=24,M=stereo,T=833/.test(op.text)); assert(history.length, "the coding history didn't make it into the report"); assert(/A=ANALOGUE/.test(history[0].text), "the second history line was dropped rather than joined: " + history[0].text); assert(/ \u00b7 /.test(history[0].text), "the lines were run together with no separator: " + history[0].text); const note = ops.filter((op) => /^boom a little hot/.test(op.text)); assert(note.length, "the note didn't make it into the report"); assert(/watch it on the close$/.test(note[0].text), "the note lost its second line: " + note[0].text); // Every row of the table on one baseline pitch: an extra line drawn by // jsPDF for a \n would show up as a y that isn't on the grid. const rowYs = Array.from(new Set(ops.filter((op) => op.size < 10).map((op) => op.y))) .sort((a, b) => b - a); const gaps = rowYs.slice(1).map((y, i) => rowYs[i] - y).filter((gap) => gap < 30); gaps.forEach((gap) => assert(Math.abs(gap - 18) < 0.5 || Math.abs(gap - 12) < 0.5, "an unexpected baseline gap of " + gap.toFixed(1) + "pt")); }); check("values are not truncated to nonsense", () => { const ops = textOps(everything); const filenames = ops.filter((op) => /^A00\d_/.test(op.text)); assert(filenames.length >= 3, "expected a filename per row, got " + filenames.length); assert(filenames.every((op) => /\.wav$/.test(op.text)), "filenames lost their extension: " + filenames.map((o) => o.text).join(", ")); }); check("a short selection still fits A4 landscape", () => { const width = pageWidthOf(compact); assert(Math.abs(width - 841.89) < 1, "six columns should not resize the page: " + width.toFixed(1)); assert(compactHeader.length >= 6, "expected six header cells, got " + compactHeader.length); }); check("a short selection spans the full page width", () => { // Columns share out the spare room rather than huddling on the left. const last = compactHeader[compactHeader.length - 1]; assert(last.x > 600, "the table stops at " + last.x.toFixed(0) + "pt of 842"); }); console.log(""); /** Where the table's own header sits, found by one of its column labels — * headerRow() picks the widest band, and with four columns selected the * detail block ties with it. */ const tableHeaderY = (ops) => { const scene = ops.filter((op) => op.text === "Scene"); assert(scene.length, "no Scene column header in the PDF"); return scene[0].y; }; check("the report carries the production details, above the table", () => { const ops = textOps(withDetails); const text = ops.map((op) => op.text).join(" | "); ["Acme Films", "The Long Weekend", "R. Okonjo", "Vincent Rozenberg", "+31 6 1234 5678", "vincent@example.com", "Day 4, ext. night"].forEach((value) => { assert(text.indexOf(value) !== -1, "missing from the report: " + value); }); // In PDF space y counts up from the bottom, so "above" means larger. const headerY = tableHeaderY(ops); const detail = ops.filter((op) => /Acme Films|R\. Okonjo/.test(op.text)); detail.forEach((op) => { assert(op.y > headerY, "a detail line at y=" + op.y + " is below the table header at y=" + headerY); }); // Two to a line, so seven details cost four lines, not seven. const lines = new Set(detail.concat(ops.filter((op) => /Production company|Sound mixer|Mixer phone/.test(op.text))).map((op) => op.y)); assert(lines.size <= 4, "the details take " + lines.size + " lines"); }); check("the details push the table down rather than printing over it", () => { const plain = tableHeaderY(textOps(compact)); const withHeader = tableHeaderY(textOps(withDetails)); assert(withHeader < plain, "the table header didn't move: " + withHeader + " vs " + plain); }); results.forEach(([s, n]) => console.log((s === "PASS" ? " ok " : " FAIL") + " " + n)); const failed = results.filter(([s]) => s === "FAIL").length; console.log("\n" + (results.length - failed) + "/" + results.length + " checks passed"); process.exit(failed ? 1 : 0); })().catch((e) => { console.error("harness error:", e); process.exit(1); });