src / pdfTranscribe.ts
src / pdfTranscribe.ts
// Orchestration de la transcription PDF → markdown, à l'usage interne de kwikines
// (kwikines_ingest / kwikines_read) : rasterisation page par page puis transcription
// par le modèle de vision. Nécessite un modèle de vision chargé dans LM Studio (en
// plus du chat et de l'embedding).
import type { LMStudioClient } from "@lmstudio/sdk";
import { openPdf, parsePageRange } from "./pdfRender";
import { pickVisionModel, transcribePage } from "./vlClient";
import { concatPages } from "./pdfMarkdown";
export interface PdfTranscribeOptions {
renderScale: number;
maxPages: number; // 0 = unlimited
language: string;
style: string; // "faithful" | "clean" | "structured"
vlModelOverride: string;
pages?: string; // optional 1-indexed page-range spec, e.g. "1-20", "3,5,7"
}
export interface PdfTranscribeContext {
status: (s: string) => void;
signal: AbortSignal;
}
export interface PdfTranscribeResult {
markdown: string;
model: string;
pagesProcessed: number;
pagesFailed: number[];
warnings: string[];
}
/** Transcribe a whole PDF to markdown by rasterising each page and driving the
* loaded vision-language model. Returns { error } if no VL model is available
* or nothing could be transcribed. */
export async function transcribePdf(
client: LMStudioClient,
absPath: string,
buffer: Buffer,
opts: PdfTranscribeOptions,
ctx: PdfTranscribeContext,
): Promise<PdfTranscribeResult | { error: string }> {
const warnings: string[] = [];
ctx.status("Ouverture du PDF…");
let doc;
try {
doc = await openPdf(buffer);
} catch (e: unknown) {
return { error: `Failed to open PDF: ${e instanceof Error ? e.message : String(e)}` };
}
if (doc.isEncrypted) {
return { error: "PDF chiffré (protégé par mot de passe) — non supporté." };
}
if (doc.numPages === 0) {
await doc.destroy();
return { error: "Le PDF ne contient aucune page." };
}
let targetPages: number[];
if (opts.pages && opts.pages.trim()) {
const parsed = parsePageRange(opts.pages, doc.numPages);
if (parsed === null) {
warnings.push(`Plage de pages "${opts.pages}" illisible — toutes les pages prises.`);
targetPages = range(1, doc.numPages);
} else {
targetPages = parsed;
}
} else {
targetPages = range(1, doc.numPages);
}
if (opts.maxPages > 0 && targetPages.length > opts.maxPages) {
warnings.push(`Tronqué de ${targetPages.length} à ${opts.maxPages} pages (plafond pdfMaxPages — précise un paramètre 'pages' ou augmente le plafond).`);
targetPages = targetPages.slice(0, opts.maxPages);
}
const totalPages = targetPages.length;
ctx.status("Sélection du modèle de vision…");
const picked = await pickVisionModel(client, opts.vlModelOverride);
if ("error" in picked) {
await doc.destroy();
return { error: picked.error };
}
const transcriptions: Array<{ pageNumber: number; markdown: string }> = [];
const failed: number[] = [];
let aborted = false;
for (let i = 0; i < targetPages.length; i++) {
if (ctx.signal.aborted) {
aborted = true;
warnings.push(`Interrompu Ă la page ${i + 1}/${totalPages}.`);
break;
}
const pageNumber = targetPages[i];
try {
ctx.status(`Page ${i + 1}/${totalPages} (p.${pageNumber}) — rendu @ ${opts.renderScale}…`);
const rendered = await doc.renderPage(pageNumber, opts.renderScale);
if (ctx.signal.aborted) {
aborted = true;
warnings.push(`Interrompu Ă la page ${i + 1}/${totalPages}.`);
break;
}
ctx.status(`Page ${i + 1}/${totalPages} (p.${pageNumber}) — transcription via ${picked.identifier}…`);
const md = await transcribePage({
client,
model: picked.model,
pngBase64: rendered.pngBase64,
pageNumber,
totalPages,
language: opts.language,
style: opts.style,
abortSignal: ctx.signal,
});
transcriptions.push({ pageNumber, markdown: md });
} catch (e: unknown) {
if (ctx.signal.aborted) {
aborted = true;
warnings.push(`Interrompu Ă la page ${i + 1}/${totalPages}.`);
break;
}
failed.push(pageNumber);
warnings.push(`Page ${pageNumber} échouée : ${e instanceof Error ? e.message : String(e)}`);
}
}
await doc.destroy();
if (transcriptions.length === 0) {
return {
error: aborted
? "Interrompu avant qu'aucune page n'ait pu ĂŞtre transcrite."
: failed.length > 0
? `Les ${failed.length} pages ont échoué. Vérifie qu'un vrai modèle de vision est chargé.`
: "Aucune page transcrite.",
};
}
const markdown = concatPages({
pages: transcriptions,
includeSeparators: true,
cleanRepeatedHeaders: opts.style === "clean",
});
return {
markdown,
model: picked.identifier,
pagesProcessed: transcriptions.length,
pagesFailed: failed,
warnings,
};
}
function range(start: number, end: number): number[] {
const out: number[] = [];
for (let i = start; i <= end; i++) out.push(i);
return out;
}
// Orchestration de la transcription PDF → markdown, à l'usage interne de kwikines
// (kwikines_ingest / kwikines_read) : rasterisation page par page puis transcription
// par le modèle de vision. Nécessite un modèle de vision chargé dans LM Studio (en
// plus du chat et de l'embedding).
import type { LMStudioClient } from "@lmstudio/sdk";
import { openPdf, parsePageRange } from "./pdfRender";
import { pickVisionModel, transcribePage } from "./vlClient";
import { concatPages } from "./pdfMarkdown";
export interface PdfTranscribeOptions {
renderScale: number;
maxPages: number; // 0 = unlimited
language: string;
style: string; // "faithful" | "clean" | "structured"
vlModelOverride: string;
pages?: string; // optional 1-indexed page-range spec, e.g. "1-20", "3,5,7"
}
export interface PdfTranscribeContext {
status: (s: string) => void;
signal: AbortSignal;
}
export interface PdfTranscribeResult {
markdown: string;
model: string;
pagesProcessed: number;
pagesFailed: number[];
warnings: string[];
}
/** Transcribe a whole PDF to markdown by rasterising each page and driving the
* loaded vision-language model. Returns { error } if no VL model is available
* or nothing could be transcribed. */
export async function transcribePdf(
client: LMStudioClient,
absPath: string,
buffer: Buffer,
opts: PdfTranscribeOptions,
ctx: PdfTranscribeContext,
): Promise<PdfTranscribeResult | { error: string }> {
const warnings: string[] = [];
ctx.status("Ouverture du PDF…");
let doc;
try {
doc = await openPdf(buffer);
} catch (e: unknown) {
return { error: `Failed to open PDF: ${e instanceof Error ? e.message : String(e)}` };
}
if (doc.isEncrypted) {
return { error: "PDF chiffré (protégé par mot de passe) — non supporté." };
}
if (doc.numPages === 0) {
await doc.destroy();
return { error: "Le PDF ne contient aucune page." };
}
let targetPages: number[];
if (opts.pages && opts.pages.trim()) {
const parsed = parsePageRange(opts.pages, doc.numPages);
if (parsed === null) {
warnings.push(`Plage de pages "${opts.pages}" illisible — toutes les pages prises.`);
targetPages = range(1, doc.numPages);
} else {
targetPages = parsed;
}
} else {
targetPages = range(1, doc.numPages);
}
if (opts.maxPages > 0 && targetPages.length > opts.maxPages) {
warnings.push(`Tronqué de ${targetPages.length} à ${opts.maxPages} pages (plafond pdfMaxPages — précise un paramètre 'pages' ou augmente le plafond).`);
targetPages = targetPages.slice(0, opts.maxPages);
}
const totalPages = targetPages.length;
ctx.status("Sélection du modèle de vision…");
const picked = await pickVisionModel(client, opts.vlModelOverride);
if ("error" in picked) {
await doc.destroy();
return { error: picked.error };
}
const transcriptions: Array<{ pageNumber: number; markdown: string }> = [];
const failed: number[] = [];
let aborted = false;
for (let i = 0; i < targetPages.length; i++) {
if (ctx.signal.aborted) {
aborted = true;
warnings.push(`Interrompu Ă la page ${i + 1}/${totalPages}.`);
break;
}
const pageNumber = targetPages[i];
try {
ctx.status(`Page ${i + 1}/${totalPages} (p.${pageNumber}) — rendu @ ${opts.renderScale}…`);
const rendered = await doc.renderPage(pageNumber, opts.renderScale);
if (ctx.signal.aborted) {
aborted = true;
warnings.push(`Interrompu Ă la page ${i + 1}/${totalPages}.`);
break;
}
ctx.status(`Page ${i + 1}/${totalPages} (p.${pageNumber}) — transcription via ${picked.identifier}…`);
const md = await transcribePage({
client,
model: picked.model,
pngBase64: rendered.pngBase64,
pageNumber,
totalPages,
language: opts.language,
style: opts.style,
abortSignal: ctx.signal,
});
transcriptions.push({ pageNumber, markdown: md });
} catch (e: unknown) {
if (ctx.signal.aborted) {
aborted = true;
warnings.push(`Interrompu Ă la page ${i + 1}/${totalPages}.`);
break;
}
failed.push(pageNumber);
warnings.push(`Page ${pageNumber} échouée : ${e instanceof Error ? e.message : String(e)}`);
}
}
await doc.destroy();
if (transcriptions.length === 0) {
return {
error: aborted
? "Interrompu avant qu'aucune page n'ait pu ĂŞtre transcrite."
: failed.length > 0
? `Les ${failed.length} pages ont échoué. Vérifie qu'un vrai modèle de vision est chargé.`
: "Aucune page transcrite.",
};
}
const markdown = concatPages({
pages: transcriptions,
includeSeparators: true,
cleanRepeatedHeaders: opts.style === "clean",
});
return {
markdown,
model: picked.identifier,
pagesProcessed: transcriptions.length,
pagesFailed: failed,
warnings,
};
}
function range(start: number, end: number): number[] {
const out: number[] = [];
for (let i = start; i <= end; i++) out.push(i);
return out;
}