281 lines
9.6 KiB
JavaScript
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;
|
|
}
|