diff --git a/backend/src/lib/gedcom-dates.ts b/backend/src/lib/gedcom-dates.ts new file mode 100644 index 0000000..41ffab9 --- /dev/null +++ b/backend/src/lib/gedcom-dates.ts @@ -0,0 +1,108 @@ +// Dates GEDCOM : « 15 JAN 1900 », mais aussi « ABT 1900 » (vers), « BEF », « AFT », « BET 1794 AND 1796 », +// « FROM … TO … », « INT 1900 (texte) », les doubles années « 12 FEB 1700/01 », le calendrier julien +// (« @#DJULIAN@ 1563 ») et le calendrier républicain (« @#DFRENCH R@ 18 BRUM 8 », converti en grégorien). +// On renvoie une date ISO (année, année-mois ou jour), sa précision et le texte d'origine. + +export type GedcomDatePrecision = 'exact' | 'about' | 'estimated' | 'calculated' | 'before' | 'after' | 'between' | 'from' | 'to' | 'interpreted' + +export type ParsedGedcomDate = { + iso: string + precision: GedcomDatePrecision + endIso: string | null + text: string +} + +const MONTHS: Record = { + JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12, +} + +// Calendrier républicain : début de chaque année (an I à XIV), années sextiles III, VII et XI +const FRENCH_YEAR_START: Record = { + 1: '1792-09-22', 2: '1793-09-22', 3: '1794-09-22', 4: '1795-09-23', 5: '1796-09-22', 6: '1797-09-22', + 7: '1798-09-22', 8: '1799-09-23', 9: '1800-09-23', 10: '1801-09-23', 11: '1802-09-23', 12: '1803-09-24', + 13: '1804-09-23', 14: '1805-09-23', +} +const FRENCH_MONTHS: Record = { + VEND: 1, BRUM: 2, FRIM: 3, NIVO: 4, PLUV: 5, VENT: 6, GERM: 7, FLOR: 8, PRAI: 9, MESS: 10, THER: 11, FRUC: 12, COMP: 13, +} + +const pad = (n: number) => String(n).padStart(2, '0') + +function frenchToIso(day: number | null, month: number | null, year: number): string | null { + const start = FRENCH_YEAR_START[year] + if (!start) return null + if (!month) return start.slice(0, 4) + const offset = (month - 1) * 30 + ((day ?? 1) - 1) + const date = new Date(`${start}T00:00:00Z`) + date.setUTCDate(date.getUTCDate() + offset) + const iso = date.toISOString().slice(0, 10) + return day ? iso : iso.slice(0, 7) +} + +// Une date simple, après le qualificatif : jour, mois, année, avec son calendrier +function parseSimple(raw: string): string | null { + let text = raw.trim().toUpperCase() + let calendar = 'GREGORIAN' + const escape = text.match(/^@#D([A-Z ]+?)@\s*/) + if (escape) { + calendar = escape[1].trim() + text = text.slice(escape[0].length) + } + text = text.replace(/\s+B\.?C\.?$/, '').trim() + const parts = text.split(/\s+/).filter(Boolean) + if (parts.length === 0 || parts.length > 3) return null + + if (calendar === 'FRENCH R') { + const year = Number(parts[parts.length - 1]) + const month = parts.length >= 2 ? FRENCH_MONTHS[parts[parts.length - 2]] ?? null : null + const day = parts.length === 3 ? Number(parts[0]) : null + if (!Number.isInteger(year) || (parts.length >= 2 && !month) || (day !== null && !(day >= 1 && day <= 30))) return null + return frenchToIso(day, month, year) + } + if (calendar !== 'GREGORIAN' && calendar !== 'JULIAN') return null + + // Double année : « 1700/01 » = 1701 en style moderne + const yearMatch = parts[parts.length - 1].match(/^(\d{3,4})(?:\/(\d{1,2}))?$/) + if (!yearMatch) return null + let year = Number(yearMatch[1]) + if (yearMatch[2]) { + const suffix = yearMatch[2] + year = Number(String(year).slice(0, String(year).length - suffix.length) + suffix) + if (year < Number(yearMatch[1])) year += 10 ** suffix.length + } + const yearText = String(year).padStart(4, '0') + if (parts.length === 1) return yearText + const month = MONTHS[parts[parts.length - 2]] + if (!month) return null + if (parts.length === 2) return `${yearText}-${pad(month)}` + const day = Number(parts[0]) + if (!(day >= 1 && day <= 31)) return null + return `${yearText}-${pad(month)}-${pad(day)}` +} + +export function parseGedcomDate(value: string | null | undefined): ParsedGedcomDate | null { + const text = (value ?? '').trim() + if (!text) return null + const upper = text.toUpperCase() + + const range = upper.match(/^BET\s+(.+?)\s+AND\s+(.+)$/) ?? upper.match(/^FROM\s+(.+?)\s+TO\s+(.+)$/) + if (range) { + const iso = parseSimple(range[1]) + const endIso = parseSimple(range[2]) + if (!iso && !endIso) return null + return { iso: (iso ?? endIso)!, endIso: iso ? endIso : null, precision: upper.startsWith('BET') ? 'between' : 'from', text } + } + + const qualifiers: Record = { + ABT: 'about', EST: 'estimated', CAL: 'calculated', BEF: 'before', AFT: 'after', FROM: 'from', TO: 'to', INT: 'interpreted', + } + const qualified = upper.match(/^(ABT|EST|CAL|BEF|AFT|FROM|TO|INT)\.?\s+(.+)$/) + if (qualified) { + const rest = qualified[1] === 'INT' ? qualified[2].replace(/\s*\(.*\)\s*$/, '') : qualified[2] + const iso = parseSimple(rest) + return iso ? { iso, endIso: null, precision: qualifiers[qualified[1]], text } : null + } + + const iso = parseSimple(upper.replace(/\s*\(.*\)\s*$/, '')) + return iso ? { iso, endIso: null, precision: 'exact', text } : null +} diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts index d8c18a1..efb96af 100644 --- a/backend/src/lib/gedcom-photos.ts +++ b/backend/src/lib/gedcom-photos.ts @@ -7,6 +7,7 @@ export type GedcomPhotoNode = { xref?: string tag: string value: string + rawValue?: string children: GedcomPhotoNode[] } @@ -124,10 +125,10 @@ export function localMediaPath(value: string): string | null { function joinedText(node: GedcomPhotoNode | undefined): string { if (!node) return '' - let text = node.value + let text = node.rawValue ?? node.value for (const c of node.children) { - if (c.tag === 'CONT') text += `\n${c.value}` - else if (c.tag === 'CONC') text += c.value + if (c.tag === 'CONT') text += `\n${c.rawValue ?? c.value}` + else if (c.tag === 'CONC') text += c.rawValue ?? c.value } return text } diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 7fd8c37..68f5842 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -2,7 +2,9 @@ import type { FastifyPluginAsync } from 'fastify' import { z } from 'zod' import { UPLOAD_BODY_LIMIT } from '../config/env.js' -import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson } from '../lib/gedcom-photos.js' +import { parseGedcomDate } from '../lib/gedcom-dates.js' +import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../lib/gedcom-photos.js' +import type { GedcomRecords } from '../lib/gedcom-photos.js' import type { MembershipRole } from '../types/auth.js' import { nextSiblingOrder } from '../lib/sibling-order.js' @@ -69,6 +71,9 @@ type GedcomNode = { xref?: string tag: string value: string + // Valeur telle qu'écrite, espaces compris : une ligne coupée en CONC se recolle sans rien perdre + // (« pendant » + « 2 CONC trente-cinq ans » ou « pendant » + « 2 CONC trente-cinq ans ») + rawValue?: string children: GedcomNode[] } @@ -79,20 +84,21 @@ export function parseGedcom(text: string): GedcomNode[] { let nodeCount = 0 for (const raw of lines) { - const line = raw.trim() - if (!line || line.length > MAX_LINE_LENGTH) continue + const line = raw.replace(/^[\s]+/, '') + if (!line.trim() || line.length > MAX_LINE_LENGTH) continue nodeCount += 1 if (nodeCount > MAX_GEDCOM_NODES) throw new GedcomTooLargeError() - const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:\s+(.*))?$/) + const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:[ \t](.*))?$/) if (!match) continue const level = parseInt(match[1], 10) const xref = match[2] ? match[2].slice(1, -1) : undefined const tag = match[3] - const value = match[4]?.trim() ?? '' + const rawValue = match[4] ?? '' + const value = rawValue.trim() - const node: GedcomNode = { level, xref, tag, value, children: [] } + const node: GedcomNode = { level, xref, tag, value, rawValue, children: [] } while (stack.length > 0 && stack[stack.length - 1].level >= level) { stack.pop() @@ -120,36 +126,30 @@ function findAllChildren(node: GedcomNode | undefined, tag: string): GedcomNode[ // ─── Date Conversion ──────────────────────────────────────────────────────── -const MONTH_MAP: Record = { - JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06', - JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12', -} - const MONTHS_ARR = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] +// Lecture complète dans lib/gedcom-dates.ts : vers, avant, après, entre, julien, républicain, double année function gedcomDateToIso(value: string): string | null { - if (!value) return null - // Remove qualifiers (ABT, BEF, AFT, EST, CAL, etc.) - const cleaned = value.trim().toUpperCase().replace(/^(ABT|BEF|AFT|EST|CAL|INT|FROM|TO|BET|AND)\s+/, '') + return parseGedcomDate(value)?.iso ?? null +} - // "15 JAN 1900" → "1900-01-15" - const ddMmmYyyy = cleaned.match(/^(\d{1,2})\s+([A-Z]{3})\s+(\d{4})$/) - if (ddMmmYyyy) { - const month = MONTH_MAP[ddMmmYyyy[2]] - if (month) return `${ddMmmYyyy[3]}-${month}-${ddMmmYyyy[1].padStart(2, '0')}` +// Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC), +// RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide. +export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null { + const texts: string[] = [] + for (const note of findAllChildren(indi, 'NOTE')) { + const pointer = note.value.match(/^@([^@]+)@$/)?.[1] + const source = pointer ? records.note.get(pointer) : note + if (!source) continue + let text = (pointer ? source.rawValue ?? source.value : note.rawValue ?? note.value) + for (const part of source.children) { + if (part.tag === 'CONT') text += `\n${part.rawValue ?? part.value}` + else if (part.tag === 'CONC') text += part.rawValue ?? part.value + } + const clean = plainNoteText(text) + if (clean) texts.push(clean) } - - // "JAN 1900" → "1900-01" - const mmmYyyy = cleaned.match(/^([A-Z]{3})\s+(\d{4})$/) - if (mmmYyyy) { - const month = MONTH_MAP[mmmYyyy[1]] - if (month) return `${mmmYyyy[2]}-${month}` - } - - // "1900" → "1900" - if (/^\d{4}$/.test(cleaned)) return cleaned - - return null + return texts.length ? texts.join('\n\n') : null } function isoDateToDbDate(iso: string | null): Date | null { @@ -550,12 +550,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200) - const noteNode = findChild(indi, 'NOTE') - let notes: string | null = null - if (noteNode?.value) { - const contLines = findAllChildren(noteNode, 'CONT').map((n) => n.value) - notes = clip([noteNode.value, ...contLines].join('\n'), 2000) - } + const notes = clip(personNotes(indi, records), 2000) const personFirstName = (firstName || '?').slice(0, 120) const personLastName = (lastName || '').slice(0, 120) diff --git a/backend/test/gedcom-dates.test.ts b/backend/test/gedcom-dates.test.ts new file mode 100644 index 0000000..b785ef3 --- /dev/null +++ b/backend/test/gedcom-dates.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import { parseGedcomDate } from '../src/lib/gedcom-dates' + +const iso = (value: string) => parseGedcomDate(value)?.iso ?? null + +describe('dates GEDCOM', () => { + it('dates simples', () => { + expect(iso('15 JAN 1900')).toBe('1900-01-15') + expect(iso('JAN 1900')).toBe('1900-01') + expect(iso('1900')).toBe('1900') + expect(iso('856')).toBe('0856') + expect(iso('3 mar 1879')).toBe('1879-03-03') + }) + + it('précision gardée', () => { + expect(parseGedcomDate('ABT 1900')).toMatchObject({ iso: '1900', precision: 'about' }) + expect(parseGedcomDate('BEF 12 MAY 1920')).toMatchObject({ iso: '1920-05-12', precision: 'before' }) + expect(parseGedcomDate('AFT 1850')).toMatchObject({ iso: '1850', precision: 'after' }) + expect(parseGedcomDate('EST 1700')).toMatchObject({ precision: 'estimated' }) + expect(parseGedcomDate('CAL 1801')).toMatchObject({ precision: 'calculated' }) + }) + + it('intervalles', () => { + expect(parseGedcomDate('BET 1794 AND 1796')).toEqual({ iso: '1794', endIso: '1796', precision: 'between', text: 'BET 1794 AND 1796' }) + expect(parseGedcomDate('FROM 1914 TO 1918')).toMatchObject({ iso: '1914', endIso: '1918', precision: 'from' }) + }) + + it('date interprétée et texte entre parenthèses', () => { + expect(parseGedcomDate('INT 1900 (vers la fin du siècle)')).toMatchObject({ iso: '1900', precision: 'interpreted' }) + expect(iso('(pendant la guerre)')).toBeNull() + }) + + it('double année', () => { + expect(iso('12 FEB 1700/01')).toBe('1701-02-12') + expect(iso('1699/00')).toBe('1700') + }) + + it('calendrier julien : la date reste celle écrite', () => { + expect(iso('@#DJULIAN@ 1563')).toBe('1563') + expect(iso('@#DJULIAN@ 3 SEP 1599')).toBe('1599-09-03') + }) + + it('calendrier républicain converti', () => { + expect(iso('@#DFRENCH R@ 18 BRUM 8')).toBe('1799-11-09') + expect(iso('@#DFRENCH R@ 1 VEND 1')).toBe('1792-09-22') + expect(iso('@#DFRENCH R@ 9 THER 2')).toBe('1794-07-27') + expect(iso('@#DFRENCH R@ GERM 3')).toBe('1795-03') + expect(iso('@#DFRENCH R@ 5')).toBe('1796') + }) + + it('illisible : rien', () => { + expect(iso('')).toBeNull() + expect(iso('vers 1900')).toBeNull() + expect(iso('@#DHEBREW@ 5660')).toBeNull() + expect(iso('32 JAN 1900')).toBeNull() + }) +}) diff --git a/backend/test/gedcom-notes.test.ts b/backend/test/gedcom-notes.test.ts new file mode 100644 index 0000000..7ccd170 --- /dev/null +++ b/backend/test/gedcom-notes.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { indexGedcomRecords } from '../src/lib/gedcom-photos' +import { parseGedcom, personNotes } from '../src/routes/gedcom' + +function notesOf(text: string) { + const roots = parseGedcom(text) + const records = indexGedcomRecords(roots) + const indi = roots.find((n) => n.tag === 'INDI')! + return personNotes(indi as never, records) +} + +describe('notes d’une personne', () => { + it('note écrite sous la personne, sur plusieurs lignes (CONT) et coupée (CONC)', () => { + expect(notesOf([ + '0 @I1@ INDI', + '1 NOTE Instituteur à Limoges pendant', + '2 CONC trente-cinq ans.', + '2 CONT Il aimait la pêche.', + ].join('\n'))).toBe('Instituteur à Limoges pendant trente-cinq ans.\nIl aimait la pêche.') + }) + + it('note partagée par renvoi (Gramps, Geneatique, Ancestry)', () => { + expect(notesOf([ + '0 @I1@ INDI', + '1 NOTE @N7@', + '0 @N7@ NOTE Revenu du front en 1919.', + '1 CONT Blessé à Verdun.', + ].join('\n'))).toBe('Revenu du front en 1919.\nBlessé à Verdun.') + }) + + it('plusieurs notes, séparées par une ligne vide', () => { + expect(notesOf([ + '0 @I1@ INDI', + '1 NOTE Première note.', + '1 NOTE @N1@', + '0 @N1@ NOTE Deuxième note.', + ].join('\n'))).toBe('Première note.\n\nDeuxième note.') + }) + + it('aucune note, ou renvoi cassé', () => { + expect(notesOf('0 @I1@ INDI\n1 NAME Jean /Dupont/')).toBeNull() + expect(notesOf('0 @I1@ INDI\n1 NOTE @N404@')).toBeNull() + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c8a82f0..3158aff 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -59,6 +59,7 @@ import { useAnnotations } from './hooks/useAnnotations' import AnnotationToolbar from './components/annotations/AnnotationToolbar' import TextInputOverlay from './components/annotations/TextInputOverlay' import { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData' +import { readGedcomFile } from './utils/gedcomText' import { loadMediaRefs, loadPhotoRefs, pickFirstFaces, prepareImageForUpload, saveMediaRefs, savePhotoRefs, sizeBucket } from './utils/photoImport' import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages' import { getSiblingParentLinks } from './utils/familyLinks' @@ -2373,7 +2374,7 @@ function App() { let imported = null let importError = '' try { - const content = await payload.gedcomFile.text() + const content = (await readGedcomFile(payload.gedcomFile)).text imported = content.trim() ? await importGedcom(createdTree.id, content, token) : null diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 42d4158..592852a 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -20,6 +20,7 @@ import { import PzBusy from './PzBusy.jsx' import TreeVisitsSection from './TreeVisitsSection.jsx' import { t } from '../i18n/index.js' +import { readGedcomFile } from '../utils/gedcomText.js' function normalizeSlug(value) { return String(value || '') @@ -219,12 +220,8 @@ const AdminPanel = ({ } } - const readFileAsText = (file) => new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result || '')) - reader.onerror = () => reject(new Error('read_failed')) - reader.readAsText(file) - }) + // Lu avec le bon encodage (ANSI de Heredis, ANSEL, Mac…), sinon les accents arrivent cassés + const readFileAsText = async (file) => (await readGedcomFile(file)).text const handleImportGedcom = async () => { if (!treeId || !authToken || saving || !gedcomFile) { diff --git a/frontend/src/utils/gedcomText.js b/frontend/src/utils/gedcomText.js new file mode 100644 index 0000000..aa3d8c4 --- /dev/null +++ b/frontend/src/utils/gedcomText.js @@ -0,0 +1,74 @@ +// Lire un fichier GEDCOM avec le bon encodage. Le navigateur lit tout en UTF-8 par défaut, alors que beaucoup +// de logiciels écrivent autre chose : Heredis PC en « ANSI » (Windows-1252), les anciens logiciels en ANSEL, +// Heredis Mac en « MACINTOSH ». Lus en UTF-8, leurs accents deviennent « é » ou « � ». +// +// Ordre : marque d'octets (BOM) ; UTF-8 si le fichier en est un valide (certains logiciels déclarent ANSI +// alors qu'ils écrivent de l'UTF-8) ; sinon l'encodage déclaré par la ligne « 1 CHAR » ; sinon Windows-1252. + +const ANSEL_SPECIALS = { + 0xa1: 'Ł', 0xa2: 'Ø', 0xa3: 'Đ', 0xa4: 'Þ', 0xa5: 'Æ', 0xa6: 'Œ', 0xa7: 'ʹ', 0xa8: '·', 0xa9: '♭', + 0xaa: '®', 0xab: '±', 0xac: 'Ơ', 0xad: 'Ư', 0xae: 'ʼ', 0xb0: 'ʻ', 0xb1: 'ł', 0xb2: 'ø', 0xb3: 'đ', + 0xb4: 'þ', 0xb5: 'æ', 0xb6: 'œ', 0xb7: 'ʺ', 0xb8: 'ı', 0xb9: '£', 0xba: 'ð', 0xbc: 'ơ', 0xbd: 'ư', + 0xc0: '°', 0xc1: 'ℓ', 0xc2: '℗', 0xc3: '©', 0xc4: '♯', 0xc5: '¿', 0xc6: '¡', 0xc7: 'ß', 0xc8: '€', +} + +// Diacritiques ANSEL : écrits AVANT la lettre, rendus par un caractère combinant APRÈS elle +const ANSEL_COMBINING = { + 0xe0: '̉', 0xe1: '̀', 0xe2: '́', 0xe3: '̂', 0xe4: '̃', 0xe5: '̄', + 0xe6: '̆', 0xe7: '̇', 0xe8: '̈', 0xe9: '̌', 0xea: '̊', 0xeb: '︠', + 0xec: '︡', 0xed: '̕', 0xee: '̋', 0xef: '̐', 0xf0: '̧', 0xf1: '̨', + 0xf2: '̣', 0xf3: '̤', 0xf4: '̥', 0xf5: '̳', 0xf6: '̲', 0xf7: '̦', + 0xf8: '̜', 0xf9: '̮', 0xfa: '︢', 0xfb: '︣', 0xfe: '̓', +} + +export function decodeAnsel(bytes) { + let out = '' + let pending = '' + for (const byte of bytes) { + if (ANSEL_COMBINING[byte]) { + pending += ANSEL_COMBINING[byte] + continue + } + const char = byte < 0x80 ? String.fromCharCode(byte) : (ANSEL_SPECIALS[byte] ?? '�') + out += char + pending + pending = '' + } + return (out + pending).normalize('NFC') +} + +function declaredCharset(bytes) { + const head = new TextDecoder('windows-1252').decode(bytes.subarray(0, 8192)) + const match = head.match(/^\s*1\s+CHAR\s+([^\r\n]+)/m) + return match ? match[1].trim().toUpperCase() : '' +} + +function isValidUtf8(bytes) { + try { + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return true + } catch { + return false + } +} + +export function decodeGedcomBytes(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input) + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + return { text: new TextDecoder('utf-8').decode(bytes.subarray(3)), encoding: 'utf-8' } + } + if (bytes[0] === 0xff && bytes[1] === 0xfe) return { text: new TextDecoder('utf-16le').decode(bytes.subarray(2)), encoding: 'utf-16le' } + if (bytes[0] === 0xfe && bytes[1] === 0xff) return { text: new TextDecoder('utf-16be').decode(bytes.subarray(2)), encoding: 'utf-16be' } + // UTF-16 sans marque : « 0 HEAD » commence par « 0 » suivi d'un octet nul + if (bytes[0] === 0x30 && bytes[1] === 0x00) return { text: new TextDecoder('utf-16le').decode(bytes), encoding: 'utf-16le' } + if (bytes[0] === 0x00 && bytes[1] === 0x30) return { text: new TextDecoder('utf-16be').decode(bytes), encoding: 'utf-16be' } + + const declared = declaredCharset(bytes) + if (declared === 'ANSEL') return { text: decodeAnsel(bytes), encoding: 'ansel' } + if (isValidUtf8(bytes)) return { text: new TextDecoder('utf-8').decode(bytes), encoding: 'utf-8' } + if (declared.startsWith('MAC')) return { text: new TextDecoder('macintosh').decode(bytes), encoding: 'macintosh' } + return { text: new TextDecoder('windows-1252').decode(bytes), encoding: 'windows-1252' } +} + +export async function readGedcomFile(file) { + return decodeGedcomBytes(new Uint8Array(await file.arrayBuffer())) +} diff --git a/frontend/src/utils/gedcomText.test.js b/frontend/src/utils/gedcomText.test.js new file mode 100644 index 0000000..dcb2b66 --- /dev/null +++ b/frontend/src/utils/gedcomText.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { decodeAnsel, decodeGedcomBytes } from './gedcomText.js' + +const latin1 = (text) => Uint8Array.from([...text].map((c) => c.charCodeAt(0))) +const utf8 = (text) => new TextEncoder().encode(text) + +describe('encodage d’un fichier GEDCOM', () => { + it('Heredis PC en ANSI (Windows-1252) : accents corrects', () => { + const bytes = latin1('0 HEAD\n1 CHAR ANSI\n0 @I1@ INDI\n1 NAME Hélène /Lefèvre/\n') + const { text, encoding } = decodeGedcomBytes(bytes) + expect(encoding).toBe('windows-1252') + expect(text).toContain('Hélène /Lefèvre/') + }) + + it('UTF-8 déclaré ANSI : on garde l’UTF-8 qui est valide', () => { + const { text, encoding } = decodeGedcomBytes(utf8('0 HEAD\n1 CHAR ANSI\n1 NAME Émile /Ménard/\n')) + expect(encoding).toBe('utf-8') + expect(text).toContain('Émile /Ménard/') + }) + + it('UTF-8 avec marque d’octets', () => { + const bytes = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8('0 HEAD\n1 NAME Zoé /Bœuf/\n')]) + expect(decodeGedcomBytes(bytes)).toEqual({ text: '0 HEAD\n1 NAME Zoé /Bœuf/\n', encoding: 'utf-8' }) + }) + + it('UTF-16 (MyHeritage, certains exports Windows)', () => { + const text = '0 HEAD\n1 NAME François /Côté/\n' + const le = new Uint8Array(text.length * 2 + 2) + le[0] = 0xff; le[1] = 0xfe + for (let i = 0; i < text.length; i++) { le[2 + i * 2] = text.charCodeAt(i) & 0xff; le[3 + i * 2] = text.charCodeAt(i) >> 8 } + expect(decodeGedcomBytes(le).text).toBe(text) + }) + + it('Heredis Mac en MACINTOSH', () => { + // « é » vaut 0x8E en Mac Roman + const bytes = Uint8Array.from([...latin1('0 HEAD\n1 CHAR MACINTOSH\n1 NAME Ren'), 0x8e, ...latin1(' /Dupr'), 0x8e, ...latin1('/\n')]) + expect(decodeGedcomBytes(bytes).text).toContain('René /Dupré/') + }) + + it('ANSEL : diacritique avant la lettre', () => { + // 0xE2 = accent aigu, 0xF0 = cédille, 0xE8 = tréma + expect(decodeAnsel(Uint8Array.from([0x52, 0x65, 0x6e, 0xe2, 0x65]))).toBe('René') + expect(decodeAnsel(Uint8Array.from([0x46, 0x72, 0x61, 0x6e, 0xf0, 0x63, 0x6f, 0x69, 0x73]))).toBe('François') + expect(decodeAnsel(Uint8Array.from([0x4e, 0x6f, 0xe8, 0x65, 0x6c]))).toBe('Noël') + const ged = Uint8Array.from([...latin1('0 HEAD\n1 CHAR ANSEL\n1 NAME '), 0x4c, 0xe2, 0x65, 0x6f, 0x6e, ...latin1(' /'), 0xa6, ...latin1('uf/\n')]) + expect(decodeGedcomBytes(ged)).toEqual({ text: '0 HEAD\n1 CHAR ANSEL\n1 NAME Léon /Œuf/\n', encoding: 'ansel' }) + }) + + it('sans déclaration et pas de l’UTF-8 : Windows-1252', () => { + expect(decodeGedcomBytes(latin1('0 HEAD\n1 NAME Jérôme /Hébert/\n')).text).toContain('Jérôme /Hébert/') + }) +})