Files
parenthese/frontend/src/utils/zipReader.js
T
IliesAllaliandClaude Opus 5.5 5447965243 feat(import): importer un zip qui contient le GEDCOM et ses photos
Le zip est lu dans le navigateur, sans bibliotheque (table des matieres puis
DecompressionStream, fichier par fichier) : le .ged le plus haut s'importe, GEDZIP
compris, et les photos et actes passent directement a l'etape des visages, rapproches
comme un dossier et decompresses seulement a l'envoi. Messages clairs pour un zip sans
.ged, protege par mot de passe ou trop gros. Corrige aussi « citait 1 autres fichiers ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:40:32 +02:00

128 lines
6.1 KiB
JavaScript

// Lecture d'un zip dans le navigateur, sans bibliothèque : la table des matières en fin de fichier, puis
// chaque fichier décompressé à la demande (DecompressionStream). Rien n'est chargé en mémoire tant qu'on ne
// demande pas un fichier, ce qui garde légers les zips de plusieurs centaines de photos.
//
// Pris en charge : fichiers stockés (méthode 0) ou compressés (méthode 8, « deflate »), noms en UTF-8 ou en
// page de code DOS. Refusés avec un message : zip chiffré, zip64 (archives de plus de 4 Go ou de 65 535
// fichiers), autres méthodes de compression.
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_SIGNATURE = 0x02014b50
const LOCAL_SIGNATURE = 0x04034b50
const MAX_EOCD_SEARCH = 65_557 // 22 octets + commentaire de 65 535 au plus
export class ZipError extends Error {
constructor(code, message) {
super(message || code)
this.name = 'ZipError'
this.code = code
}
}
async function readBytes(file, start, end) {
return new Uint8Array(await file.slice(start, end).arrayBuffer())
}
// Page de code 437 (noms de fichiers des zips faits sous Windows sans le drapeau UTF-8) : les accents
// français y ont leur place fixe ; le reste des octets hauts devient un caractère neutre
const CP437_HIGH = 'ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ '
function decodeName(bytes, utf8) {
if (utf8) return new TextDecoder('utf-8').decode(bytes)
let out = ''
for (const byte of bytes) out += byte < 128 ? String.fromCharCode(byte) : CP437_HIGH[byte - 128]
return out
}
export function isZipSignature(bytes) {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 0x03 || bytes[2] === 0x05) && (bytes[3] === 0x04 || bytes[3] === 0x06)
}
export async function isZipFile(file) {
if (!file) return false
if (/\.(zip|gdz)$/i.test(file.name || '')) return true
return isZipSignature(await readBytes(file, 0, 4))
}
// Ouvre un zip : renvoie ses fichiers (sans les dossiers) et de quoi lire chacun
export async function openZip(file) {
const tailStart = Math.max(0, file.size - MAX_EOCD_SEARCH)
const tail = await readBytes(file, tailStart, file.size)
const view = new DataView(tail.buffer, tail.byteOffset, tail.byteLength)
let eocd = -1
for (let i = tail.length - 22; i >= 0; i -= 1) {
if (view.getUint32(i, true) === EOCD_SIGNATURE) { eocd = i; break }
}
if (eocd < 0) throw new ZipError('not_a_zip')
const count = view.getUint16(eocd + 10, true)
const centralSize = view.getUint32(eocd + 12, true)
const centralOffset = view.getUint32(eocd + 16, true)
if (count === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff) throw new ZipError('zip64')
const central = await readBytes(file, centralOffset, centralOffset + centralSize)
const cv = new DataView(central.buffer, central.byteOffset, central.byteLength)
const entries = []
let p = 0
for (let n = 0; n < count; n += 1) {
if (p + 46 > central.length || cv.getUint32(p, true) !== CENTRAL_SIGNATURE) throw new ZipError('corrupt')
const flags = cv.getUint16(p + 8, true)
const method = cv.getUint16(p + 10, true)
const compressedSize = cv.getUint32(p + 20, true)
const size = cv.getUint32(p + 24, true)
const nameLength = cv.getUint16(p + 28, true)
const extraLength = cv.getUint16(p + 30, true)
const commentLength = cv.getUint16(p + 32, true)
const localOffset = cv.getUint32(p + 42, true)
const path = decodeName(central.subarray(p + 46, p + 46 + nameLength), Boolean(flags & 0x0800)).replace(/\\/g, '/')
p += 46 + nameLength + extraLength + commentLength
if (path.endsWith('/')) continue
if (compressedSize === 0xffffffff || size === 0xffffffff || localOffset === 0xffffffff) throw new ZipError('zip64')
entries.push({ path, name: path.split('/').pop(), size, compressedSize, method, flags, localOffset })
}
const read = async (entry) => {
if (entry.flags & 0x0001) throw new ZipError('encrypted')
const local = await readBytes(file, entry.localOffset, entry.localOffset + 30)
const lv = new DataView(local.buffer, local.byteOffset, local.byteLength)
if (lv.getUint32(0, true) !== LOCAL_SIGNATURE) throw new ZipError('corrupt')
const dataStart = entry.localOffset + 30 + lv.getUint16(26, true) + lv.getUint16(28, true)
const raw = file.slice(dataStart, dataStart + entry.compressedSize)
if (entry.method === 0) return raw
if (entry.method !== 8) throw new ZipError('unsupported_method')
return new Response(raw.stream().pipeThrough(new DecompressionStream('deflate-raw'))).blob()
}
return { entries, read }
}
const MIME_BY_EXTENSION = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', heic: 'image/heic',
tif: 'image/tiff', tiff: 'image/tiff', bmp: 'image/bmp',
pdf: 'application/pdf',
mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/mp4', webm: 'video/webm', avi: 'video/x-msvideo',
mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', ogg: 'audio/ogg',
}
export const extensionOf = (name) => (String(name).match(/\.([a-z0-9]{1,5})$/i)?.[1] || '').toLowerCase()
export const mimeTypeOf = (name) => MIME_BY_EXTENSION[extensionOf(name)] || ''
// Fichier du zip qu'on peut rapprocher comme un fichier de dossier (nom, chemin), et ne lire qu'au moment
// de l'envoyer : toFile() le décompresse et renvoie un vrai File
export function lazyZipFile(zip, entry) {
return {
name: entry.name,
size: entry.size,
type: mimeTypeOf(entry.name),
webkitRelativePath: entry.path,
async toFile() {
const blob = await zip.read(entry)
return new File([blob], entry.name, { type: mimeTypeOf(entry.name) || blob.type || '' })
},
}
}
// Un fichier venu d'un dossier reste tel quel, un fichier du zip est lu à ce moment-là
export async function materializeFile(file) {
return file && typeof file.toFile === 'function' ? file.toFile() : file
}