Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function setUp(op: ToolOp) {
const rasterOptions = (o: Options) => ({
dpi: o.dpi === '300' ? 300 : 150,
look: (o.look === 'gray' || o.look === 'scanned' ? o.look : 'color') as 'color' | 'gray' | 'scanned',
keepText: o.keepText === 'true',
});

// Every tool that runs outside the qpdf worker. Engines load on first use.
Expand Down
176 changes: 173 additions & 3 deletions src/engine/raster.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Redraws every page of a PDF as an image. Redact PDF burns its marks in, so the text under them is
// gone from the file, and PDF to scanned PDF uses the same path with an optional scanner look.
import { PDFDocument } from 'pdf-lib';
import { PDFDocument, PDFHexString, PDFOperator, PDFOperatorNames as Op, PDFNumber, type PDFRef } from 'pdf-lib';
import { LocalError, type Output } from './local';

/** A marked area in PDF user space (unrotated page coordinates, y up). */
Expand All @@ -19,6 +19,140 @@ export interface RasterOptions {
look: Look;
/** Areas to paint black on each page (0-based page index). */
marks?: (page: number) => Box[];
/** Adds the visible text outside the marks back as an invisible layer, so the file stays searchable. */
keepText?: boolean;
}

type Matrix = [number, number, number, number, number, number];
/** `outer` after `inner`, in PDF's [a b c d e f] form. */
const multiply = (outer: Matrix, inner: Matrix): Matrix => [
outer[0] * inner[0] + outer[2] * inner[1],
outer[1] * inner[0] + outer[3] * inner[1],
outer[0] * inner[2] + outer[2] * inner[3],
outer[1] * inner[2] + outer[3] * inner[3],
outer[0] * inner[4] + outer[2] * inner[5] + outer[4],
outer[1] * inner[4] + outer[3] * inner[5] + outer[5],
];
const overlaps = (a: Box, b: Box) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;

interface TextRun {
str: string;
transform: Matrix;
width: number;
height: number;
}

/** A run's area in PDF space, padded like the search marks so it errs on the side of touching a mark. */
function runBox(run: TextRun): Box {
const [a, b, c, d, e, f] = run.transform;
const size = Math.hypot(a, b) || 1;
const up = Math.hypot(c, d) || 1;
const height = Math.max(run.height, size);
const corners = [
[-size * 0.35, -height * 0.3],
[run.width + size * 0.35, -height * 0.3],
[-size * 0.35, height * 1.05],
[run.width + size * 0.35, height * 1.05],
].map(([along, across]) => [e + (a / size) * along + (c / up) * across, f + (b / size) * along + (d / up) * across]);
const xs = corners.map((p) => p[0]);
const ys = corners.map((p) => p[1]);
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) };
}

/**
* A font with no glyphs whose codes are the text's own UTF-16 units, the way OCR tools add an invisible
* text layer. Nothing is ever drawn with it, so any script can be searched and copied.
*/
function glyphlessFont(out: PDFDocument): PDFRef {
const ranges = Array.from({ length: 256 }, (_, hi) => {
const h = hi.toString(16).padStart(2, '0').toUpperCase();
return `<${h}00> <${h}FF> <${h}00>`;
});
const cmap = [
'/CIDInit /ProcSet findresource begin 12 dict begin begincmap',
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
'/CMapName /Adobe-Identity-UCS def /CMapType 2 def',
'1 begincodespacerange <0000> <FFFF> endcodespacerange',
...[0, 100, 200].map((start) => {
const chunk = ranges.slice(start, start + 100);
return `${chunk.length} beginbfrange\n${chunk.join('\n')}\nendbfrange`;
}),
'endcmap CMapName currentdict /CMap defineresource pop end end',
].join('\n');
const descriptor = out.context.obj({
Type: 'FontDescriptor',
FontName: 'GlyphLessFont',
Flags: 5,
FontBBox: [0, 0, 1000, 1000],
ItalicAngle: 0,
Ascent: 1000,
Descent: 0,
CapHeight: 1000,
StemV: 80,
});
const cid = out.context.obj({
Type: 'Font',
Subtype: 'CIDFontType2',
BaseFont: 'GlyphLessFont',
CIDSystemInfo: { Registry: PDFHexString.fromText('Adobe'), Ordering: PDFHexString.fromText('Identity'), Supplement: 0 },
FontDescriptor: out.context.register(descriptor),
DW: 1000,
CIDToGIDMap: 'Identity',
});
return out.context.register(
out.context.obj({
Type: 'Font',
Subtype: 'Type0',
BaseFont: 'GlyphLessFont',
Encoding: 'Identity-H',
DescendantFonts: [out.context.register(cid)],
ToUnicode: out.context.register(out.context.flateStream(cmap)),
}),
);
}

