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; }