Compare commits

..
Author SHA1 Message Date
Laurent Cozic 98a55d175a update 2026-11-11 18:48:25 +00:00
Laurent Cozic 54a645bfec update 2026-11-11 18:13:02 +00:00
9 changed files with 208 additions and 55 deletions

No files matched your search

+1
View File
@@ -1839,6 +1839,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/createAccessiblePdf.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/glyphLessFont.js
packages/lib/services/ocr/utils/types.js
packages/lib/services/plugins/BasePlatformImplementation.js
packages/lib/services/plugins/BasePluginRunner.js
+1
View File
@@ -1865,6 +1865,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/createAccessiblePdf.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/glyphLessFont.js
packages/lib/services/ocr/utils/types.js
packages/lib/services/plugins/BasePlatformImplementation.js
packages/lib/services/plugins/BasePluginRunner.js
@@ -6,7 +6,7 @@ import Note from '@joplin/lib/models/Note';
import CommandService from '@joplin/lib/services/CommandService';
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
import { ChatRole } from '@joplin/lib/services/ai/types';
import { setupDatabaseAndSynchronizer, switchClient, withWarningSilenced } from '@joplin/lib/testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import appReducer, { AiChatMessage, createAppDefaultState, createAppDefaultWindowState } from '../../app.reducer';
import ChatPanel from './ChatPanel';
import { WindowIdContext } from '../NewWindowOrIFrame';
@@ -186,10 +186,8 @@ describe('ChatPanel', () => {
jest.mocked(runNoteChat).mockRejectedValue(new Error('Network down'));
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A', dispatch: jest.fn() });
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
await withWarningSilenced(/Chat failed/, async () => {
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
}, { requireWarning: true });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
});
it('should assign an ID before messages are added to a new conversation', async () => {
@@ -17,8 +17,6 @@ describe('PromptDialog', () => {
answer: null,
});
instance.state = { visible: true, answer: 'some-answer' };
// The instance is never mounted, so a real setState() call would trigger a React warning
instance.setState = jest.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private property for test setup
(instance as unknown as any).menuIsOpened_ = menuIsOpened;
@@ -397,6 +397,33 @@ describe('OcrService', () => {
expect(pdfBytes.length).toBeGreaterThan(jpegBuffer.length * 1.5);
});
it.each([
{ words: ['ı', 'Мир', '日本語'], rtl: false },
{ words: ['אור', 'חדש'], rtl: true },
{ words: ['نور', 'باب', 'عام2024'], rtl: true },
])('should create an accessible PDF with text that can be extracted: $words', async ({ words, rtl }) => {
const jpegBuffer = await fs.readFile(`${supportDir}/photo.jpg`);
// Like Tesseract, words are in logical order, so right-to-left words are laid out from the right
const ocrDetails: PdfOcrDetails = {
version: 1,
pages: [{
lines: [{ words: words.map((t, i) => {
const x = rtl ? 700 - (i + 1) * 150 : 20 + i * 150;
return { t, bb: [x, x + 130, 20, 60] };
}) }],
}],
};
const pdfBytes = await createAccessiblePdf([{ buffer: jpegBuffer, width: 800, height: 400 }], JSON.stringify(ocrDetails));
const pdfPath = join(Setting.value('tempDir'), 'accessible.pdf');
await fs.writeFile(pdfPath, pdfBytes);
const textByPage = await shim.pdfExtractEmbeddedText(pdfPath);
expect(textByPage.length).toBe(1);
expect(textByPage[0].split(/\s+/)).toEqual(words);
});
it('should truncate long OCR text', async () => {
const service = newOcrService();
service.testing_setOcrMaxSize(10);
@@ -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();
@@ -0,0 +1,7 @@
// Minimal TrueType font generated with fontTools' FontBuilder. Same idea as Tesseract's GlyphLessFont,
// used for invisible OCR text. Glyph 1 is a 500x1000 rectangle, with ascent 800 and descent -200.
// It's never drawn (render mode 3), but macOS PDFKit (Preview) needs a non-zero descent and a glyph
// outline to compute text bounds, otherwise the text can't be selected.
const glyphLessFontBase64 = 'AAEAAAAKAIAAAwAgT1MvMkUAQ7AAAAEoAAAAYGNtYXAADABzAAABkAAAADRnbHlmA24B+AAAAcwAAAAaaGVhZC9AAeoAAACsAAAANmhoZWEFFgEuAAAA5AAAACRobXR4AfQAAAAAAYgAAAAGbG9jYQANAAAAAAHEAAAABm1heHAABAAGAAABCAAAACBuYW1lGZ8ZNAAAAegAAABycG9zdJ5/ds8AAAJcAAAALQABAAAAAQAAN7DqNF8PPPUAAwPoAAAAAOcaX1kAAAAA5xpfWQAA/zgB9AMgAAAAAwACAAAAAAAAAAEAAAMg/zgAAAH0AAAAAAH0AAEAAAAAAAAAAAAAAAAAAAABAAEAAAACAAQAAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwH0AZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAPz8/PwAAACAAIAMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAAAAAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAAAIP//AAAAIP///+EAAQAAAAAAAAAAAA0AAAABAAD/OAH0AyAAAwAAFREhEQH0yAPo/BgAAAAAAAAEADYAAQAAAAAAAQANAAAAAQAAAAAAAgAHAA0AAwABBAkAAQAaABQAAwABBAkAAgAOAC5HbHlwaExlc3NGb250UmVndWxhcgBHAGwAeQBwAGgATABlAHMAcwBGAG8AbgB0AFIAZQBnAHUAbABhAHIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAECBmdseXBoMQAAAA==';
export default glyphLessFontBase64;
+4
View File
@@ -378,3 +378,7 @@ instr
mixmark
Hrvatski
Defense
glyphless
bfrange
flate
Thaana
+10 -20
View File
@@ -1,35 +1,20 @@
import { readFile } from 'fs/promises';
import { createTempDir } from './fs.test';
import { IpcMessageHandler, IpcServer, newHttpError, sendMessage, startServer as startServerBase, stopServer } from './ipc';
import { newHttpError, sendMessage, startServer, stopServer } from './ipc';
const getRandomPort = () => {
// Stay below the OS ephemeral port ranges (Linux: 32768+, macOS/Windows: 49152+), otherwise an outgoing
// connection may hold the port, which `tcp-port-used` reports as free but `listen()` rejects with EADDRINUSE.
return Math.floor(Math.random() * 20000) + 10000;
};
let startedServers: IpcServer[] = [];
const startServer = async (startPort: number, secretKeyFilePath: string, messageHandler: IpcMessageHandler) => {
const server = await startServerBase(startPort, secretKeyFilePath, messageHandler);
startedServers.push(server);
return server;
return Math.floor(Math.random() * (65535 - 20000 + 1)) + 20000;
};
describe('ipc', () => {
afterEach(async () => {
for (const server of startedServers) await stopServer(server);
startedServers = [];
});
it('should send and receive messages', async () => {
const tempDir = await createTempDir();
const secretFilePath = `${tempDir}/secret.txt`;
const serverPort1 = getRandomPort();
const serverPort2 = serverPort1 + 5;
await startServer(serverPort1, secretFilePath, async (request) => {
const server1 = await startServer(serverPort1, secretFilePath, async (request) => {
if (request.action === 'testing') {
return {
text: 'hello1',
@@ -39,7 +24,7 @@ describe('ipc', () => {
throw newHttpError(404);
});
await startServer(serverPort2, secretFilePath, async (request) => {
const server2 = await startServer(serverPort2, secretFilePath, async (request) => {
if (request.action === 'testing') {
return {
text: 'hello2',
@@ -98,6 +83,9 @@ describe('ipc', () => {
{ port: serverPort2, response: { text: 'hello2' } },
]);
}
await stopServer(server1);
await stopServer(server2);
});
it('should not process message if secret is invalid', async () => {
@@ -105,7 +93,7 @@ describe('ipc', () => {
const secretFilePath = `${tempDir}/secret.txt`;
const serverPort = getRandomPort();
await startServer(serverPort, secretFilePath, async (request) => {
const server = await startServer(serverPort, secretFilePath, async (request) => {
if (request.action === 'testing') {
return {
text: 'hello1',
@@ -136,6 +124,8 @@ describe('ipc', () => {
expect(responses.length).toBe(1);
}
await stopServer(server);
});
});