Files
ICON-CRM/server/src/cvPhoto.js
T
2026-09-14 16:55:34 +02:00

281 lines
9.6 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import zlib from 'node:zlib';
/*
* Das Bewerbungsfoto aus einem Lebenslauf holen.
*
* Viele Lebenslaeufe tragen oben rechts ein Portrait. Es von Hand nachzupflegen
* ist Fleissarbeit, die niemand macht - danach steht im CRM eine Kachelwand aus
* Initialen, obwohl das Foto in der Datei lag.
*
* Bewusst ohne zusaetzliches Paket:
*
* DOCX ist ein ZIP. Gebraucht wird genau eine Sache - die Dateien unter
* `word/media/` herausloesen. Das leistet das eingebaute zlib
* (`inflateRawSync`) zusammen mit dem Lesen des Zentralverzeichnisses.
* PDF speichert JPEG-Bilder mit `/DCTDecode` unveraendert im Datenstrom.
* Sie lassen sich deshalb an ihren eigenen Markierungen (FFD8 … FFD9)
* aus der Datei schneiden. Bilder in anderen Kodierungen (FlateDecode,
* also rohe Bitmaps) bleiben aussen vor: sie muessten neu kodiert
* werden, und dafuer braeuchte es eine Bildbibliothek.
*
* Verkleinert wird hier NICHT. Das macht der Browser beim Uebernehmen - dort
* liegt schon die Skalierung fuer alle anderen Fotos (src/lib/imageCompress.ts),
* und ein zweiter Weg mit eigener Bibliothek auf dem Server waere eine zweite
* Stelle, an der sich Ergebnisse unterscheiden koennen.
*/
/** Groesser wird nicht uebernommen - das waere ein Scan der ganzen Seite. */
const MAX_BYTES = 6 * 1024 * 1024;
/** Kleiner ist kein Portrait, sondern ein Symbol, ein Logo oder eine Linie. */
const MIN_SIDE = 100;
/**
* Sucht das wahrscheinlichste Portrait in der Datei.
*
* @param {string} filePath absoluter Pfad
* @param {string} originalName fuer die Endung
* @returns {Promise<{dataUrl: string, mime: string, width: number, height: number, bytes: number} | null>}
*/
export async function extractPhoto(filePath, originalName) {
const ext = path.extname(originalName || filePath).toLowerCase();
let candidates = [];
try {
const buffer = await fs.readFile(filePath);
if (ext === '.docx') candidates = imagesFromDocx(buffer);
else if (ext === '.pdf') candidates = imagesFromPdf(buffer);
} catch {
// Ein unleserliches Bild darf den Import nicht aufhalten - der Text ist
// das Wesentliche, das Foto die Zugabe.
return null;
}
const best = pickPortrait(candidates);
if (!best) return null;
return {
dataUrl: `data:${best.mime};base64,${best.data.toString('base64')}`,
mime: best.mime,
width: best.width,
height: best.height,
bytes: best.data.length,
};
}
/**
* Welches der gefundenen Bilder ist das Portrait?
*
* Die Reihenfolge in der Datei hilft nicht: in DOCX steht das Firmenlogo oft
* vor dem Foto. Entschieden wird deshalb ueber die Form.
*
* * zu klein - Symbol, Trennlinie, Aufzaehlungszeichen
* * sehr breit oder flach - Kopfzeile, Banner, Diagramm
* * hochkant bis leicht quer bleibt uebrig; davon das groesste
*
* Hochformat gewinnt gegen Querformat, auch wenn es kleiner ist: ein Portrait
* ist so gut wie immer hoeher als breit, ein Banner nie.
*/
function pickPortrait(images) {
const usable = images.filter((image) => {
if (!image.width || !image.height) return false;
if (image.data.length > MAX_BYTES) return false;
if (image.width < MIN_SIDE || image.height < MIN_SIDE) return false;
const ratio = image.width / image.height;
return ratio >= 0.4 && ratio <= 1.6;
});
if (usable.length === 0) return null;
return usable.sort((a, b) => {
const portraitA = a.height >= a.width ? 1 : 0;
const portraitB = b.height >= b.width ? 1 : 0;
if (portraitA !== portraitB) return portraitB - portraitA;
return b.width * b.height - a.width * a.height;
})[0];
}
/* ----------------------------------------------------------------- DOCX */
/**
* Die Bilder unter `word/media/` aus dem ZIP holen.
*
* Gelesen wird das Zentralverzeichnis am Dateiende - nur dort stehen die
* Namen zuverlaessig. Die lokalen Kopfsaetze davor koennen Groessenangaben
* nachreichen (Bit 3 der Flags), das Zentralverzeichnis nie.
*/
function imagesFromDocx(buffer) {
const images = [];
const endOffset = findEndOfCentralDirectory(buffer);
if (endOffset < 0) return images;
const entryCount = buffer.readUInt16LE(endOffset + 10);
let offset = buffer.readUInt32LE(endOffset + 16);
for (let index = 0; index < entryCount; index += 1) {
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) break;
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const nameLength = buffer.readUInt16LE(offset + 28);
const extraLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
const localOffset = buffer.readUInt32LE(offset + 42);
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
offset += 46 + nameLength + extraLength + commentLength;
if (!/^word\/media\//i.test(name)) continue;
if (compressedSize === 0 || compressedSize > MAX_BYTES) continue;
const data = readLocalEntry(buffer, localOffset, method, compressedSize);
if (!data) continue;
const info = describeImage(data);
if (info) images.push({ ...info, data });
}
return images;
}
/** Nutzdaten eines Eintrags ab seinem lokalen Kopfsatz. */
function readLocalEntry(buffer, localOffset, method, compressedSize) {
if (localOffset + 30 > buffer.length) return null;
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) return null;
const nameLength = buffer.readUInt16LE(localOffset + 26);
const extraLength = buffer.readUInt16LE(localOffset + 28);
const start = localOffset + 30 + nameLength + extraLength;
const slice = buffer.subarray(start, start + compressedSize);
try {
if (method === 0) return Buffer.from(slice); // unkomprimiert abgelegt
if (method === 8) return zlib.inflateRawSync(slice);
} catch {
return null;
}
return null;
}
/** Das "End of Central Directory"-Kennzeichen steht am Dateiende. */
function findEndOfCentralDirectory(buffer) {
// Der Kommentar am Ende darf 64 KB lang sein - weiter zurueck lohnt nicht.
const from = Math.max(0, buffer.length - 22 - 0xffff);
for (let index = buffer.length - 22; index >= from; index -= 1) {
if (buffer.readUInt32LE(index) === 0x06054b50) return index;
}
return -1;
}
/* ------------------------------------------------------------------ PDF */
/**
* JPEG-Bilder aus einem PDF schneiden.
*
* Mit `/DCTDecode` abgelegte Bilder stehen unveraendert in der Datei; ihre
* eigenen Markierungen begrenzen sie zuverlaessiger als ein halber
* PDF-Parser. Was dabei kein gueltiges JPEG ergibt, faellt bei der
* Groessenpruefung ohnehin durch.
*/
function imagesFromPdf(buffer) {
const images = [];
let index = 0;
while (index < buffer.length - 3) {
// Start of Image, gefolgt von einer Markierung (FFE0-FFEF oder FFDB).
if (buffer[index] === 0xff && buffer[index + 1] === 0xd8 && buffer[index + 2] === 0xff) {
const end = findJpegEnd(buffer, index + 2);
if (end > index) {
const data = buffer.subarray(index, end);
const info = describeImage(data);
if (info) images.push({ ...info, data: Buffer.from(data) });
index = end;
continue;
}
}
index += 1;
}
return images;
}
/** Laeuft die Markierungen entlang bis zum "End of Image". */
function findJpegEnd(buffer, start) {
let index = start;
while (index < buffer.length - 1) {
if (buffer[index] !== 0xff) {
index += 1;
continue;
}
const marker = buffer[index + 1];
if (marker === 0xd9) return index + 2; // EOI
if (marker === 0xda) {
// Beginn der Bilddaten: darin stehen beliebige Bytes, nur FF00 und die
// Restart-Markierungen sind erlaubt. Bis zum naechsten echten Marker.
index += 2;
while (index < buffer.length - 1) {
if (buffer[index] === 0xff && buffer[index + 1] !== 0x00 &&
!(buffer[index + 1] >= 0xd0 && buffer[index + 1] <= 0xd7)) {
break;
}
index += 1;
}
continue;
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
index += 2;
continue;
}
if (index + 3 >= buffer.length) return -1;
index += 2 + buffer.readUInt16BE(index + 2);
}
return -1;
}
/* --------------------------------------------------------------- Format */
/**
* Art und Abmessungen eines Bildes - ohne es zu dekodieren.
*
* Gebraucht werden sie fuer die Auswahl: ohne Abmessungen liesse sich ein
* Portrait nicht von einem Logo unterscheiden.
*/
function describeImage(data) {
if (data.length < 24) return null;
// PNG: Abmessungen stehen im IHDR direkt am Anfang.
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
return { mime: 'image/png', width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
}
// JPEG: die Abmessungen stehen im Start-of-Frame (C0-CF, ohne C4, C8, CC).
if (data[0] === 0xff && data[1] === 0xd8) {
let index = 2;
while (index < data.length - 9) {
if (data[index] !== 0xff) {
index += 1;
continue;
}
const marker = data[index + 1];
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return {
mime: 'image/jpeg',
height: data.readUInt16BE(index + 5),
width: data.readUInt16BE(index + 7),
};
}
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
index += 2;
continue;
}
if (index + 3 >= data.length) break;
index += 2 + data.readUInt16BE(index + 2);
}
return null;
}
return null;
}