Spaces:
Running
Running
Download engine.js from f1k/katip-mask: direct link, hf CLI and curl.
- Browser
- Download file 32.4 kB
-
https://huggingface.co/spaces/f1k/katip-mask/resolve/main/engine.js
- Command line
-
hf download hf://spaces/f1k/katip-mask/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/f1k/katip-mask/resolve/main/engine.js
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 }; | |
| } | |