# gedcom-ts

`gedcom-ts` is a browser-oriented TypeScript library to:

- import genealogy data from GEDCOM (`.ged`) or ZIP (`.zip` / `.gdz`)
- work with a typed JSON model (persons, acts, dates, places, notes, media, name variants, attributes)
- edit the model in-place through a fluent, chainable API (`editPerson`, `editAct`, …)
- export data back to GEDCOM (`.ged`) or GEDZIP (`.zip`)
- attach **audio** and other media via GEDCOM 7 `OBJE` records (`MEDI AUDIO`, multi-`FILE`, `TRAN`)
- model **oral history interviews** as `EVEN` acts with `OBJE` + transcription notes
- geocode event places (OpenStreetMap / Nominatim) and group similar city names
- generate a **genealogy booklet** as PDF (`gedcom-ts/booklet`, 11 locales including RTL Arabic/Hebrew)

## Live demo

A graphical demo showcasing the public API (import a `.ged` / `.zip`, browse the typed model, export back) is available at **[https://gedcomts.com](https://gedcomts.com)**

## Contents

- [Installation](#installation)
- [Quick start](#quick-start)
- [Geocoding places](#geocoding-places)
- [Genealogy booklet (PDF)](#genealogy-booklet-pdf)
- [Audio and oral history (GEDCOM 7)](#audio-and-oral-history-gedcom-7)
- [API reference](#api-reference)
- [Error handling](#error-handling)

## Package

- NPM: [gedcom-ts](https://www.npmjs.com/package/gedcom-ts)
- Current release **`2026.6.4`** — affichage des prénoms GEDCOM 7 sans guillemets parasites après round-trip ; série **2026.6.x** (OBJE audio, GEDZIP fflate, dates insensibles à la casse). Version format **CalVer** `AAAA.M.micro` (e.g. `2026.6.4` = June 2026, micro release). See [CHANGELOG.md](CHANGELOG.md).
- Entry points:
  - **`gedcom-ts`** — import, model, edit layer, export, geocoding
  - **`gedcom-ts/booklet`** — PDF livret (`pdf-lib` en peerDependency, chargé depuis `node_modules`)

## Installation

```bash
npm install gedcom-ts
```

Both entry points come from the same package. For the PDF booklet, **`pdf-lib` is a peer dependency** — install it in your app if you use `gedcom-ts/booklet`:

```bash
npm install pdf-lib
```

For `zh` / `ar` / `he` PDF output, your bundler should also be able to resolve `@pdf-lib/fontkit` (included in `gedcom-ts` dependencies; the host app typically preloads it — see [Bundle layout](#bundle-layout) below).

## Runtime Requirements

- modern browser runtime (`File`, `Blob`, `XMLHttpRequest`, `URL.createObjectURL`)
- for pure Node.js usage, DOM polyfills are required (the library targets browsers)

## Quick start

```ts
import { importGedFile, ExportGedzipFile } from "gedcom-ts";

async function roundTrip(file: File) {
  const readGed = await importGedFile(file);
  const persons = readGed.persons;

  if (persons.length > 0) {
    persons[0].lastname = persons[0].lastname.toUpperCase();
  }

  await new ExportGedzipFile("updated-tree", persons).download();
}
```

Typical workflow:

1. import a file with `importGedFile` (or start from `createEmptyReadGed()`)
2. read / mutate the typed `Person`, `Act`, `Place`, `Note`, `MultimediaFile` objects (directly or through `editPerson` / `editAct` / `editPlace` / …)
3. export as `.ged` (`ExportGedcomFile`) or `.zip` (`ExportGedzipFile`)

## Geocoding places

Use this section when your app needs **GPS coordinates** on event places, a **list of cities still without coordinates**, **map markers**, or a **data-quality** view for inconsistent place names.

You work with a `ReadGed` (after import) and a flat `Act[]` built from every person’s events (see [Prepare act list](#prepare-act-list)).

### How city names are matched

The library groups variants of the same place with **`citiesAreSimilar`**, not exact string equality.

| Label A     | Label B                              | Same place?                 |
| ----------- | ------------------------------------ | --------------------------- |
| `Pleurtuit` | `Pleurtuit, Ille-et-Vilaine, France` | Yes                         |
| `Paris`     | `Paris, TX, USA`                     | Depends on similarity rules |

Use the functions in the tables below for grouping, geocoding, and maps. Do **not** compare raw strings yourself, and do **not** use `normalizeCityKey` for grouping (spelling normalization only).

### Prepare act list

```ts
import type { ReadGed, Act } from "gedcom-ts";

function collectAllActs(ged: ReadGed): Act[] {
  const acts: Act[] = [];
  for (const person of ged.persons) {
    for (const act of person.acts.list) acts.push(act);
  }
  return acts;
}
```

### Geocode one city (typical flow)

When the user picks a city and a Nominatim result, update **every similar act that still has no GPS** — not only acts with the exact same label.

| Step                                           | Function                                                    |
| ---------------------------------------------- | ----------------------------------------------------------- |
| 1. Context from the tree (countries, centroid) | `inferGeocodeContext(ged)`                                  |
| 2. Search OpenStreetMap                        | `searchPlacesWithContext(cityName, context)`                |
| 3. Acts to update                              | `actsNeedingGeocodeForCity(allActs, cityName)`              |
| 4. Write coordinates                           | `applyGeocodeCandidateToActs(targets, candidate, cityName)` |

```ts
import {
  inferGeocodeContext,
  searchPlacesWithContext,
  actsNeedingGeocodeForCity,
  applyGeocodeCandidateToActs,
} from "gedcom-ts";
import type { ReadGed, Act } from "gedcom-ts";

async function geocodeCity(ged: ReadGed, allActs: Act[], cityName: string) {
  const context = inferGeocodeContext(ged);
  const candidates = await searchPlacesWithContext(cityName, context);
  const chosen = candidates[0];
  if (!chosen) return;

  const targets = actsNeedingGeocodeForCity(allActs, cityName);
  applyGeocodeCandidateToActs(targets, chosen, cityName);
}
```

> **Tip:** Do not pass only acts from a harmonization cluster to `applyGeocodeCandidateToActs`. Longer labels (e.g. `Pleurtuit, Ille-et-Vilaine, France`) would stay without GPS. Always use `actsNeedingGeocodeForCity`.

### List cities missing coordinates

One row per city; `withoutCoordCount` is how many acts still need GPS.

```ts
import { groupActsBySimilarCity } from "gedcom-ts";

const groups = groupActsBySimilarCity(allActs, {
  onlyWithoutCoordinates: true,
});

for (const group of groups) {
  console.log(group.cityLabel, group.withoutCoordCount);
  // group.acts — acts in this group without GPS
}
```

Largest groups first (`group.acts.length`).

### Map: one marker per city

```ts
import { clusterKeyForCity } from "gedcom-ts";

const markerKey = clusterKeyForCity(act.place?.city ?? "");
// merge markers that share the same markerKey
```

### Harmonization (data quality, optional)

Use when spellings, GPS positions, or “some acts with / without GPS” disagree for the same similar city.

```ts
import { findHarmonizationClusters } from "gedcom-ts";

for (const cluster of findHarmonizationClusters(ged)) {
  console.log(cluster.labels, cluster.coordVariants, cluster.actsWithoutCoord);
}
```

After a full geocode via `actsNeedingGeocodeForCity`, you should not get a cluster that only reports missing GPS for that city.

### Geocoding API cheat sheet

| Goal                      | Call                                                             |
| ------------------------- | ---------------------------------------------------------------- |
| Search                    | `searchPlacesWithContext(city, inferGeocodeContext(ged))`        |
| Acts to update on confirm | `actsNeedingGeocodeForCity(acts, city)`                          |
| Apply lat/lng             | `applyGeocodeCandidateToActs(targets, candidate, city)`          |
| Cities without GPS        | `groupActsBySimilarCity(acts, { onlyWithoutCoordinates: true })` |
| Map marker id             | `clusterKeyForCity(city)`                                        |
| Inconsistencies           | `findHarmonizationClusters(ged)`                                 |

Network: `GET https://nominatim.openstreetmap.org/search?q=…&format=jsonv2` with a `User-Agent` (`gedcom-ts/<version> (genealogy library)`). For offline or mocked search, pass `fetchFn` in `searchPlaces` / `searchPlacesWithContext` options.

The legacy callback `getCityCoordinates` is deprecated — use the flow above.

## Genealogy booklet (PDF)

The **`gedcom-ts/booklet`** subpath builds a printable family booklet: cover page, table of contents, chapters by generation (Sosa), family sheets, and narrative text from GEDCOM acts (birth, marriage, death, …). **English is the default locale** (`DEFAULT_BOOKLET_LOCALE`). Full copy is available for **`en`**, **`fr`**, **`de`**, **`nl`**, **`es`**, **`zh`**, **`it`**, **`pt`**, **`pl`**, **`ar`**, and **`he`** (aligned with [gedcomts.com](https://gedcomts.com) UI languages). Arabic and Hebrew use **RTL layout** in the PDF (`isBookletLocaleRtl`, `bookletTextDirection`).

### Bundle layout

Code is split so the host app only downloads what it uses. Approximate sizes after minify (obfuscation adds ~25–30 KiB on `gedcom-ts/booklet`).

| Artifact                                    | Size (typ.) | When loaded                                  |
| ------------------------------------------- | ----------: | -------------------------------------------- |
| `gedcom-ts` (`dist/index.mjs`)              |    ~180 KiB | GEDCOM import / edit / export                |
| `gedcom-ts/booklet` (`dist/booklet.mjs`)    |    ~100 KiB | livret (collect, estimate, PDF)              |
| `locale-chunks/booklet-locale-*`            |   14–26 KiB | one UI language                              |
| `feature-chunks/booklet-logo-draw`          |     ~11 KiB | cover logo (`coverLogo !== false`)           |
| `feature-chunks/booklet-timeline-raster`    |      ~3 KiB | `timelineStyle: "canvas"`                    |
| `font-chunks/booklet-pdf-font-bytes-ar`     |     ~43 KiB | Arabic PDF                                   |
| `font-chunks/booklet-pdf-font-bytes-he`     |     ~13 KiB | Hebrew PDF                                   |
| `font-chunks/booklet-pdf-font-bytes-zh`     |    ~190 KiB | Chinese PDF (base subset)                    |
| `font-chunks/booklet-pdf-font-bytes-zh-ext` |    ~1.4 MiB | Chinese PDF when GEDCOM names need extra Han |
| `pdf-lib` (peer, not in gedcom-ts)          |   ~500 KiB+ | any PDF generation                           |

**Not bundled in gedcom-ts:** `fflate` (main entry, external), `pdf-lib` (booklet peer). **`bidi-js`** and **`naqqash`** (Arabic shaping / RTL reordering) ship inside `gedcom-ts/booklet` today.

Call **`await ensureBookletLocale(locale)`** before sync APIs. `generateGenealogyBookletPdf` preloads locale, logo, and timeline when needed.

**Lazy chunk subpaths** (for bundler preload or explicit `import()` — normal apps should use `ensureBookletLocale()` instead):

| Subpath                                                                             | Role                                                |
| ----------------------------------------------------------------------------------- | --------------------------------------------------- |
| `gedcom-ts/booklet/locale-chunks/booklet-locale-{en,fr,de,nl,es,zh,it,pt,pl,ar,he}` | Messages, narratives, date/place helpers per locale |
| `gedcom-ts/booklet/font-chunks/booklet-pdf-font-bytes-{ar,he,zh,zh-ext}`            | Subset Noto font bytes                              |
| `gedcom-ts/booklet/feature-chunks/booklet-logo-draw`                                | Cover logo SVG paths                                |
| `gedcom-ts/booklet/feature-chunks/booklet-timeline-raster`                          | Canvas timeline PNG rasterizer                      |

Each subpath is listed in `package.json` **`exports`** with matching **`types`** (`.d.ts` under `dist/booklet/…`) and runtime (`.mjs` / `.cjs` under `dist/…`). TypeScript resolves them via `exports.types` (`moduleResolution: bundler` / `node16`). Adding a locale updates `scripts/booklet-chunk-manifest.mjs`; `npm run build:js` regenerates chunk files and syncs `exports`.

### Vite / Angular integration

1. **Lazy-load the booklet entry** — `import('gedcom-ts/booklet')` only when the user opens export / download (not on every tree page).
2. **Preload the UI locale** — `import('gedcom-ts/booklet/locale-chunks/booklet-locale-fr')` (or your loader) before sync APIs; then `ensureBookletLocale('fr')`.
3. **Unicode PDF (`zh` / `ar` / `he`)** — the host app should import font chunks explicitly and call `registerBookletPdfFontBytes` + `registerPdfFontkit` before `generateGenealogyBookletPdf` (see [gedcomts.com](https://gedcomts.com) `booklet-pdf-deps.loader.ts` for a reference pattern).
4. **Dev server** — exclude the main booklet bundle from Vite pre-bundling so lazy subpaths resolve via `node_modules`:

```ts
// vite.config.ts / angular.json (esbuild optimizeDeps)
optimizeDeps: {
  exclude: ["gedcom-ts/booklet"],
},
```

Published `dist/booklet.mjs` keeps **literal** dynamic `import('gedcom-ts/booklet/…')` strings (rewrite before obfuscation, paths reserved from string encoding) so Vite can statically analyze chunk loads.

### Workflow

1. Import with `importGedFile` (`gedcom-ts`).
2. **`await ensureBookletLocale(locale)`** — load messages/narratives for the chosen language.
3. Collect persons with `collectBookletPersons` (`gedcom-ts/booklet`).
4. Optionally preview size with `estimateBookletSize` + `groupBookletIntoChapters`.
5. Build bytes with `generateGenealogyBookletPdf`, then `downloadBookletPdf` (browser).

```ts
import { importGedFile } from "gedcom-ts";
import {
  collectBookletPersons,
  ensureBookletLocale,
  groupBookletIntoChapters,
  estimateBookletSize,
  generateGenealogyBookletPdf,
  downloadBookletPdf,
  personDisplayName,
} from "gedcom-ts/booklet";

async function exportBooklet(file: File) {
  const ged = await importGedFile(file);
  const root = ged.persons[0] ?? null;

  const locale = "en" as const; // BookletLocale — align with your UI language (en, fr, de, nl, es, zh, it, pt, pl, ar, he)
  await ensureBookletLocale(locale);

  const entries = collectBookletPersons({
    ged,
    scope: "from-reference",
    referencePerson: root,
    maxGeneration: 6,
    locale,
  });

  const chapters = groupBookletIntoChapters(entries, locale);
  const families = chapters.reduce((n, ch) => n + ch.families.length, 0);
  const size = estimateBookletSize(
    chapters,
    entries.length,
    families,
    "summary",
    "canvas",
    locale,
  );
  console.log(size.label);

  const pdf = await generateGenealogyBookletPdf(
    entries,
    {
      title: "Family booklet",
      scopeLabel: "Ancestors and descendants",
      personCount: entries.length,
      referenceName: root ? personDisplayName(root) : undefined,
    },
    {
      detailLevel: "summary",
      timelineStyle: "canvas",
      coverLogo: true,
      locale,
    },
  );

  downloadBookletPdf(pdf, "family-booklet.pdf");
}
```

Use `scope: "all"` and `referencePerson: null` to include every individual in the file.

### `collectBookletPersons` options

| Option            | Role                                                                                                |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| `ged`             | `ReadGed` after import                                                                              |
| `scope`           | `"all"` or `"from-reference"` (Sosa from `referencePerson`)                                         |
| `referencePerson` | Root person for `"from-reference"`; `null` if scope is `"all"`                                      |
| `maxGeneration`   | Max Sosa generation (e.g. `6`); ignored when `scope === "all"`                                      |
| `locale`          | `BookletLocale` (default `"en"`) — `en`, `fr`, `de`, `nl`, `es`, `zh`, `it`, `pt`, `pl`, `ar`, `he` |

Helpers: `personDisplayName`, `buildBookletPersonEntry`, `sortBookletEntries`, `bookletSexFromPerson`, `DEFAULT_BOOKLET_LOCALE`, `BOOKLET_LOCALES`, `isBookletLocaleRtl`.

### `generateGenealogyBookletPdf` options

| Option          | Values                                                  | Effect                                                 |
| --------------- | ------------------------------------------------------- | ------------------------------------------------------ |
| `detailLevel`   | `"summary"` \| `"detailed"`                             | Short prose per person vs longer biographies           |
| `timelineStyle` | `"off"` \| `"canvas"`                                   | Generation timeline pages per chapter                  |
| `coverLogo`     | `true` (default), `false`, or `DrawGedcomTsLogoOptions` | gedcom-ts vector logo on the cover                     |
| `locale`        | `BookletLocale` (default `"en"`)                        | Narrative, chapter titles, PDF chrome, timeline legend |
| `unicodeFont`   | `Uint8Array` (optional)                                 | Replace bundled Noto font for `zh` / `ar` / `he`       |

`BookletPdfMeta`: `title`, `scopeLabel`, `personCount`, optional `referenceName` (usually translated in the host app).

### Cover logo

The cover draws the **gedcom-ts logo** from lazy-loaded SVG paths (`ensureBookletLogo`, `getGedcomTsLogoPaths`, `getGedcomTsLogoViewbox`). Reuse on custom PDF pages:

```ts
import { PDFDocument } from "pdf-lib";
import {
  drawGedcomTsLogoOnPage,
  defaultCoverLogoOptions,
  ensureBookletLogo,
} from "gedcom-ts/booklet";

const doc = await PDFDocument.create();
const page = doc.addPage();
await ensureBookletLogo();
await drawGedcomTsLogoOnPage(page, defaultCoverLogoOptions());
```

### Booklet API cheat sheet

| Goal                             | Export                                                                                                               |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Persons for the booklet          | `collectBookletPersons`                                                                                              |
| Chapters / families              | `groupBookletIntoChapters`, `partnerNamesLabel`                                                                      |
| Localized narratives (custom UI) | `buildPersonSummaryNarrative`, `buildPersonDetailedNarratives`, `buildFamilyNarrative`, `buildChapterIntroNarrative` |
| Page estimate                    | `estimateBookletSize`, `bookletSizeAdvice`                                                                           |
| Timeline data / PNG              | `buildGenerationTimeline`, `rasterizeGenerationTimelinePng`                                                          |
| PDF output                       | `generateGenealogyBookletPdf`, `downloadBookletPdf`, `toPdfText`, `preparePdfText`                                   |
| Locale helpers                   | `resolveBookletLocale`, `bookletLocaleToBcp47`, `bookletTextDirection`, `BOOKLET_RTL_LOCALES`, `ensureBookletLocale` |
| Font preload (host)              | `registerBookletPdfFontBytes`, `registerPdfFontkit`, `localeNeedsUnicodePdfFont`                                     |
| Logo                             | `ensureBookletLogo`, `drawGedcomTsLogoOnPage`, `getGedcomTsLogoPaths`, `defaultCoverLogoOptions`                     |

## Audio and oral history (GEDCOM 7)

Since **`2026.6.0`**, gedcom-ts models audio according to [FamilySearch GEDCOM 7](https://gedcom.io/): external files referenced by **`OBJE`** records (`FILE` + `FORM` + optional `MEDI AUDIO` + `TRAN` for alternate formats or WebVTT). Text transcriptions belong in **`NOTE`** under the event, not inside `OBJE`.

**Oral history pattern:** `1 EVEN` + `2 TYPE Interview` (or `Oral history`) + `2 OBJE @O…@` + `2 NOTE` (transcription).

```ts
import {
  createAudioInterviewAct,
  editAct,
  ExportGedzipFile,
  type Person,
  type ReadGed,
} from "gedcom-ts";

async function addInterview(readGed: ReadGed, person: Person, mp3: File) {
  const objeMap = new Map(readGed.objeRecordsById);

  const { act, objeRecord } = createAudioInterviewAct(
    {
      description: "Entretien avec grand-mère",
      participantIndis: [person.INDI],
      ownerIndi: person.INDI,
      audioFile: mp3,
      transcription: "Bonjour, je m'appelle Marie…",
      vttUri: "media/transcript.vtt", // optional FILE.TRAN on OBJE
    },
    readGed.persons,
    objeMap,
  );

  person.acts.add(act);
  if (objeRecord) {
    objeMap.set(objeRecord.id, objeRecord);
  }

  await new ExportGedzipFile("my-tree", readGed.persons, {
    extraTopLevelRecords: readGed.preservedTopLevelRecords,
    objeRecordsById: objeMap,
  }).download();
}

// enrich an existing act:
editAct(act)
  .asAudioInterview(readGed.persons, objeMap)
  .setTranscription("Suite de l'entretien…")
  .attachAudio({
    audioUri: "https://example.org/rec.mp3",
    ownerIndi: person.INDI,
  });
```

| Goal                       | API                                                                      |
| -------------------------- | ------------------------------------------------------------------------ |
| Create interview act       | `createAudioInterviewAct`                                                |
| Attach audio to act        | `attachAudioInterviewToAct`, `editAct(act).asAudioInterview()`           |
| Detect imported interviews | `isAudioInterviewAct`, `isAudioInterviewEvenType`                        |
| Read transcription / media | `getAudioInterviewTranscription`, `primaryAudioInterviewUri`             |
| OBJE round-trip            | `readGed.objeRecordsById` → `objeRecordsById` in export options          |
| Low-level OBJE parse/emit  | `parseStandaloneObjeBlock`, `formatObjeRecordBlock`, `guessMediFromForm` |
| EVEN type constants        | `GEDCOM_7_EVEN_TYPE_AUDIO_INTERVIEW`, `GEDCOM_7_EVEN_TYPE_ORAL_HISTORY`  |

## API reference

Short description and a minimal snippet for each export of **`gedcom-ts`**. For the PDF booklet, see [Genealogy booklet (PDF)](#genealogy-booklet-pdf) (`gedcom-ts/booklet`).

### Importing a file

#### `importGedFile(file: File): Promise<ReadGed>`

Detects the format from the file MIME / extension and dispatches to the right reader.

- accepts a single `.ged` file
- accepts a `.zip` / `.gdz` archive containing one `.ged` + optional media files
- throws `Error(IMPORT_ERR_ZIP_GED_UNREADABLE)` when the embedded `.ged` cannot be decoded (typical cause: password-protected ZIP)

```ts
import { importGedFile } from "gedcom-ts";

const readGed = await importGedFile(fileInput.files![0]);
console.log(readGed.persons.length, readGed.datasetVersion);
```

#### `createEmptyReadGed(options?): ReadGed`

Creates an empty graph with the same runtime shape as a successful import (empty `persons`, initialized `mapPersons` / `partnersMap` / `childsMap` / `placesMap` / `mapFiles`). Use it to start a brand-new tree without parsing a file. `datasetVersion` is set to `"7.0"`.

```ts
import { createEmptyReadGed } from "gedcom-ts";

const readGed = createEmptyReadGed();
```

#### `IMPORT_ERR_ZIP_GED_UNREADABLE: string`

Sentinel error message thrown by `importGedFile` when a `.ged` inside a ZIP cannot be decoded (typically because the ZIP is password-protected). Compare with `error.message === IMPORT_ERR_ZIP_GED_UNREADABLE` to display a tailored message.

```ts
import { importGedFile, IMPORT_ERR_ZIP_GED_UNREADABLE } from "gedcom-ts";

try {
  await importGedFile(file);
} catch (error) {
  if (
    error instanceof Error &&
    error.message === IMPORT_ERR_ZIP_GED_UNREADABLE
  ) {
    alert("Please unzip the archive manually and import the .ged file.");
  }
}
```

#### `ReadGed`

Result of an import. Standalone `0 @O…@ OBJE` blocks with at least one `FILE` are parsed into **`objeRecordsById`** (multi-file, `MEDI`, `TRAN`). Other top-level records (`REPO`, `SOUR`, `SUBM`, …) stay on `preservedTopLevelRecords` for round-trip export.

Notable members:

| Member                                     | Description                                                                                                                                                |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `persons: Person[]`                        | Imported individuals.                                                                                                                                      |
| `mapPersons: Map<number, Person>`          | `INDI` (integer) → `Person`.                                                                                                                               |
| `partnersMap: Map<number, Person[]>`       | Family id → spouses.                                                                                                                                       |
| `childsMap: Map<number, Person[]>`         | Family id → children.                                                                                                                                      |
| `placesMap: Map<string, Place>`            | City name → `Place` (first occurrence wins).                                                                                                               |
| `mapFiles: Map<string, File>`              | Relative path → media `File` (for ZIP imports).                                                                                                            |
| `datasetVersion: GedcomDatasetVersion`     | `"7.0"`, `"5.5"` or `"unknown"`.                                                                                                                           |
| `objeRecordsById: Map<number, ObjeRecord>` | Parsed `OBJE` records (audio, images, …) keyed by `@O{n}@`.                                                                                                |
| `preservedTopLevelRecords: string[]`       | Raw blocks for partial round-trip.                                                                                                                         |
| `resolveIndividualPointer(raw)`            | Resolves `@I12@`, `I12`, `@Homer_Simpson@`, `Homer_Simpson` to a `Person`.                                                                                 |
| `getChildrenForParent(parent)`             | All children attached to a parent (via `FAMS`).                                                                                                            |
| `getChildrenOfFamily(familyId)`            | Children of a single family.                                                                                                                               |
| `groupPartners()`                          | Rebuilds `partnersMap` / `childsMap` after editing links.                                                                                                  |
| `generateUniqueIndi()`                     | Next free `INDI` number for new persons.                                                                                                                   |
| `rehydratePlacesFromActs()`                | Rebuilds `placesMap` from act places (e.g. after manual graph edits). Use `editReadGed(readGed).addPerson(...)` to register persons so maps stay coherent. |

```ts
import { ReadGed } from "gedcom-ts";

function describe(readGed: ReadGed) {
  return {
    version: readGed.datasetVersion,
    persons: readGed.persons.length,
    places: readGed.placesMap.size,
  };
}
```

### Exporting

#### `ExportGedcomFile`

Writes a GEDCOM 7 (`.ged`) file. Three call signatures are supported:

```ts
new ExportGedcomFile(persons);
new ExportGedcomFile(title, persons);
new ExportGedcomFile(title, persons, options);
```

`.download()` triggers a browser download. `.toString()` returns the GEDCOM text.

```ts
import { ExportGedcomFile, type ReadGed } from "gedcom-ts";

function exportGed(readGed: ReadGed) {
  new ExportGedcomFile("my-tree", readGed.persons, {
    extraTopLevelRecords: readGed.preservedTopLevelRecords,
    objeRecordsById: readGed.objeRecordsById,
    headLanguageTag: "fr-FR",
    headCopyright: "© 2026 Family Archive",
    headDestination: "https://gedcom.io/",
    headSchemaTagDefs: [{ tag: "_FOO", uri: "https://example.com/foo" }],
  }).download();
}
```

#### `ExportGedzipFile`

Writes a `.zip` (GEDZIP) bundling the GEDCOM and all attached `MultimediaFile` payloads. Built with **fflate** (`zip` async, level 9).

```ts
import { ExportGedzipFile, Person } from "gedcom-ts";

async function exportZip(persons: Person[]) {
  await new ExportGedzipFile("my-tree", persons).download();
}

async function exportZipBlob(persons: Person[]) {
  return new ExportGedzipFile("my-tree", persons).gedzipBlob();
}
```

#### `GedcomExportOptions`

Options shared by both exporters:

| Option                 | Effect                                                                                                   |
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
| `extraTopLevelRecords` | Raw `0 …` blocks re-emitted before `SUBM` / `TRLR` (round-trip with `readGed.preservedTopLevelRecords`). |
| `objeRecordsById`      | Full `OBJE` records (multi-`FILE`, `MEDI AUDIO`, `TRAN`) from `readGed.objeRecordsById`.                 |
| `headLanguageTag`      | BCP 47 tag for `HEAD`.`LANG` (defaults to `en-US`).                                                      |
| `headCopyright`        | One-line `1 COPR` notice.                                                                                |
| `headDestination`      | Value of `HEAD`.`DEST` (target app / URI).                                                               |
| `headSchemaTagDefs`    | Extension tag definitions emitted as `HEAD`.`SCHMA` / `2 TAG …`.                                         |

### Domain model

#### `Person`, `Sex`

```ts
import { Person, Sex } from "gedcom-ts";

const person = new Person();
person.INDI = 1;
person.SEX = Sex.M; // M | F | U | X
person.firstnames = ["Jean"];
person.lastname = "DUPONT";

person.addMultimedia(/* MultimediaFile */);
person.deleteMultimedia("1/photo.jpg");
```

Key fields: `INDI`, `sosa`, `SEX`, `firstnames`, `lastname`, `FAMC`, `FAMS`, `acts`, `notes`, `multimediaFiles`, `nameVariants`, `attributes`.

#### `PersonGedcomImportOptions` (type)

Optional hints consumed by `Person.createPersonJson` when re-parsing a single individual block (label-keyed pointers for `FAMS` / `FAMC` / `NOTE`, and standalone `OBJE` payloads). Useful when assembling a graph manually outside `ReadGed`.

#### `PersonNameVariant`, `PersonNameTranslation`

GEDCOM 7 may quote the preferred given name in `1 NAME` (e.g. `"Jean" /Dupont/`). Since **2026.6.4**, `personDisplayName`, `personInitialsFromPerson`, and `formatPersonNameFromVariant` strip those quotes for UI display after import or export round-trip.

Lossless representation of every `1 NAME` block of an individual (type, `NPFX`/`GIVN`/`SURN`/… parts, `TRAN` translations). `Person.nameVariants` keeps them in order so alias / translation data survive an import → export round-trip.

```ts
import { PersonNameVariant, PersonNameTranslation } from "gedcom-ts";

const variant = new PersonNameVariant("Jean /Dupont/", "BIRTH");
variant.parts.push({ level: "2", tag: "GIVN", value: "Jean" });
variant.translations.push(new PersonNameTranslation("ジャン /デュポン/", "jp"));
```

#### `IndiAttribute`, `IndiGedcomSubLine`

Generic level-1 individual attributes (`FACT`, `DSCR`, `CAST`, `EDUC`, `OCCU`, `RELI`, `TITL`, `RESN`, …) with their sub-lines preserved (`IndiGedcomSubLine = { level; tag; value }`).

```ts
import { IndiAttribute } from "gedcom-ts";

const occ = new IndiAttribute("OCCU", "Blacksmith", [
  { level: "2", tag: "DATE", value: "1820" },
]);
```

#### `Act`, `Acts`, `TypeAct`, `ActConstructionOptions`

`Act` models an individual or family event (BIRT, MARR, etc.). `Acts` is the ordered collection on a `Person`. `TypeAct` is the union of every supported GEDCOM 7 event tag (= `Gedcom7EventTag`). Event-level notes live on `act.notes` (`2 NOTE` / `3 CONT` in GEDCOM); they are distinct from `person.notes` (`1 NOTE` on `INDI`) and round-trip through ZIP export/import.

```ts
import { Act, Acts, Identifier, type TypeAct } from "gedcom-ts";

const acts = new Acts();
const type: TypeAct = Identifier.BIRT;
acts.add(new Act(type));
acts.sortByDate();
```

`ActConstructionOptions` controls the extras when building an `Act` manually:

```ts
import { Act, DateAct, Identifier } from "gedcom-ts";

const act = new Act(
  Identifier.EVEN,
  new DateAct("12 JAN 1901"),
  null,
  null,
  null,
  undefined,
  undefined,
  {
    evenDescription: "Won a medal",
    evenTypeLabel: "Award",
    sdateAct: new DateAct("13 JAN 1901"),
    eventPhrases: ["family gathering"],
    preservedSubrecordPrefix: [],
    preservedSubrecordSuffix: [],
  },
);
```

#### `GEDCOM_7_ALL_EVENT_TAGS`, `GEDCOM_7_EVENT_SORT_ORDER`, `GEDCOM_7_EVENT_TAG_SET`, `GEDCOM_7_PAIR_UNION_EVENT_TAGS`, `Gedcom7EventTag`, `Gedcom7PairUnionEventTag`

Canonical lists of GEDCOM 7 event tags (`INDIVIDUAL_EVENT_STRUCTURE` ∪ `FAMILY_EVENT_STRUCTURE`, LDS ordinances excluded). Use them to build UI selects or guard custom logic. `GEDCOM_7_PAIR_UNION_EVENT_TAGS` lists tags commonly used when creating a family union via `createMarriageFamily` (`ENGA`, `MARB`, `MARC`, `MARL`, `MARR`, `MARS`).

```ts
import {
  GEDCOM_7_ALL_EVENT_TAGS,
  GEDCOM_7_EVENT_SORT_ORDER,
  GEDCOM_7_EVENT_TAG_SET,
  type Gedcom7EventTag,
} from "gedcom-ts";

const options: Gedcom7EventTag[] = [...GEDCOM_7_ALL_EVENT_TAGS];
const isEvent = GEDCOM_7_EVENT_TAG_SET.has("MARR");
const sorted = [...GEDCOM_7_EVENT_SORT_ORDER];
```

#### `DateAct`, `Day`, `Month`, `dateToDateLine`, `days`, `months`, `TypeDateActSimpleQualifier`

GEDCOM dates with full GEDCOM 7 / 5.5 support, including `INT`, `EST`, `CAL`, `BET … AND …`, `FROM … TO …`, ISO `YYYY-MM-DD`, `3 PHRASE` under `DATE`, `3 TIME`, and a verbatim fallback for unparseable payloads. Month tokens (`JAN`, `may`, …) and simple qualifiers are matched **case-insensitively** (since **2026.6.2**).

```ts
import { DateAct, Day, Month, dateToDateLine, days, months } from "gedcom-ts";

const day: Day = 12;
const month: Month = months[0]; // JAN
const dateLine = dateToDateLine(1901, month, day); // "12 JAN 1901"
const dateAct = new DateAct(dateLine);
const formattedDate = dateAct.date;
const knownDaysCount = days.length;
```

`TypeDateActSimpleQualifier` is the union of single-anchor qualifiers (`BEF | ABT | AFT | INT | EST | CAL`) accepted by `DateAct.updateQualifiedDate` and by `DateActEdit.setQualified`.

#### `Place`, `CoordinateGPS`

```ts
import { Place, CoordinateGPS } from "gedcom-ts";

const place = new Place("Paris, FR", new CoordinateGPS(48.8566, 2.3522));
place.setFromGedcom7PlacPayload("Paris, Île-de-France, France");
const payload = place.toGedcom7PlacPayload(); // "Paris, , Île-de-France, France"
```

`Place` understands the `City, County, State, Country` GEDCOM 7 list (1 to 4+ segments) and exposes `placPhrase` for `3 PHRASE` under `PLAC` (import + export).

#### `MultimediaFile`, `MultimediaFiles`

Wraps either a local `File`, an external URI (`sourceUri`) or an OBJE pointer (`objeXrefId`) so the same model can survive a GED / ZIP round-trip. See [Audio and oral history](#audio-and-oral-history-gedcom-7) for interview workflows.

```ts
import { MultimediaFile, MultimediaFiles } from "gedcom-ts";

const bucket = new MultimediaFiles();
bucket.relativePath = "1/BIRT";
bucket.add(new MultimediaFile(new File(["img"], "birth.jpg")));

const remote = new MultimediaFile(undefined, "https://example.com/p.jpg");
const isExportable = remote.hasExportablePayload();
```

#### `Note`, `Notes`, `TypeNote`

```ts
import { Notes, Note, Identifier, type TypeNote } from "gedcom-ts";

const typeNote: TypeNote = Identifier.CONT;
const note = new Note();
note.updateType(typeNote);
note.updateLines(["first line", "second line"]);

const notes = new Notes();
notes.addNote(note);
notes.removeFromIndex(0);
```

#### `Identifier` (and deprecated `Identificator`)

Enum of every GEDCOM tag the library refers to (`INDI`, `BIRT`, `MARR`, `DATE`, `PLAC`, …). Prefer `Identifier`; `Identificator` is kept as a deprecated alias.

```ts
import { Identifier } from "gedcom-ts";

const birthTag = Identifier.BIRT;
```

#### `EventsByYears`, `ActsByYear`

Group acts by year, deduplicating per individual. Handy to build chronological timelines.

```ts
import { EventsByYears, ActsByYear } from "gedcom-ts";

const grouped = new EventsByYears(person.acts.list);
for (const bucket of grouped.events) {
  console.log(bucket.year, bucket.list.length);
}

const yearBucket = new ActsByYear(1901);
```

### Edit layer (in-place, chainable)

Wrap an existing model object to mutate it with a fluent API. Every method returns `this`, so you can chain. `.value` exposes the underlying target.

```ts
import { editPerson, editDateAct, DateAct } from "gedcom-ts";

editPerson(person)
  .setLastname("Dupont")
  .setFirstnames(["Jean", "Marie"])
  .acts()
  .at(0)
  .setDateAct(new DateAct("1 JAN 1900"));

editDateAct(person.acts.list[0].dateAct!).setExactDate(1900, "JAN", 1);
```

| Helper / class                                         | Purpose                                                                                                                                                                                                                   |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `editPerson(person)` / `PersonEdit`                    | Identity, `FAMS` / `FAMC` / `removeFams*`, `nameVariants()` / `attributes()` / `multimedia()`, bulk `clear*`, entry points to `acts()` and `notes()`.                                                                     |
| `editActs(acts)` / `ActsEdit`                          | `add`, `addNew`, `insertAt`, `insertNewAt`, `replaceAt`, `removeAt`, `removeAct`, `removeLast`, `clear`, `sortByDate`, `at`, `indexOfAct`.                                                                                |
| `editAct(act)` / `ActEdit`                             | `setType`, `setIndis`, dates, place, EVEN fields, preserved lines, `notes()`, `multimedia()`, `asAudioInterview()`, `clearNotes`, `clearMultimedia`.                                                                      |
| `editAudioInterviewAct(act)` / `AudioInterviewActEdit` | Oral-history facade: `attachAudio`, `setTranscription`, `setDescription`, `registerPendingObje`, `isAudioInterview`, …                                                                                                    |
| `editDateAct(dateAct)` / `DateActEdit`                 | `clear`, `applyGedcomPayload`, `setExactDate`, `setQualified`, `setBetween`, `setFromTo`, `setTime`, `setDatePhrase` / `appendDatePhrase`, `setVerbatimPayload`.                                                          |
| `editPlace(place)` / `PlaceEdit`                       | `setFromGedcom7Payload`, `setCity`, `setCounty`, `setState`, `setCountry`, `setPlacPhrase` / `appendPlacPhrase` / `clearPlacPhrase`, `setCoordinates` / `clearCoordinates` / `replaceCoordinateModel`, `clearStructured`. |
| `editNotes(notes)` / `NotesEdit`                       | `add`, `addNew`, `insertAt`, `insertNewAt`, `replaceAt`, `removeAt`, `removeNote`, `removeLast`, `clear`, `at`, `indexOfNote`.                                                                                            |

### Dataset editing: `ReadGed`, graph, clone, export options, validation

Higher-level helpers complement the per-object edit facades:

```ts
import type { GedcomExportOptions } from "gedcom-ts";
import {
  createEmptyReadGed,
  createPersonStub,
  DateAct,
  Sex,
  editReadGed,
  editGedcomExportOptions,
  clonePerson,
  cloneAct,
  createMarriageFamily,
  linkChildToFamily,
  removeFamilyReferencesFromDataset,
  addPersonToReadGed,
  validateReadGed,
  Identifier,
  Act,
} from "gedcom-ts";

const readGed = createEmptyReadGed();
const p = createPersonStub(readGed.generateUniqueIndi(), {
  sex: Sex.M,
  firstnames: ["Jean"],
  lastname: "Dupont",
});
editReadGed(readGed).addPerson(p);
p.acts.add(new Act(Identifier.BIRT, new DateAct("1900")));

editReadGed(readGed).preserved().append("0 @S42@ SOUR Custom");

const opts: GedcomExportOptions = {};
editGedcomExportOptions(opts)
  .setHeadCopyright("© 2026")
  .setExtraTopLevelRecords([...readGed.preservedTopLevelRecords]);

const twin = clonePerson(p, readGed.generateUniqueIndi());
const birthCopy = cloneAct(p.acts.list[0]!);

// const fam = createMarriageFamily(readGed, spouseA, spouseB, { dateAct: new DateAct("1 JAN 2000") }, { eventTag: Identifier.ENGA });
// linkChildToFamily(readGed, child, fam);
// removeFamilyReferencesFromDataset(readGed, fam);

validateReadGed(readGed, {
  checkMarrParticipants: true,
  checkFamcWithoutSpouses: true,
  checkFamsWithoutSpouses: true,
  checkDuplicateFamsEntries: true,
  checkAncestorCycles: true,
});
```

#### Commands (invariants before mutation)

`tryAddPersonToReadGed`, `tryRemovePersonFromReadGedByIndi`, `tryLinkChildToFamily`, `tryUnlinkChildFromFamily`, and `tryCreateMarriageFamily` return a `CommandResult` (`ok` + `issues` or `value` + optional `warnings`). `editReadGed(...).addPerson` uses these checks internally (throws on blocking errors). For UI or transactional flows, prefer the `try*` APIs and inspect `commandBlockingIssues`.

| Export                                                                                                                                                              | Role                                                                                                                                                                                                                                                       |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `editReadGed(readGed)` / `ReadGedEdit`                                                                                                                              | `addPerson`, `removePersonByIndi`, `preserved()` → `PreservedTopLevelEdit` (`append`, `insertAt`, `replaceAt`, `removeAt`, `clear`) on `preservedTopLevelRecords`. Low-level helpers: `addPersonToReadGed`, `removePersonFromReadGedByIndi`.               |
| `editGedcomExportOptions(opts)` / `GedcomExportOptionsEdit`                                                                                                         | Fluent setters for `extraTopLevelRecords`, `objeRecordsById`, `headLanguageTag`, `headCopyright`, `headDestination`, `headSchemaTagDefs`.                                                                                                                  |
| `clonePerson(person, newIndi)` / `cloneAct(act)` / `person.clone` / `act.clone`                                                                                     | Deep copies for templates or undo stacks.                                                                                                                                                                                                                  |
| `nextFamilyId(persons)`                                                                                                                                             | Next internal family id `F`.                                                                                                                                                                                                                               |
| `createMarriageFamily`, `linkChildToFamily`, `unlinkChildFromFamily`, `removeFamilyReferencesFromDataset`                                                           | Create family `F` and spouse acts; optional `{ eventTag }` (default `MARR`, also `ENGA`, banns, contract, `EVEN` + `CreateActInit`, …). See `GEDCOM_7_PAIR_UNION_EVENT_TAGS`.                                                                              |
| `validateReadGed`, `assertReadGedConsistent`, `validatePerson`, `assertPersonConsistent`                                                                            | Typed `code` / `severity` (`error` \| `warn`). Options: `checkMarrParticipants`, `checkDuplicateIndis`, `checkFamcWithoutSpouses`, `checkFamsWithoutSpouses`, `checkDuplicateFamsEntries`, `checkAncestorCycles`. Assertions: `failOn` (default: `error`). |
| `tryAddPersonToReadGed`, `tryRemovePersonFromReadGedByIndi`, `tryLinkChildToFamily`, `tryUnlinkChildFromFamily`, `tryCreateMarriageFamily`, `commandBlockingIssues` | Command layer with `CommandResult` / `validate*Command` prechecks.                                                                                                                                                                                         |

### Utilities

#### `createSosaMap(root, partnersMap)`

Computes a Sosa numbering (Ahnentafel) starting at `root` (Sosa 1) and recursively walking ancestors via `partnersMap`.

```ts
import { createSosaMap, Person } from "gedcom-ts";

const sosaMap = createSosaMap(root, readGed.partnersMap);
// e.g. sosaMap.get(root.INDI) === 1
```

#### `remainingTypesAct(acts, actToUpdate?)`

Returns the list of event types still allowed for a person’s `Acts`, enforcing the “unique per individual” rule for `BIRT` / `DEAT` / `BURI` / `CHR` while keeping every other tag selectable. Pass the currently edited act as `actToUpdate` so its own type stays in the list when re-opening a form.

```ts
import { remainingTypesAct, Acts } from "gedcom-ts";

const available = remainingTypesAct(new Acts());
```

#### `getCityCoordinates(cityName, callback)` (deprecated)

Legacy callback API. Use [Geocoding places](#geocoding-places) instead.

#### `resolveDatasetVersion(headerLines)` / `GedcomDatasetVersion`

Inspects the lines of a `0 HEAD` block and returns `"7.0"`, `"5.5"` or `"unknown"`. Useful to branch UI behaviour for legacy datasets.

```ts
import { resolveDatasetVersion } from "gedcom-ts";

const version = resolveDatasetVersion(headLines); // "7.0" | "5.5" | "unknown"
```

#### `guessMediaFormFromUri(uri)`

Best-effort media type detection from a path/URI or local `File` (`file.type` when set). Covers images (incl. **tiff**, **heic**, svg), **audio** (mp3, **webm**, ogg, wav, m4a, flac, …), **video** (mp4, mov, mkv, …), subtitles (vtt, srt), and documents (pdf, djvu, docx, odt, xlsx, csv, html). Used to fill `OBJE`.`FILE`.`FORM`. Pair with `guessMediFromForm` for `MEDI` (`AUDIO`, `PHOTO`, `VIDEO`, `ELECTRONIC`, …). `refineGenericObjeForm` upgrades legacy `application/octet-stream` exports using the file extension.

```ts
import { guessMediaFormFromUri, guessMediaFormFromFile } from "gedcom-ts";

guessMediaFormFromUri("media/recording.webm"); // "audio/webm"
guessMediaFormFromFile(new File(["x"], "take.webm", { type: "audio/webm" })); // "audio/webm"
```

#### `extractPersonNameVariants(personLines)` / `extractIndiAttributes(personLines)`

Low-level parsers used by `Person.createPersonJson`. They turn the raw GEDCOM lines of a single `INDI` record into structured `PersonNameVariant[]` / `IndiAttribute[]`. Reuse them when parsing custom GEDCOM fragments outside `ReadGed`.

```ts
import { extractPersonNameVariants, extractIndiAttributes } from "gedcom-ts";

const variants = extractPersonNameVariants(rawIndiLines);
const attributes = extractIndiAttributes(rawIndiLines);
```

#### `selectPrimaryNameVariant(variants)`

Picks the most relevant `1 NAME` block: priority to `2 TYPE BIRTH`, then to a name with a `/surname/` payload, otherwise the first variant.

```ts
import { selectPrimaryNameVariant } from "gedcom-ts";

const primary = selectPrimaryNameVariant(person.nameVariants);
```

#### `GEDCOM_LIBRARY_VERSION`

CalVer string written in exported `HEAD`.`SOUR`.`VERS` (same value as the npm package version, e.g. `2026.6.4`).

```ts
import { GEDCOM_LIBRARY_VERSION } from "gedcom-ts";

console.log(`gedcom-ts ${GEDCOM_LIBRARY_VERSION}`);
```

## Error handling

```ts
import { importGedFile, IMPORT_ERR_ZIP_GED_UNREADABLE } from "gedcom-ts";

try {
  const readGed = await importGedFile(file);
  console.log(readGed.persons.length);
} catch (error) {
  if (
    error instanceof Error &&
    error.message === IMPORT_ERR_ZIP_GED_UNREADABLE
  ) {
    console.error("Encrypted ZIP: please extract the .ged manually.");
  } else {
    console.error("GED import failed:", error);
  }
}
```