/** True when the area holds visible ink: text under a box, or white on white, renders as one flat colour. */
function showsInk(context: OffscreenCanvasRenderingContext2D, x: number, y: number, width: number, height: number) {
const [x0, y0] = [Math.max(0, Math.floor(x)), Math.max(0, Math.floor(y))];
const [x1, y1] = [Math.min(context.canvas.width, Math.ceil(x + width)), Math.min(context.canvas.height, Math.ceil(y + height))];
if (x1 - x0 < 1 || y1 - y0 < 1) return false;
const { data } = context.getImageData(x0, y0, x1 - x0, y1 - y0);
let [low, high] = [255, 0];
for (let i = 0; i < data.length; i += 4) {
const light = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
if (light < low) low = light;
if (light > high) high = light;
}
return high - low > 40;
}

/**
* True when the run's letters can be seen on the rendered page. Each letter gets an equal share of the
* run's width; two blank letters in a row (a word under a box, or white on white), or no ink at all,
* hide the whole run.
*/
function visible(context: OffscreenCanvasRenderingContext2D, viewport: { convertToViewportPoint(x: number, y: number): number[] }, run: TextRun) {
const [a, b, c, d, e, f] = run.transform;
const size = Math.hypot(a, b) || 1;
const up = Math.hypot(c, d) || 1;
const height = Math.max(run.height, size) * 0.75;
const cell = run.width / run.str.length;
let [blank, inked] = [0, 0];
for (let i = 0; i < run.str.length; i++) {
if (/\s/.test(run.str[i])) {
blank = 0;
continue;
}
const corners = [
[i * cell, 0],
[(i + 1) * cell, height],
].map(([along, across]) => viewport.convertToViewportPoint(e + (a / size) * along + (c / up) * across, f + (b / size) * along + (d / up) * across));
const [[ax, ay], [bx, by]] = corners;
const ink = showsInk(context, Math.min(ax, bx), Math.min(ay, by), Math.abs(bx - ax), Math.abs(by - ay));
[blank, inked] = ink ? [0, inked + 1] : [blank + 1, inked];
if (blank >= 2) return false;
}
return inked > 0;
}

