Quellcode des ICON CRM (3)
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user