katip-mask / engine.js
f1k's picture
Katip Mask demo
92b6005 verified
Raw History Blame Contribute Delete
32.4 kB
// Katip Yerel engine: parse -> detect -> redact -> leak check, all in-process.
// Used by worker.js in the browser and by test_node.mjs in Node. No network I/O here.
export const OCR_DPI = 300;
const MIN_TEXT_CHARS = 20;
export function kindOf(name) {
const ext = name.toLowerCase().split(".").pop();
if (ext === "pdf") return "pdf";
if (["png", "jpg", "jpeg", "tif", "tiff", "bmp", "webp"].includes(ext)) return "image";
if (ext === "docx") return "docx";
if (ext === "udf") return "udf";
if (["txt", "md", "csv"].includes(ext)) return "txt";
throw new Error(`Desteklenmeyen dosya türü: .${ext}`);
}
// ---- text assembly (same layout rule as documents._assemble on the server)
function assemble(pages) {
let text = "";
for (const p of pages) {
p.start = text.length;
for (const line of p.lines) {
line.forEach((w, k) => {
if (k) text += " ";
w.start = text.length; text += w.text; w.end = text.length;
p.words.push(w);
});
text += "\n";
}
text += "\f\n";
p.end = text.length;
}
return text;
}
// MuPDF structured text -> lines of words with boxes (PDF points)
function pdfLines(page) {
const st = page.toStructuredText("preserve-whitespace");
const lines = []; let line = null, word = null;
const flush = () => { if (word && word.text.trim()) line.push(word); word = null; };
st.walk({
beginLine() { line = []; },
onChar(c, origin, font, size, quad) {
if (/\s/.test(c)) { flush(); return; }
const xs = [quad[0], quad[2], quad[4], quad[6]], ys = [quad[1], quad[3], quad[5], quad[7]];
const b = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
if (!word) word = { text: "", bbox: b };
word.text += c;
word.bbox = [Math.min(word.bbox[0], b[0]), Math.min(word.bbox[1], b[1]), Math.max(word.bbox[2], b[2]), Math.max(word.bbox[3], b[3])];
},
endLine() { flush(); if (line.length) lines.push(line); },
});
st.destroy?.();
lines.sort((a, b) => Math.min(...a.map(w => w.bbox[1])) - Math.min(...b.map(w => w.bbox[1])));
return lines.map(l => l.sort((a, b) => a.bbox[0] - b.bbox[0]));
}
// ocr(pngBytes) must resolve to lines of {text, bbox:[x0,y0,x1,y1]} in pixels; ocr.engine names it ("paddle" or Tesseract by default)
export async function parsePdf(mupdf, bytes, ocr, { forceOcr = false, onProgress } = {}) {
const doc = mupdf.Document.openDocument(bytes, "application/pdf");
const n = doc.countPages(), pages = [];
let ocrPages = 0;
for (let i = 0; i < n; i++) {
onProgress?.(`Sayfa ${i + 1}/${n}`);
const page = doc.loadPage(i);
const [x0, y0, x1, y1] = page.getBounds();
const p = { number: i, width: x1 - x0, height: y1 - y0, words: [], ocr: false, engine: null, lines: forceOcr ? [] : pdfLines(page) };
if (p.lines.flat().reduce((s, w) => s + w.text.length, 0) < MIN_TEXT_CHARS) {
if (!ocr) throw new Error("Taranmış sayfa var ama OCR yüklenmedi");
onProgress?.(`OCR sayfa ${i + 1}/${n}`);
const s = ocrScale(p.width, p.height);
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
const { colour, ...prep } = prepareScan(pix) ?? {};
p.prep = prep;
const pen = unreadInk(pix, [], { colour }).filter(b => b.pen >= PEN_BLUE);
const read = pickOcr(ocr, pen, prep);
let raw = await read(pix.asPNG());
const blobs = unreadInk(pix, raw.flat().map(w => w.bbox), { colour });
const again = await reocrBlobs(mupdf, pix, blobs, read);
raw = mergeLines(raw, again.words);
p.unread = again.unread.map(b => Object.assign(b.map(v => v / s), { pen: b.pen }));
p.pen = pen.map(b => Object.assign(b.map(v => v / s), { pen: b.pen }));
p.reocr = again.words.length;
pix.destroy?.();
p.lines = raw.map(l => l.map(w => ({ text: w.text, bbox: w.bbox.map(v => v / s) })));
p.ocr = true; p.engine = read.engine ?? "tesseract"; ocrPages++;
}
pages.push(p);
}
return { kind: "pdf", text: assemble(pages), pages, ocrPages, _doc: doc };
}
// "auto" OCR: an ocr function may carry forPage({ pen, cropped }) to pick a reader per page. Katip Yerel
// uses it to send handwriting and phone photos to PaddleOCR and printed scans to Tesseract.
// pen = ballpoint-blue ink blobs found before any OCR; cropped = a photographed sheet was cut out of a background.
export const HANDWRITING_MIN_BLOBS = 3;
function pickOcr(ocr, pen, prep) {
return ocr.forPage?.({ pen: pen.length, cropped: !!prep?.cropped, handwriting: pen.length >= HANDWRITING_MIN_BLOBS || !!prep?.cropped }) ?? ocr;
}
export async function parseImage(bytes, ocr, size, mupdf = null) {
let png = bytes, prep = null;
if (mupdf) { // same clean-up as scanned PDF pages; pixel coordinates stay those of the original image
const img = new mupdf.Image(bytes), pix = img.toPixmap();
const rgb = pix.getNumberOfComponents() === 3 && !pix.getAlpha?.() ? pix : pix.convertToColorSpace(mupdf.ColorSpace.DeviceRGB, false);
const { colour, ...rest } = prepareScan(rgb) ?? {};
prep = rest; png = rgb.asPNG().slice();
const pen = unreadInk(rgb, [], { colour }).filter(b => b.pen >= PEN_BLUE), read = pickOcr(ocr, pen, prep);
let lines = await read(png);
const again = await reocrBlobs(mupdf, rgb, unreadInk(rgb, lines.flat().map(w => w.bbox), { colour }), read);
lines = mergeLines(lines, again.words);
const p = { number: 0, width: size?.width ?? rgb.getWidth(), height: size?.height ?? rgb.getHeight(), words: [], ocr: true, engine: read.engine ?? "tesseract", prep, unread: again.unread, pen, lines };
return { kind: "image", text: assemble([p]), pages: [p], ocrPages: 1 };
}
const p = { number: 0, width: size?.width ?? 0, height: size?.height ?? 0, words: [], ocr: true, prep, unread: [], lines: await ocr(png) };
return { kind: "image", text: assemble([p]), pages: [p], ocrPages: 1 };
}
// ---- handwriting the OCR could not read
// After prepareScan: dark strokes that no OCR word covers, with ruled lines (form borders) removed and
// strokes grouped into word-sized blobs. On a filled-in form these are mostly handwritten values the
// OCR skipped (an ID number, a name) and signatures; they become UNREAD items so they can be masked
// even though there is no text to check. Boxes are in pixmap pixels.
export function unreadInk(pix, wordBoxes, { dark = 150, colour = null } = {}) {
const w = pix.getWidth(), h = pix.getHeight(), stride = pix.getStride(), n = pix.getNumberOfComponents(), px = pix.getPixels();
const ink = new Uint8Array(w * h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) ink[y * w + x] = px[y * stride + x * n] < dark ? 1 : 0;
// ruled lines: long horizontal / vertical runs of ink. A photographed line is slanted and a few pixels
// thick, so a pixel counts as "on" for the run if any pixel within 2 rows (columns) of it is ink, small
// gaps are bridged, and the whole band around a found run is cleared.
const L = Math.max(40, Math.round(Math.max(w, h) / 30)), T = 2, GAP = 3;
const clearH = (y, a, b) => { for (let yy = Math.max(0, y - T - 1); yy <= Math.min(h - 1, y + T + 1); yy++) for (let k = a; k < b; k++) if (ink[yy * w + k]) ink[yy * w + k] = 2; };
const clearV = (x, a, b) => { for (let xx = Math.max(0, x - T - 1); xx <= Math.min(w - 1, x + T + 1); xx++) for (let k = a; k < b; k++) if (ink[k * w + xx]) ink[k * w + xx] = 2; };
for (let y = 0; y < h; y += 2) {
let a = -1, gap = 0;
for (let x = 0; x <= w; x++) {
let on = false;
if (x < w) for (let yy = Math.max(0, y - T); yy <= Math.min(h - 1, y + T) && !on; yy++) on = ink[yy * w + x] > 0;
if (on) { if (a < 0) a = x; gap = 0; } else if (a >= 0 && ++gap > GAP) { if (x - gap - a >= L) clearH(y, a, x - gap + 1); a = -1; gap = 0; }
}
}
for (let x = 0; x < w; x += 2) {
let a = -1, gap = 0;
for (let y = 0; y <= h; y++) {
let on = false;
if (y < h) for (let xx = Math.max(0, x - T); xx <= Math.min(w - 1, x + T) && !on; xx++) on = ink[y * w + xx] === 1;
if (on) { if (a < 0) a = y; gap = 0; } else if (a >= 0 && ++gap > GAP) { if (y - gap - a >= L) clearV(x, a, y - gap + 1); a = -1; gap = 0; }
}
}
// what the OCR read
const P = 3;
for (const [x0, y0, x1, y1] of wordBoxes)
for (let y = Math.max(0, Math.floor(y0) - P); y < Math.min(h, Math.ceil(y1) + P); y++)
for (let x = Math.max(0, Math.floor(x0) - P); x < Math.min(w, Math.ceil(x1) + P); x++) ink[y * w + x] = 0;
// coarse grid, joined sideways so the strokes of one handwritten word become one blob
const C = Math.max(3, Math.round(Math.max(w, h) / 600)), gw = Math.ceil(w / C), gh = Math.ceil(h / C), g = new Uint8Array(gw * gh);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (ink[y * w + x] === 1) g[((y / C) | 0) * gw + ((x / C) | 0)]++;
const on = new Uint8Array(gw * gh), JX = 4, JY = 0;
for (let y = 0; y < gh; y++) for (let x = 0; x < gw; x++) if (g[y * gw + x] >= 2)
for (let dy = -JY; dy <= JY; dy++) for (let dx = -JX; dx <= JX; dx++) { const yy = y + dy, xx = x + dx; if (yy >= 0 && yy < gh && xx >= 0 && xx < gw) on[yy * gw + xx] = 1; }
const seen = new Uint8Array(gw * gh), boxes = [], stack = [];
for (let i = 0; i < gw * gh; i++) {
if (!on[i] || seen[i]) continue;
let bx0 = gw, by0 = gh, bx1 = 0, by1 = 0, mass = 0;
stack.push(i); seen[i] = 1;
while (stack.length) {
const j = stack.pop(), x = j % gw, y = (j / gw) | 0;
bx0 = Math.min(bx0, x); bx1 = Math.max(bx1, x); by0 = Math.min(by0, y); by1 = Math.max(by1, y); mass += g[j];
for (const k of [j - 1, j + 1, j - gw, j + gw]) if (k >= 0 && k < gw * gh && on[k] && !seen[k] && Math.abs((k % gw) - x) <= 1) { seen[k] = 1; stack.push(k); }
}
// undo the sideways join on the box edges, then keep word-sized blobs with real ink in them
const x0 = (bx0 + JX) * C, x1 = (bx1 - JX + 1) * C, y0 = (by0 + JY) * C, y1 = (by1 - JY + 1) * C;
const bw = x1 - x0, bh = y1 - y0, minH = Math.max(h, w) / 160;
if (bw < 2 * minH || bh < minH || bh > h / 16 || bw > w * 0.5 || mass < 4 * minH) continue; // thin = left-over ruling
// mean blueness (blue minus red) of the blob's ink: ballpoint ink is bluer than printed black
let inkN = 0, colN = 0;
if (colour) for (let y = Math.max(0, y0); y < Math.min(h, y1); y++) for (let x = Math.max(0, x0); x < Math.min(w, x1); x++)
if (ink[y * w + x] === 1) { inkN++; colN += colour[y * w + x] - 128; }
const box = [x0, y0, x1, y1];
box.pen = inkN ? colN / inkN : 0;
boxes.push(box);
}
return boxes;
}
// Second look at what the page OCR skipped: Tesseract's page layout analysis often drops whole rows of
// a ruled form (two-line label cells, handwriting between lines), but reads the same strokes when given
// them alone. Each blob (with a margin) is cut out and read again; blobs that now give text become words
// of the page, the rest stay "unread". Coordinates are pixmap pixels.
export async function reocrBlobs(mupdf, pix, blobs, ocr, { pad = 10, maxBlobs = 80 } = {}) {
const w = pix.getWidth(), h = pix.getHeight(), stride = pix.getStride(), n = pix.getNumberOfComponents(), px = pix.getPixels();
const words = [], unread = [];
for (const [k, b] of blobs.entries()) {
if (k >= maxBlobs) { unread.push(b); continue; }
const x0 = Math.max(0, Math.floor(b[0] - pad)), y0 = Math.max(0, Math.floor(b[1] - pad)), x1 = Math.min(w, Math.ceil(b[2] + pad)), y1 = Math.min(h, Math.ceil(b[3] + pad));
// white frame around the crop helps Tesseract find the line
const F = 20, cw = x1 - x0 + 2 * F, ch = y1 - y0 + 2 * F;
const crop = new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, cw, ch], false);
const cp = crop.getPixels(), cs = crop.getStride();
cp.fill(255);
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) for (let c = 0; c < 3; c++) cp[(y - y0 + F) * cs + (x - x0 + F) * 3 + c] = px[y * stride + x * n + c];
let got = [];
try { got = (await ocr(crop.asPNG())).flat(); } catch { got = []; }
crop.destroy?.();
const good = got.filter(t => (t.text.match(/[\p{L}\p{N}]/gu) ?? []).length >= 2);
if (!good.length) { unread.push(b); continue; }
for (const t of good) words.push({ text: t.text, bbox: [t.bbox[0] - F + x0, t.bbox[1] - F + y0, t.bbox[2] - F + x0, t.bbox[3] - F + y0], reocr: true });
}
return { words, unread };
}
// Put re-read words into the page's lines by vertical overlap (so "Kiraya verenin T.C. Kimlik No" and the
// number written next to it end up on one text line), then order lines top to bottom, words left to right.
export function mergeLines(lines, words) {
if (!words.length) return lines;
const out = lines.map(l => [...l]);
const mid = b => (b[1] + b[3]) / 2;
for (const wd of words) {
let best = null, bestD = Infinity;
for (const l of out) {
const y0 = Math.min(...l.map(x => x.bbox[1])), y1 = Math.max(...l.map(x => x.bbox[3]));
const m = mid(wd.bbox);
if (m >= y0 && m <= y1) { const d = Math.abs(m - (y0 + y1) / 2); if (d < bestD) { bestD = d; best = l; } }
}
if (best) best.push(wd); else out.push([wd]);
}
for (const l of out) l.sort((a, b) => a.bbox[0] - b.bbox[0]);
out.sort((a, b) => Math.min(...a.map(x => x.bbox[1])) - Math.min(...b.map(x => x.bbox[1])));
return out;
}
// Blueness above which an unread blob is taken as pen (handwriting). On the one real photographed lease we
// have, handwritten values measured +7 to +9 and printed labels -3 to -11; to be checked on the synthetic
// photo set.
export const PEN_BLUE = 6;
// A row label that asks for personal data: "Kiraya verenin", "Kiracının", "T.C. Kimlik No", "Adı Soyadı" ...
const PII_LABEL = /kimlik|t\.?c\.?$|tckn|soyad|ad soyad|kiraya veren|kirac[ıi]|kefil|mal sahibi|telefon|^tel\b|gsm|iban|hesap no|vergi no|ikamet|adres|imza|vekil|davac[ıi]|daval[ıi]|borçlu|alacakl[ıi]/i;
// Labels whose handwritten value is always masked, read or not: ID numbers and names. Not addresses (the
// street stays readable; the text rules mask the building and door) and not party words alone.
const ID_NAME_LABEL = /k[iı]ml[iı]k|kımlık|tckn|t\.\s?c\.|\btc\b|soy\s?ad|ad[ıi]\s+soyad/i;
const median = a => { const b = [...a].sort((x, y) => x - y); return b.length ? b[b.length >> 1] : 0; };
const rowWords = (p, [x0, y0, x1, y1]) => {
const m = (y1 - y0) * 0.5;
return p.words.filter(w => w.bbox[2] <= x0 + 2 && w.bbox[3] > y0 - m && w.bbox[1] < y1 + m);
};
// Handwriting items for the review list, one per ink blob, each carrying its own box (no text offsets):
// * pen ink in a row labelled T.C. Kimlik No or Adı soyadı: masked, whatever the OCR made of it
// (handwritten digits often come out as letters, so the text rules cannot be relied on there);
// * pen ink the OCR could not read at all, next to any personal-data label: masked;
// * other unreadable marks: listed for review, unmasked.
export function unreadSpans(doc) {
const out = [];
for (const p of doc.pages ?? []) {
const add = (b, i, kind, masked, why) => out.push({
id: `u${p.number}_${kind}${i}`, uid: `UNREAD:${p.number}:${kind}${i}`, entity_type: "UNREAD", start: p.end, end: p.end, text: "", score: 0.5,
recognizer: kind === "p" ? "pen_in_id_row" : "unread_ink", explanation: why, flag_only: !masked, page: p.number, boxes: [[b[0], b[1], b[2], b[3]]] });
const penBoxes = [];
(p.pen ?? []).forEach((b, i) => {
// strict row match: the blob's middle must sit inside the label line, so a tall blob
// that spills over from a neighbouring (street) row does not count
// (two-line label cells: the ink sits between "Kiraya verenin" and "T.C. Kimlik No.", so allow one line of slack)
const lh = p.lineH ??= median(p.words.map(w => w.bbox[3] - w.bbox[1])) || 10;
// errs towards masking: a TC number left readable is worse than a street hidden by mistake
// PaddleOCR boxes are about a third taller than Tesseract's, so a full line of slack reaches the
// street row below a TC row; there the blob's middle must sit within 0.6 line of the label instead
const cy = (b[1] + b[3]) / 2;
const words = p.engine === "paddle" ? p.words.filter(w => w.bbox[2] <= b[0] + 2 && cy > w.bbox[1] - 0.6 * lh && cy < w.bbox[3] + 0.6 * lh)
: p.words.filter(w => w.bbox[2] <= b[0] + 2 && w.bbox[3] > b[1] - lh && w.bbox[1] < b[3] + lh);
if (b[3] - b[1] <= 3.5 * lh && ID_NAME_LABEL.test(words.map(w => w.text).join(" "))) { penBoxes.push(b); add(b, i, "p", true, "handwriting in a row labelled T.C. Kimlik No / Adı soyadı"); }
});
(p.unread ?? []).forEach((b, i) => {
if (penBoxes.some(q => q[0] < b[2] && q[2] > b[0] && q[1] < b[3] && q[3] > b[1])) return;
const pen = b.pen >= PEN_BLUE, label = PII_LABEL.test(rowWords(p, b).map(w => w.text).join(" ").toLocaleLowerCase("tr"));
add(b, i, "u", pen && label, pen ? (label ? "handwriting next to a personal-data label, not readable by OCR" : "handwriting the OCR could not read") : "marks the OCR could not read");
});
}
return out;
}
// ---- spans -> boxes (port of redact.attach_boxes; errs towards over-masking)
export function attachBoxes(doc, spans) {
for (const sp of spans) {
if (sp.entity_type === "UNREAD") continue; // boxes come from the page image, not from text
const page = doc.pages.find(p => sp.start >= p.start && sp.start < p.end);
if (!page) continue;
sp.page = page.number;
const byLine = new Map();
for (const w of page.words) {
if (w.end <= sp.start || w.start >= sp.end) continue;
const [x0, y0, x1, y1] = w.bbox, n = Math.max(1, w.text.length);
let a = Math.max(sp.start, w.start) - w.start, b = Math.min(sp.end, w.end) - w.start;
if (!/[\p{L}\p{N}]/u.test(w.text.slice(0, a))) a = 0;
if (!/[\p{L}\p{N}]/u.test(w.text.slice(b))) b = n;
const cw = (x1 - x0) / n;
const cx0 = x0 + cw * a - (a ? cw / 2 : 0), cx1 = x0 + cw * b + (b < n ? cw / 2 : 0);
const key = Math.round((y0 + y1) / 2 / 4);
const box = byLine.get(key) ?? [cx0, y0, cx1, y1];
byLine.set(key, [Math.min(box[0], cx0), Math.min(box[1], y0), Math.max(box[2], cx1), Math.max(box[3], y1)]);
}
sp.boxes = [...byLine.values()];
// handwriting: OCR often drops the first or last letter of a word, so grow the box over pen ink
// that touches it on the same line (not over tall blobs such as signatures)
if (page.ocr && page.pen?.length && sp.entity_type !== "ADDRESS") sp.boxes = sp.boxes.map(b => {
let [x0, y0, x1, y1] = b; const h = y1 - y0;
for (const q of page.pen) {
const vo = Math.min(y1, q[3]) - Math.max(y0, q[1]), gap = Math.max(x0, q[0]) - Math.min(x1, q[2]);
if (q[3] - q[1] <= 2 * h && vo > 0.5 * Math.min(h, q[3] - q[1]) && gap < 0.8 * h)
[x0, y0, x1, y1] = [Math.min(x0, q[0]), Math.min(y0, q[1]), Math.max(x1, q[2]), Math.max(y1, q[3])];
}
return [x0, y0, x1, y1];
});
}
}
// Fill rectangles (page space, y down) into the page content itself, wrapped so the
// original content's graphics state cannot leak into ours.
function paintBoxes(mupdf, pdf, page, boxes, [r, g, b]) {
const inv = mupdf.Matrix.invert(page.getTransform());
const f = v => v.toFixed(3);
let ops = `Q q ${f(r)} ${f(g)} ${f(b)} rg\n`;
for (const box of boxes) {
const [x0, y0, x1, y1] = mupdf.Rect.transform(box, inv);
ops += `${f(Math.min(x0, x1))} ${f(Math.min(y0, y1))} ${f(Math.abs(x1 - x0))} ${f(Math.abs(y1 - y0))} re f\n`;
}
ops += "Q\n";
const obj = page.getObject(), old = obj.get("Contents");
const arr = pdf.newArray();
arr.push(pdf.addStream(new TextEncoder().encode("q\n"), {}));
if (old.isArray()) old.forEach(v => arr.push(v)); else if (!old.isNull?.()) arr.push(old);
arr.push(pdf.addStream(new TextEncoder().encode(ops), {}));
obj.put("Contents", arr);
}
// Centre stamp: the Katip mark across the middle of the page, drawn on top of everything
// (so it shows on scans too) at low opacity through an ExtGState.
function stampMark(mupdf, pdf, page, mark, alpha = 0.08) {
const obj = page.getObject();
let res = obj.getInheritable("Resources");
if (res.isNull()) { res = pdf.newDictionary(); }
obj.put("Resources", res);
let ext = res.get("ExtGState");
if (ext.isNull()) { ext = pdf.newDictionary(); res.put("ExtGState", ext); }
const gs = pdf.newDictionary();
gs.put("Type", pdf.newName("ExtGState")); gs.put("ca", alpha); gs.put("CA", alpha);
ext.put("KatipMark", pdf.addObject(gs));
const [bx0, by0, bx1, by1] = page.getBounds(), W = bx1 - bx0, H = by1 - by0;
const size = Math.min(W, H) * 0.42, ox = bx0 + (W - size) / 2, oy = by0 + (H - size) / 2, k = size / 32;
const [a, b, c, d, e, f] = mupdf.Matrix.invert(page.getTransform());
const pt = (x, y) => { const X = ox + x * k, Y = oy + y * k; return `${(a * X + c * Y + e).toFixed(2)} ${(b * X + d * Y + f).toFixed(2)}`; };
const path = cmds => cmds.map(([op, ...v]) => op === "Z" ? "h" : op === "C" ? `${pt(v[0], v[1])} ${pt(v[2], v[3])} ${pt(v[4], v[5])} c`
: `${pt(v[0], v[1])} ${op === "M" ? "m" : "l"}`).join(" ");
const rgb = v => v.map(x => x.toFixed(3)).join(" ");
const ops = `q /KatipMark gs ${rgb(mark.colors.top)} rg ${path(mark.top)} f ${rgb(mark.colors.bottom)} rg ${path(mark.bottom)} f ` +
`${rgb(mark.colors.k)} RG ${(mark.stroke * k * Math.hypot(a, b)).toFixed(2)} w 1 J 1 j ${path(mark.k)} S Q\n`;
const old = obj.get("Contents"), arr = pdf.newArray();
arr.push(pdf.addStream(new TextEncoder().encode("q\n"), {}));
if (old.isArray()) old.forEach(v => arr.push(v)); else if (!old.isNull()) arr.push(old);
arr.push(pdf.addStream(new TextEncoder().encode("Q\n" + ops), {}));
obj.put("Contents", arr);
}
// ---- true PDF redaction: remove glyphs + blank image pixels, then paint the mask colour,
// add the optional watermark line + link, and scrub metadata.
// opts.color: [r, g, b] in 0..1 (default black); opts.watermark: { text, url } or null;
// opts.mark: MARK from brand.js to stamp the Katip mark in the centre of every page, or null
export function redactPdf(mupdf, doc, spans, opts = {}) {
const pdf = doc._doc, PAD = 1, color = opts.color ?? [0, 0, 0], wm = opts.watermark;
const per = new Map();
for (const sp of spans) if (sp.page != null) per.set(sp.page, [...(per.get(sp.page) ?? []), ...sp.boxes]);
// OCR pages: close small gaps between boxes on one line, so letters between two masked words
// of a handwritten name do not stay readable
for (const [pno, boxes] of per) if (doc.pages?.[pno]?.ocr) per.set(pno, closeGaps(boxes));
for (const [pno, boxes] of per) {
const page = pdf.loadPage(pno);
for (const [x0, y0, x1, y1] of boxes) {
const a = page.createAnnotation("Redact");
a.setRect([x0 - PAD, y0 - PAD, x1 + PAD, y1 + PAD]);
a.update?.();
}
page.applyRedactions(false, mupdf.PDFPage.REDACT_IMAGE_PIXELS);
paintBoxes(mupdf, pdf, page, boxes.map(([x0, y0, x1, y1]) => [x0 - PAD, y0 - PAD, x1 + PAD, y1 + PAD]), color);
}
// Photographed or scanned pages: replace the page by its cleaned image (sheet only, shadows flattened,
// show-through from the back of the sheet wiped). Ink from the other side can carry the names and
// numbers we just masked on that page, mirrored and faint but readable with a contrast tweak.
if (opts.cleanScans !== false) for (const p of doc.pages ?? []) if (p.ocr) cleanScanPage(mupdf, pdf, p.number, per.get(p.number) ?? [], color);
if (opts.mark) for (let i = 0; i < pdf.countPages(); i++) stampMark(mupdf, pdf, pdf.loadPage(i), opts.mark);
const links = [];
if (wm?.text) for (let i = 0; i < pdf.countPages(); i++) {
const page = pdf.loadPage(i), [bx0, by0, bx1, by1] = page.getBounds();
const r = [bx0 + 24, by1 - 18, Math.min(bx1 - 24, bx0 + 24 + wm.text.length * 3.6), by1 - 8];
const a = page.createAnnotation("FreeText");
a.setRect(r); a.setContents(wm.text); a.setDefaultAppearance("Helv", 6.5, [0.45, 0.45, 0.45]); a.setBorderWidth(0); a.setFlags(4 | 128); a.update?.(); // print + locked
if (wm.url) links.push([i, r]);
}
for (const [i, r] of links) pdf.loadPage(i).createLink(r, wm.url);
for (const k of ["info:Title", "info:Author", "info:Subject", "info:Keywords", "info:Creator", "info:Producer"])
pdf.setMetaData(k, k === "info:Producer" || k === "info:Creator" ? "katip-mask" : "");
try { pdf.getTrailer().get("Root").delete("Metadata"); } catch { /* no XMP */ }
return pdf.saveToBuffer("garbage=4,compress,clean").asUint8Array().slice();
}
export function closeGaps(boxes) {
const out = boxes.map(b => [...b]).sort((a, b) => a[1] - b[1] || a[0] - b[0]);
for (let merged = true; merged;) {
merged = false;
for (let i = 0; i < out.length && !merged; i++) for (let j = i + 1; j < out.length && !merged; j++) {
const a = out[i], b = out[j], h = Math.min(a[3] - a[1], b[3] - b[1]);
const vo = Math.min(a[3], b[3]) - Math.max(a[1], b[1]), gap = Math.max(a[0], b[0]) - Math.min(a[2], b[2]);
if (vo > 0.5 * h && gap < 1.5 * h) {
out[i] = [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
out.splice(j, 1); merged = true;
}
}
}
return out;
}
function cleanScanPage(mupdf, pdf, i, boxes, color) {
const page = pdf.loadPage(i);
if (page.getObject().get("Rotate")?.asNumber?.()) return; // rotated pages keep the plain redaction
const [x0, y0, x1, y1] = page.getBounds(), w = x1 - x0, h = y1 - y0, s = ocrScale(w, h);
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
if (!prepareScan(pix)) return;
const res = pdf.newDictionary(), xo = pdf.newDictionary();
xo.put("Im0", pdf.addImage(new mupdf.Image(pix)));
res.put("XObject", xo);
const np = pdf.addPage([0, 0, w, h], 0, res, `q ${w.toFixed(3)} 0 0 ${h.toFixed(3)} 0 0 cm /Im0 Do Q`);
pdf.insertPage(i, np); pdf.deletePage(i + 1);
// the masks went grey in the clean-up; paint them again, full colour, on top
if (boxes.length) paintBoxes(mupdf, pdf, pdf.loadPage(i), boxes.map(([a, b, c, d]) => [a - x0 - 1, b - y0 - 1, c - x0 + 1, d - y0 + 1]), color);
}
export function pdfText(mupdf, bytes) {
const d = mupdf.Document.openDocument(bytes, "application/pdf"); let t = "";
for (let i = 0; i < d.countPages(); i++) t += d.loadPage(i).toStructuredText().asText() + "\n";
return t;
}
// ---- python bridge
export function pyDetect(py, text, opts) {
py.globals.set("_T", text); py.globals.set("_O", JSON.stringify(opts ?? {}));
return JSON.parse(py.runPython("import local_detect; local_detect.detect_json(_T, _O)"));
}
export function pyExtract(py, kind, bytes) {
py.globals.set("_K", kind); py.globals.set("_B", bytes);
return py.runPython("import local_formats; local_formats.extract(_K, _B.to_py())");
}
export function pyRedact(py, kind, bytes, spans, style, watermark = "") {
py.globals.set("_K", kind); py.globals.set("_B", bytes); py.globals.set("_S", JSON.stringify(spans)); py.globals.set("_Y", style); py.globals.set("_W", watermark);
const r = py.runPython("import local_formats; local_formats.redact(_K, _B.to_py(), _S, _Y, _W)");
const out = r.toJs(); r.destroy(); return out;
}
export function pyLeaks(py, outText, spans) {
py.globals.set("_T", outText); py.globals.set("_S", JSON.stringify(spans));
return JSON.parse(py.runPython("import local_formats; local_formats.leak_check(_T, _S)"));
}
// ---- images: burn boxes into pixels (colour [r,g,b] 0..1), re-encode as PNG (drops EXIF/GPS)
export function redactImage(mupdf, bytes, spans, mime = "image/png", color = [0, 0, 0]) {
const d = mupdf.Document.openDocument(bytes, mime);
const page = d.loadPage(0);
const img = new mupdf.Image(bytes);
const [x0, , x1] = page.getBounds();
const s = img.getWidth() / (x1 - x0);
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
const w = pix.getWidth(), h = pix.getHeight(), stride = pix.getStride(), n = pix.getNumberOfComponents();
const px = pix.getPixels(), P = 2;
for (const sp of spans) for (const [bx0, by0, bx1, by1] of sp.boxes ?? []) {
for (let y = Math.max(0, Math.floor(by0 - P)); y < Math.min(h, Math.ceil(by1 + P)); y++)
for (let x = Math.max(0, Math.floor(bx0 - P)); x < Math.min(w, Math.ceil(bx1 + P)); x++)
for (let c = 0; c < n; c++) px[y * stride + x * n + c] = Math.round(255 * (color[c] ?? 0));
}
return pix.asPNG().slice();
}
// render each page of a PDF for a second OCR pass (leak check on scans)
export function pdfPagePngs(mupdf, bytes, pages) {
const d = mupdf.Document.openDocument(bytes, "application/pdf");
return pages.map(i => {
const page = d.loadPage(i), [x0, y0, x1, y1] = page.getBounds(), s = ocrScale(x1 - x0, y1 - y0);
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
prepareScan(pix);
return pix.asPNG().slice();
});
}
// ---- scans and phone photos: what the OCR sees
// Render at 300 DPI for normal page sizes, but never wider than an A4 page at 300 DPI (3508 px on the
// long side): a phone photo wrapped in a PDF is often a 1152x2048 pt page holding a 72 ppi image, and
// 300 DPI would upscale it 4x, which makes Tesseract read handwriting and the wood grain under the paper
// as text.
const MAX_OCR_SIDE = 3508;
export function ocrScale(width, height) {
return Math.min(OCR_DPI / 72, MAX_OCR_SIDE / Math.max(1, width, height));
}
// In place, on an RGB pixmap: (1) find the sheet of paper (bright, unsaturated) and whiten everything
// around it, so the floor or desk is not read as words; (2) divide by a blurred copy to flatten shadows
// and uneven light; (3) wipe faint grey (text showing through from the back of the sheet). Clean flatbed
// scans come out almost unchanged. Returns what it did, for the report.
export function prepareScan(pix) {
const w = pix.getWidth(), h = pix.getHeight(), stride = pix.getStride(), n = pix.getNumberOfComponents();
if (n < 3 || w < 64 || h < 64) return null;
const px = pix.getPixels(), g = new Float32Array(w * h), paper = new Uint8Array(w * h), colour = new Uint8Array(w * h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const o = y * stride + x * n, r = px[o], gg = px[o + 1], b = px[o + 2];
const v = 0.299 * r + 0.587 * gg + 0.114 * b, sat = Math.max(r, gg, b) - Math.min(r, gg, b);
g[y * w + x] = v;
paper[y * w + x] = v > 165 && sat < 45 ? 1 : 0;
colour[y * w + x] = Math.max(0, Math.min(255, 128 + b - r)); // blueness: ballpoint ink is bluer than printed black
}
// paper box: rows and columns that are at least 30% paper
const rowHit = new Array(h).fill(0), colHit = new Array(w).fill(0);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (paper[y * w + x]) { rowHit[y]++; colHit[x]++; }
const rows = rowHit.map((c, y) => c > 0.3 * w ? y : -1).filter(y => y >= 0);
const cols = colHit.map((c, x) => c > 0.3 * h ? x : -1).filter(x => x >= 0);
let box = [0, 0, w, h];
if (rows.length && cols.length) {
const b = [cols[0], rows[0], cols.at(-1) + 1, rows.at(-1) + 1];
if ((b[2] - b[0]) * (b[3] - b[1]) >= 0.3 * w * h) box = b; // a small "paper" is a guess gone wrong
}
// background estimate: box blur (integral image), radius ~2% of the long side
const R = Math.max(8, Math.round(Math.max(w, h) / 50)), I = new Float64Array((w + 1) * (h + 1));
for (let y = 0; y < h; y++) { let row = 0; for (let x = 0; x < w; x++) { row += g[y * w + x]; I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row; } }
let flattened = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
let v = 255;
if (x >= box[0] && x < box[2] && y >= box[1] && y < box[3]) {
const ya = Math.max(0, y - R), yb = Math.min(h, y + R + 1), xa = Math.max(0, x - R), xb = Math.min(w, x + R + 1);
const bg = (I[yb * (w + 1) + xb] - I[ya * (w + 1) + xb] - I[yb * (w + 1) + xa] + I[ya * (w + 1) + xa]) / ((yb - ya) * (xb - xa));
v = Math.min(255, (g[y * w + x] / Math.max(1, bg)) * 255);
if (v > 200) v = 255; else flattened++;
}
const o = y * stride + x * n;
px[o] = px[o + 1] = px[o + 2] = v;
}
return { paper: box, cropped: box[2] - box[0] < w || box[3] - box[1] < h, ink: flattened / (w * h), colour };
}