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/') + }) +})