|
|
|
@@ -1,60 +1,188 @@
|
|
|
|
|
import { PDFDocument, PDFFont, PDFPage, rgb, StandardFonts } from 'pdf-lib';
|
|
|
|
|
import { beginText, endText, PDFDocument, PDFHexString, PDFOperator, PDFPage, PDFRef, PDFString, setCharacterSqueeze, setFontAndSize, setTextMatrix, setTextRenderingMode, showText, TextRenderingMode } from 'pdf-lib';
|
|
|
|
|
import { PdfOcrDetails, RecognizeResultLine } from './types';
|
|
|
|
|
import glyphLessFontBase64 from './glyphLessFont';
|
|
|
|
|
|
|
|
|
|
// The PDF OCR images are created at 2x scale by pdfToImages()
|
|
|
|
|
const OCR_SCALE_FACTOR = 2;
|
|
|
|
|
|
|
|
|
|
// Metrics of the glyphless font, in 1/1000 of the font size
|
|
|
|
|
const GLYPH_WIDTH = 500;
|
|
|
|
|
const FONT_ASCENT = 800;
|
|
|
|
|
const FONT_DESCENT = -200;
|
|
|
|
|
|
|
|
|
|
export interface PageImageWithDimensions {
|
|
|
|
|
buffer: Buffer;
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Maps each UTF-16 code unit (used as CID) to itself. A bfrange may only vary in its last byte and a
|
|
|
|
|
// block may contain at most 100 entries.
|
|
|
|
|
const makeToUnicodeCMap = () => {
|
|
|
|
|
const ranges: string[] = [];
|
|
|
|
|
for (let i = 0; i < 256; i++) {
|
|
|
|
|
const high = i.toString(16).toUpperCase().padStart(2, '0');
|
|
|
|
|
ranges.push(`<${high}00> <${high}FF> <${high}00>`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cSpell:disable
|
|
|
|
|
const blocks: string[] = [];
|
|
|
|
|
for (let i = 0; i < ranges.length; i += 100) {
|
|
|
|
|
const chunk = ranges.slice(i, i + 100);
|
|
|
|
|
blocks.push(`${chunk.length} beginbfrange\n${chunk.join('\n')}\nendbfrange`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'/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',
|
|
|
|
|
...blocks,
|
|
|
|
|
'endcmap',
|
|
|
|
|
'CMapName currentdict /CMap defineresource pop',
|
|
|
|
|
'end',
|
|
|
|
|
'end',
|
|
|
|
|
].join('\n');
|
|
|
|
|
// cSpell:enable
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Standard fonts only support WinAnsi, so any other character (Cyrillic, CJK, etc.) would make
|
|
|
|
|
// pdf-lib throw. Instead, like Tesseract, we use a Type0 font whose CIDs are UTF-16 code units, all
|
|
|
|
|
// mapped to the same empty glyph, with a ToUnicode CMap so that the text can be extracted.
|
|
|
|
|
const embedGlyphLessFont = (pdfDoc: PDFDocument) => {
|
|
|
|
|
const context = pdfDoc.context;
|
|
|
|
|
|
|
|
|
|
const fontFile = Buffer.from(glyphLessFontBase64, 'base64');
|
|
|
|
|
const fontFileRef = context.register(context.flateStream(fontFile, { Length1: fontFile.length }));
|
|
|
|
|
|
|
|
|
|
// Two bytes per CID, all pointing to glyph 1
|
|
|
|
|
const cidToGidMap = new Uint8Array(0x10000 * 2);
|
|
|
|
|
for (let i = 1; i < cidToGidMap.length; i += 2) cidToGidMap[i] = 1;
|
|
|
|
|
const cidToGidMapRef = context.register(context.flateStream(cidToGidMap));
|
|
|
|
|
|
|
|
|
|
const fontDescriptorRef = context.register(context.obj({
|
|
|
|
|
Type: 'FontDescriptor',
|
|
|
|
|
FontName: 'GlyphLessFont',
|
|
|
|
|
Flags: 5,
|
|
|
|
|
FontBBox: [0, FONT_DESCENT, GLYPH_WIDTH, FONT_ASCENT],
|
|
|
|
|
ItalicAngle: 0,
|
|
|
|
|
Ascent: FONT_ASCENT,
|
|
|
|
|
Descent: FONT_DESCENT,
|
|
|
|
|
CapHeight: FONT_ASCENT,
|
|
|
|
|
StemV: 80,
|
|
|
|
|
FontFile2: fontFileRef,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const cidFontRef = context.register(context.obj({
|
|
|
|
|
Type: 'Font',
|
|
|
|
|
Subtype: 'CIDFontType2',
|
|
|
|
|
BaseFont: 'GlyphLessFont',
|
|
|
|
|
CIDSystemInfo: {
|
|
|
|
|
Registry: PDFString.of('Adobe'),
|
|
|
|
|
Ordering: PDFString.of('Identity'),
|
|
|
|
|
Supplement: 0,
|
|
|
|
|
},
|
|
|
|
|
FontDescriptor: fontDescriptorRef,
|
|
|
|
|
CIDToGIDMap: cidToGidMapRef,
|
|
|
|
|
DW: GLYPH_WIDTH,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const toUnicodeRef = context.register(context.flateStream(makeToUnicodeCMap()));
|
|
|
|
|
|
|
|
|
|
return context.register(context.obj({
|
|
|
|
|
Type: 'Font',
|
|
|
|
|
Subtype: 'Type0',
|
|
|
|
|
BaseFont: 'GlyphLessFont',
|
|
|
|
|
Encoding: 'Identity-H',
|
|
|
|
|
DescendantFonts: [cidFontRef],
|
|
|
|
|
ToUnicode: toUnicodeRef,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const rtlScriptRegex = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u;
|
|
|
|
|
const isRtlLetter = (char: string) => rtlScriptRegex.test(char) && !/\p{N}/u.test(char);
|
|
|
|
|
const isLtrStrong = (char: string) => /[\p{L}\p{N}]/u.test(char) && !isRtlLetter(char);
|
|
|
|
|
|
|
|
|
|
// OCR text is in logical order, but viewers expect PDF text in visual (left-to-right) order and
|
|
|
|
|
// reorder it when extracting. So in right-to-left words, the runs are reversed, as well as the
|
|
|
|
|
// characters within right-to-left runs (numbers and Latin text remain left-to-right).
|
|
|
|
|
const toVisualOrder = (text: string) => {
|
|
|
|
|
const chars = Array.from(text);
|
|
|
|
|
if (!chars.some(isRtlLetter)) return text;
|
|
|
|
|
|
|
|
|
|
const runs: string[][] = [];
|
|
|
|
|
for (const char of chars) {
|
|
|
|
|
const lastRun = runs[runs.length - 1];
|
|
|
|
|
if (lastRun && isLtrStrong(lastRun[0]) === isLtrStrong(char)) {
|
|
|
|
|
lastRun.push(char);
|
|
|
|
|
} else {
|
|
|
|
|
runs.push([char]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return runs.reverse().map(run => isLtrStrong(run[0]) ? run.join('') : run.reverse().join('')).join('');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toUtf16Hex = (text: string) => {
|
|
|
|
|
let output = '';
|
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
|
output += text.charCodeAt(i).toString(16).padStart(4, '0');
|
|
|
|
|
}
|
|
|
|
|
return output;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Adds an invisible text layer to a PDF page based on OCR word positions.
|
|
|
|
|
// The text is rendered in "invisible" mode (render mode 3) so it doesn't
|
|
|
|
|
// appear visually but can be selected, copied, and read by screen readers.
|
|
|
|
|
const addInvisibleTextLayer = (
|
|
|
|
|
page: PDFPage,
|
|
|
|
|
lines: RecognizeResultLine[],
|
|
|
|
|
font: PDFFont,
|
|
|
|
|
_pageWidth: number,
|
|
|
|
|
fontRef: PDFRef,
|
|
|
|
|
pageHeight: number,
|
|
|
|
|
): void => {
|
|
|
|
|
) => {
|
|
|
|
|
const fontName = page.node.newFontDictionary('GlyphLessFont', fontRef);
|
|
|
|
|
const operators: PDFOperator[] = [beginText(), setTextRenderingMode(TextRenderingMode.Invisible)];
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
for (const word of line.words) {
|
|
|
|
|
const text = word.t;
|
|
|
|
|
if (!text || !text.trim()) continue;
|
|
|
|
|
|
|
|
|
|
// Bounding box format from Tesseract: [x0, x1, y0, y1]
|
|
|
|
|
const [x0, , y0, y1] = word.bb;
|
|
|
|
|
const [x0, x1, y0, y1] = word.bb;
|
|
|
|
|
|
|
|
|
|
// Convert from OCR coordinates (2x scale, origin top-left)
|
|
|
|
|
// to PDF coordinates (1x scale, origin bottom-left)
|
|
|
|
|
const pdfX = x0 / OCR_SCALE_FACTOR;
|
|
|
|
|
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR); // Flip Y axis and use bottom of bbox
|
|
|
|
|
|
|
|
|
|
// Calculate word height in PDF coordinates (width not currently used)
|
|
|
|
|
const wordWidth = (x1 - x0) / OCR_SCALE_FACTOR;
|
|
|
|
|
const wordHeight = (y1 - y0) / OCR_SCALE_FACTOR;
|
|
|
|
|
|
|
|
|
|
// Estimate font size based on word height
|
|
|
|
|
// We want the text to fit within the bounding box
|
|
|
|
|
const fontSize = Math.max(1, wordHeight * 0.85);
|
|
|
|
|
// The font's ascent-to-descent height is one em, so this makes the glyphs span the word's
|
|
|
|
|
// bounding box, with the baseline placed above its bottom by the descent.
|
|
|
|
|
const fontSize = Math.max(1, wordHeight);
|
|
|
|
|
const pdfX = x0 / OCR_SCALE_FACTOR;
|
|
|
|
|
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR) - fontSize * FONT_DESCENT / 1000;
|
|
|
|
|
|
|
|
|
|
// Draw the text as invisible (using transparent color)
|
|
|
|
|
// PDF.js and screen readers can still detect and read this text
|
|
|
|
|
page.drawText(text, {
|
|
|
|
|
x: pdfX,
|
|
|
|
|
y: pdfY,
|
|
|
|
|
size: fontSize,
|
|
|
|
|
font: font,
|
|
|
|
|
color: rgb(0, 0, 0),
|
|
|
|
|
opacity: 0, // Make text invisible
|
|
|
|
|
// Note: pdf-lib doesn't directly support render mode 3 (invisible),
|
|
|
|
|
// but opacity: 0 achieves the same visual effect while keeping
|
|
|
|
|
// the text selectable
|
|
|
|
|
});
|
|
|
|
|
// Stretch the text horizontally so that its selection matches the word on the image
|
|
|
|
|
const hex = toUtf16Hex(toVisualOrder(text));
|
|
|
|
|
const naturalWidth = (hex.length / 4) * fontSize * GLYPH_WIDTH / 1000;
|
|
|
|
|
const horizontalScale = wordWidth > 0 ? wordWidth / naturalWidth * 100 : 100;
|
|
|
|
|
|
|
|
|
|
operators.push(
|
|
|
|
|
setFontAndSize(fontName, fontSize),
|
|
|
|
|
setCharacterSqueeze(horizontalScale),
|
|
|
|
|
setTextMatrix(1, 0, 0, 1, pdfX, pdfY),
|
|
|
|
|
showText(PDFHexString.of(hex)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
operators.push(endText());
|
|
|
|
|
page.pushOperators(...operators);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Creates an accessible PDF by overlaying invisible text on top of page images.
|
|
|
|
@@ -78,8 +206,7 @@ const createAccessiblePdf = async (
|
|
|
|
|
|
|
|
|
|
const pdfDoc = await PDFDocument.create();
|
|
|
|
|
|
|
|
|
|
// Embed a standard font for the invisible text layer
|
|
|
|
|
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
|
|
|
const fontRef = embedGlyphLessFont(pdfDoc);
|
|
|
|
|
|
|
|
|
|
for (let pageIndex = 0; pageIndex < pageImages.length; pageIndex++) {
|
|
|
|
|
const pageImage = pageImages[pageIndex];
|
|
|
|
@@ -104,7 +231,7 @@ const createAccessiblePdf = async (
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add invisible text layer on top
|
|
|
|
|
addInvisibleTextLayer(page, pageOcr.lines, font, pageWidth, pageHeight);
|
|
|
|
|
addInvisibleTextLayer(page, pageOcr.lines, fontRef, pageHeight);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pdfDoc.save();
|
|
|
|
|