BWF Analyser: browser page and macOS app
Reads and edits BWF metadata for production sound. One source tree builds a single self-contained page and a native Tauri app with a Rust audio engine and WAV writer. Around 370 checks across seven test suites. First commit of the existing state, so that from here every change can be seen and undone.
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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, "..", "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);
|
||||
});
|
||||
Reference in New Issue
Block a user