type PdfJs = typeof import('pdfjs-dist/legacy/build/pdf.mjs');
Expand Down Expand Up @@ -52,14 +186,15 @@ function grain() {
export async function rasterize(
doc: PdfDoc,
name: string,
{ dpi, look, marks }: RasterOptions,
{ dpi, look, marks, keepText }: RasterOptions,
onProgress?: (fraction: number) => void,
): Promise<Output> {
const out = await PDFDocument.create();
out.setProducer('Fizzdoc');
out.setCreator('Fizzdoc');
const texture = look === 'scanned' ? grain() : undefined;
let marked = 0;
let font: PDFRef | undefined;
for (let index = 0; index < doc.numPages; index++) {
const page = await doc.getPage(index + 1);
const size = page.getViewport({ scale: 1 });
Expand All @@ -83,6 +218,18 @@ export async function rasterize(
marked++;
}

// The text left showing on the page, checked against the marks and against the rendered page.
const keep: TextRun[] = [];
if (keepText) {
const covered = marks?.(index) ?? [];
for (const item of (await page.getTextContent()).items as unknown as TextRun[]) {
if (typeof item.str !== 'string' || !item.str.trim() || !item.width) continue;
const box = runBox(item);
if (covered.some((mark) => overlaps(mark, box))) continue;
if (visible(renderContext, viewport, item)) keep.push(item);
}
}

const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d')!;
context.fillStyle = look === 'scanned' ? '#f4f3ef' : '#fff';
Expand All @@ -105,7 +252,30 @@ export async function rasterize(
}
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 });
const image = await out.embedJpg(new Uint8Array(await blob.arrayBuffer()));
out.addPage([size.width, size.height]).drawImage(image, { x: 0, y: 0, width: size.width, height: size.height });
const sheet = out.addPage([size.width, size.height]);
sheet.drawImage(image, { x: 0, y: 0, width: size.width, height: size.height });
if (keep.length) {
font ??= glyphlessFont(out);
const key = sheet.node.newFontDictionary('GlyphLessFont', font);
// PDF space to the upright output page: the page's own rotation and crop, then y up again.
const place = multiply([1, 0, 0, -1, 0, size.height], size.transform as Matrix);
const operators = [PDFOperator.of(Op.BeginText), PDFOperator.of(Op.SetFontAndSize, [key, PDFNumber.of(1)]), PDFOperator.of(Op.SetTextRenderingMode, [PDFNumber.of(3)])];
for (const run of keep) {
const units = run.str.replace(/[\uD800-\uDFFF]/g, '');
if (!units) continue;
const fontSize = Math.hypot(run.transform[0], run.transform[1]) || 1;
const matrix = multiply(place, run.transform);
const hex = [...units].map((char) => char.charCodeAt(0).toString(16).padStart(4, '0')).join('');
operators.push(
PDFOperator.of(Op.SetTextMatrix, matrix.map((n) => PDFNumber.of(n))),
// Stretch the run so selecting it highlights the same width as the words on the image.
PDFOperator.of(Op.SetTextHorizontalScaling, [PDFNumber.of((100 * run.width) / (units.length * fontSize))]),
PDFOperator.of(Op.ShowText, [PDFHexString.of(hex)]),
);
}
operators.push(PDFOperator.of(Op.EndText));
sheet.pushOperators(...operators);
}
page.cleanup();
onProgress?.((index + 1) / doc.numPages);
}
Expand Down
3 changes: 2 additions & 1 deletion src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export const UI = {
'ws.lookColor': 'Colour',
'ws.lookGray': 'Black and white',
'ws.lookScanned': 'Scanned (grey, slightly tilted)',
'ws.keepText': 'Keep the rest of the text searchable',
'ws.chooseAudio': 'Choose an MP3 or M4A file',
'ws.chooseAudios': 'Choose MP3 or M4A files',
'ws.chooseMedia': 'Choose an audio or video file',
Expand Down Expand Up @@ -290,7 +291,7 @@ export const UI = {
'rd.remove': 'Remove this mark',
'rd.draw': 'Draw boxes',
'rd.touchHint': 'On a touch screen, swipe to scroll. Tap “Draw boxes” to mark with your finger, and tap it again to scroll.',
'rd.hint': 'Drag on a page to cover anything: text, a signature, a photo. When you save, every page becomes an image, so what you covered is gone for good.',
'rd.hint': 'Drag on a page to cover anything: text, a signature, a photo. When you save, every page becomes an image, so what you covered is gone for good. The text you didn’t cover stays searchable.',
'ocr.unsure': 'Some of this text may be wrong. Text recognition reads English that is upright: rotate sideways photos first, and expect other scripts to come out garbled.',
'ocr.copyAll': 'Copy all text',
'ocr.copied': 'Copied',
Expand Down
9 changes: 5 additions & 4 deletions src/i18n/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"ws.lookColor": "রঙিন",
"ws.lookGray": "সাদা-কালো",
"ws.lookScanned": "স্ক্যানড (ধূসর, সামান্য কাত)",
"ws.keepText": "বাকি টেক্সট সার্চযোগ্য রাখুন",
"ws.wmText": "ওয়াটারমার্কের লেখা",
"ws.wmDefault": "CONFIDENTIAL",
"ws.opacity": "স্বচ্ছতা",
Expand Down Expand Up @@ -233,7 +234,7 @@
"rd.remove": "এই মার্কটা সরান",
"rd.draw": "বক্স আঁকুন",
"rd.touchHint": "টাচ স্ক্রিনে স্ক্রল করতে সোয়াইপ করুন। আঙুল দিয়ে চিহ্ন দিতে “বক্স আঁকুন”-এ ট্যাপ করুন, আর স্ক্রল করতে আবার ট্যাপ করুন।",
"rd.hint": "যেকোনো কিছু ঢাকতে পাতায় ড্র্যাগ করুন: টেক্সট, স্বাক্ষর, ছবি। সেভ করলে প্রতিটা পাতা একটা ছবিতে পরিণত হয়, তাই যা ঢেকেছেন তা চিরতরে মুছে যায়।",
"rd.hint": "যেকোনো কিছু ঢাকতে পাতায় ড্র্যাগ করুন: টেক্সট, স্বাক্ষর, ছবি। সেভ করলে প্রতিটা পাতা একটা ছবিতে পরিণত হয়, তাই যা ঢেকেছেন তা চিরতরে মুছে যায়। যে টেক্সট ঢাকেননি তা সার্চযোগ্য থাকে।",
"footer.madeBy": "ডিজাইন ও নির্মাণ:",
"footer.feedback": "বাগ রিপোর্ট করুন বা টুল প্রস্তাব করুন",
"footer.compare": "পরিচিত টুলের বিকল্প",
Expand Down Expand Up @@ -751,7 +752,7 @@
"title": "PDF রিড্যাক্ট করুন ফ্রি — তথ্য লুকান, আপলোড ছাড়া | Fizzdoc",
"description": "PDF-এ নাম, নম্বর, স্বাক্ষর বা ছবি কালো করুন এবং এমন একটা কপি সেভ করুন যেখানে সেগুলো সত্যিই মুছে গেছে। ব্রাউজারেই চলে; কিছু আপলোড হয় না। ফ্রি।",
"h1": "PDF রিডাক্ট করুন: যা কিছু চিরতরে কালো করুন",
"lede": "যা মুছতে চান তার ওপর ড্র্যাগ করুন, বা একটা নাম বা নম্বর খুঁজে প্রতিটা মিল মার্ক করুন। সেভ করলে প্রতিটা পাতা একটা ছবিতে পরিণত হয়, তাই লুকানো টেক্সট কপি বা ফিরিয়ে আনা যায় না।",
"lede": "যা মুছতে চান তার ওপর ড্র্যাগ করুন, বা একটা নাম বা নম্বর খুঁজে প্রতিটা মিল মার্ক করুন। সেভ করলে প্রতিটা পাতা একটা ছবিতে পরিণত হয়, তাই লুকানো টেক্সট কপি বা ফিরিয়ে আনা যায় না, আর বাকি টেক্সট সার্চযোগ্য থাকে।",
"steps": [
"একটা PDF ফাইল যোগ করুন।",
"একটা এলাকা ঢাকতে পাতায় ড্র্যাগ করুন, বা একটা শব্দ লিখে “সব মার্ক করুন”-এ ক্লিক করুন।",
Expand All @@ -764,11 +765,11 @@
],
[
"রিডাক্ট করা টেক্সট কি সত্যিই মুছে যায়?",
"হ্যাঁ। Fizzdoc প্রতিটা পাতা আপনার কালো মার্কসহ একটা ছবি হিসেবে নতুন করে আঁকে আর সেই ছবি থেকে একটা নতুন PDF তৈরি করে। মূল টেক্সট, ফন্ট আর ডকুমেন্ট মেটাডেটা কপি করা হয় না, তাই মার্কের নিচে কিছুই সিলেক্ট, সার্চ বা ফিরিয়ে আনা যায় না।"
"হ্যাঁ। Fizzdoc প্রতিটা পাতা আপনার কালো মার্কসহ একটা ছবি হিসেবে নতুন করে আঁকে আর সেই ছবি থেকে একটা নতুন PDF তৈরি করে। মূল ফন্ট আর ডকুমেন্ট মেটাডেটা কপি করা হয় না, আর মার্কের নিচের টেক্সট কখনো ফিরিয়ে যোগ করা হয় না, তাই তা সিলেক্ট, সার্চ বা ফিরিয়ে আনা যায় না।"
],
[
"বাকি টেক্সট কি সার্চ বা কপি করা যাবে?",
"না। প্রতিটা পাতা একটা ছবি হয়ে যায়, তাই সেভ করা ফাইলে কোনো সিলেক্ট করার মতো টেক্সট থাকে না। আবার সার্চযোগ্য টেক্সট দরকার হলে, রিডাক্ট করা ফাইলটা OCR PDF-এ চালান; কালো মার্কগুলো কালোই থাকবে।"
"হ্যাঁ। আপনি যে টেক্সট ঢাকেননি তা প্রতিটা পাতার ছবির ওপর একটা অদৃশ্য স্তর হিসেবে আবার যোগ করা হয়, তাই আপনি সার্চ, সিলেক্ট আর কপি করতে পারবেন। মার্কের নিচের টেক্সট বাদ যায়, আর পাতায় যে টেক্সট দেখা যায় না, যেমন কালো বাক্সের নিচে আগে থেকে লুকানো শব্দ, সেটাও বাদ যায়। শুধু ছবির ফাইলের জন্য “বাকি টেক্সট সার্চযোগ্য রাখুন” টিকটা তুলে দিন।"
],
[
"Fizzdoc কি ফ্রি?",
Expand Down
Loading
Loading