Picarones / picarones /measurements /early_modern_typography.py
Claude
refactor(measurements): promouvoir modules philologiques/acadรฉmiques/governance depuis extras/
7a072e2 unverified
Raw
History Blame
13.1 kB
"""Marqueurs typographiques de l'imprimรฉ ancien (XVIแต‰-XVIIIแต‰).
Sprint 58 โ€” ร‰tape 3 / extension philologique du plan d'รฉvolution
2026.
Pourquoi ce module
------------------
Les Sprints 56 (abrรฉviations Capelli) et 57 (couverture MUFI) sont
orientรฉs **mรฉdiรฉval scribal**. Mais Picarones doit aussi servir
les รฉditeurs d'**imprimรฉs anciens** (XVIแต‰-XVIIIแต‰ siรจcles), pour
qui les marqueurs caractรฉristiques ne sont pas scribaux mais
**typographiques** : ligatures composรฉes (๏ฌ, ๏ฌ‚, ๏ฌ€, ๏ฌƒ, ๏ฌ„, ๏ฌ…),
s long (ลฟ), i sans point (ฤฑ), esperluette (&), tildes nasaux
indiquant une abrรฉviation (รฃ = an/am, รต = on/om).
Distinction avec MUFI/abbreviations
------------------------------------
- ``mufi.py`` (Sprint 57) : caractรจres mรฉdiรฉvaux scribaux
(Capelli + lettres รพ รฐ ฦฟ + PUA MUFI).
- ``abbreviations.py`` (Sprint 56) : signes d'abrรฉviation latins
scribaux mรฉdiรฉvaux (๊‘ ๊“ โŠ + tildes scribaux).
- ``early_modern_typography.py`` (ce module) : marqueurs
**typographiques** de la composition imprimรฉe ancienne.
Les ligatures ๏ฌ et ๏ฌ‚ sont communes aux deux univers (mรฉdiรฉval et
imprimรฉ ancien) ; le choix du module ร  utiliser dรฉpend du **corpus**
et de l'angle d'analyse รฉditoriale, pas du caractรจre pris isolรฉment.
Catรฉgorisation
--------------
Les marqueurs sont classรฉs en cinq catรฉgories pour permettre un
breakdown รฉditorial :
1. ``ligatures`` : ๏ฌ ๏ฌ‚ ๏ฌ€ ๏ฌƒ ๏ฌ„ ๏ฌ…
2. ``long_s`` : ลฟ
3. ``dotless_i`` : ฤฑ
4. ``ampersand`` : & (esperluette typographique)
5. ``nasal_tildes`` : รฃ รต ลฉ รฑ ฤ“ ฤซ (abrรฉviation par tilde nasal)
``compute_early_modern_metrics`` retourne le taux de prรฉservation
par catรฉgorie + global.
"""
from __future__ import annotations
import logging
from difflib import SequenceMatcher
from typing import Optional
from picarones.core.metric_registry import register_metric
from picarones.core.modules import ArtifactType
logger = logging.getLogger(__name__)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Marqueurs typographiques imprimรฉ ancien
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Ligatures typographiques hรฉritรฉes de l'incunable (XVแต‰) et toujours
# courantes jusqu'au XVIIIแต‰ avant la normalisation typographique.
LIGATURES: frozenset[str] = frozenset({
"๏ฌ€", # U+FB00 ff
"๏ฌ", # U+FB01 fi
"๏ฌ‚", # U+FB02 fl
"๏ฌƒ", # U+FB03 ffi
"๏ฌ„", # U+FB04 ffl
"๏ฌ…", # U+FB05 long s + t
"๏ฌ†", # U+FB06 st
})
# S long : Latin Extended-A. Caractรฉristique de la typographie
# antรฉrieure ร  1800.
LONG_S: frozenset[str] = frozenset({"ลฟ"}) # U+017F
# i sans point : utilisรฉ en typographie ancienne, parfois confondu
# avec un l ou un 1 par les OCR modernes.
DOTLESS_I: frozenset[str] = frozenset({"ฤฑ"}) # U+0131
# Esperluette typographique : "&" remplace frรฉquemment "et" dans
# les imprimรฉs ; sa prรฉservation discrimine un OCR diplomatique
# d'un OCR modernisant.
AMPERSAND: frozenset[str] = frozenset({"&"})
# Tildes nasaux : prรฉ-composรฉs (รฑ รฃ แบฝ ฤฉ รต ลฉ) ou sรฉquences
# lettre + U+0303 combinant. En imprimรฉ ancien, รฃ = an/am abrรฉgรฉ,
# รต = on/om, etc. Distinction avec les tildes scribaux mรฉdiรฉvaux
# (Sprint 56) : ici on cible les **prรฉ-composรฉs** ou sรฉquences sur
# des voyelles (le scribal mรฉdiรฉval cible plutรดt pฬƒ qฬƒ).
NASAL_TILDE_PRECOMPOSED: frozenset[str] = frozenset({
"รฃ", "รƒ", # U+00E3 / U+00C3
"รฑ", "ร‘", # U+00F1 / U+00D1
"รต", "ร•", # U+00F5 / U+00D5
"ลฉ", "ลจ", # U+0169 / U+0168
"แบฝ", "แบผ", # U+1EBD / U+1EBC
"ฤฉ", "ฤจ", # U+0129 / U+0128
})
# Voyelles susceptibles de porter un tilde combinant pour former
# un tilde nasal (couvre les รฉcritures NFD non prรฉ-composรฉes).
_NASAL_TILDE_VOWELS: frozenset[str] = frozenset(
"aeiouAEIOU"
)
_COMBINING_TILDE = "ฬƒ"
# Catรฉgorisation : nom โ†’ set de caractรจres prรฉ-composรฉs ou sรฉquences.
_CATEGORIES: dict[str, frozenset[str]] = {
"ligatures": LIGATURES,
"long_s": LONG_S,
"dotless_i": DOTLESS_I,
"ampersand": AMPERSAND,
"nasal_tildes": NASAL_TILDE_PRECOMPOSED,
}
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Dรฉtection des marqueurs dans la GT
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _detect_markers(text: str) -> list[tuple[int, str, str]]:
"""Retourne les positions des marqueurs typographiques dans
``text``.
Forme de sortie : ``[(index, marker, category), ...]`` dans
l'ordre d'apparition. Pour les tildes nasaux non
prรฉ-composรฉs, on dรฉtecte les sรฉquences ``voyelle + U+0303`` et
on retourne l'index de la voyelle.
"""
if not text:
return []
found: list[tuple[int, str, str]] = []
i = 0
while i < len(text):
ch = text[i]
# Cas 1 : marqueur prรฉ-composรฉ dans une catรฉgorie
category = _category_of_char(ch)
if category is not None:
found.append((i, ch, category))
i += 1
continue
# Cas 2 : voyelle + tilde combinant โ†’ nasal_tildes
if (
ch in _NASAL_TILDE_VOWELS
and i + 1 < len(text)
and text[i + 1] == _COMBINING_TILDE
):
seq = ch + _COMBINING_TILDE
found.append((i, seq, "nasal_tildes"))
i += 2
continue
i += 1
return found
def _category_of_char(ch: str) -> Optional[str]:
"""Retourne la catรฉgorie d'un caractรจre typographique ou
``None`` s'il n'est pas reconnu."""
for cat, chars in _CATEGORIES.items():
if ch in chars:
return cat
return None
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Calcul de la prรฉservation par catรฉgorie
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def compute_early_modern_metrics(
reference: Optional[str],
hypothesis: Optional[str],
) -> dict:
"""Mesure la prรฉservation des marqueurs typographiques de
l'imprimรฉ ancien dans l'OCR.
Stratรฉgie d'alignement
----------------------
Pour chaque marqueur identifiรฉ dans la GT ร  la position ``i``,
on vรฉrifie si l'OCR l'a prรฉservรฉ en utilisant l'alignement
caractรจre par caractรจre via ``difflib.SequenceMatcher`` (mรชme
mรฉthode que les Sprints 55/57) :
- Marqueur **mono-caractรจre** (๏ฌ, ลฟ, ฤฑ, &, รฃโ€ฆ) : la position
``i`` est-elle dans un opcode ``equal`` ?
- Marqueur **bi-caractรจre** (voyelle + U+0303) : les positions
``i`` et ``i+1`` sont-elles toutes deux dans un opcode
``equal`` ?
Returns
-------
dict
``{
"n_markers_reference": int,
"n_markers_preserved": int,
"global_preservation": float, # โˆˆ [0, 1]
"per_category": {
category: {"total", "preserved", "preservation"}
},
"missed_markers": [{"index", "marker", "category"}, ...],
}``
Cas dรฉgรฉnรฉrรฉs : GT vide ou sans marqueur โ†’ tous compteurs ร  0,
``global_preservation = 0``.
"""
ref = reference or ""
hyp = hypothesis or ""
# Forme NFD pour reconnaรฎtre les tildes nasaux dรฉcomposรฉs (รฃ =
# 'a' + U+0303) cรดtรฉ GT โ€” on conserve toutefois la forme passรฉe
# pour les indices rapportรฉs dans missed_markers.
markers = _detect_markers(ref)
n_total = len(markers)
if n_total == 0:
return {
"n_markers_reference": 0,
"n_markers_preserved": 0,
"global_preservation": 0.0,
"per_category": {},
"missed_markers": [],
}
# Aligner GT/hyp et rรฉcupรฉrer le set des positions GT couvertes
# par un opcode "equal".
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
correct_positions: set[int] = set()
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
if op == "equal":
correct_positions.update(range(i1, i2))
per_cat_total: dict[str, int] = {}
per_cat_preserved: dict[str, int] = {}
n_preserved = 0
missed: list[dict] = []
for index, marker, category in markers:
per_cat_total[category] = per_cat_total.get(category, 0) + 1
# Marqueur prรฉservรฉ si toutes ses positions GT sont dans
# un opcode "equal".
marker_len = len(marker)
positions_ok = all(
(index + k) in correct_positions for k in range(marker_len)
)
if positions_ok:
per_cat_preserved[category] = (
per_cat_preserved.get(category, 0) + 1
)
n_preserved += 1
else:
missed.append({
"index": index,
"marker": marker,
"category": category,
})
per_category = {
cat: {
"total": per_cat_total[cat],
"preserved": per_cat_preserved.get(cat, 0),
"preservation": (
per_cat_preserved.get(cat, 0) / per_cat_total[cat]
if per_cat_total[cat] > 0
else 0.0
),
}
for cat in sorted(per_cat_total)
}
return {
"n_markers_reference": n_total,
"n_markers_preserved": n_preserved,
"global_preservation": n_preserved / n_total,
"per_category": per_category,
"missed_markers": missed,
}
def early_modern_preservation(
reference: Optional[str], hypothesis: Optional[str],
) -> float:
"""Raccourci : taux global de prรฉservation des marqueurs
typographiques de l'imprimรฉ ancien."""
return compute_early_modern_metrics(
reference, hypothesis,
)["global_preservation"]
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Helpers exposรฉs
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def detect_markers(text: Optional[str]) -> list[tuple[int, str, str]]:
"""Wrapper public sur ``_detect_markers`` (acceptant ``None``)."""
return _detect_markers(text or "")
def get_category(char: str) -> Optional[str]:
"""Retourne la catรฉgorie typographique d'un caractรจre
(``ligatures``, ``long_s``, ``dotless_i``, ``ampersand``,
``nasal_tildes``) ou ``None``.
Pour un tilde combinant suivi d'une voyelle, l'utilisateur doit
utiliser ``detect_markers`` qui gรจre les sรฉquences.
"""
return _category_of_char(char[0]) if char else None
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Enregistrement dans le registre typรฉ (Sprint 34)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@register_metric(
name="early_modern_preservation",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description=(
"Taux de prรฉservation des marqueurs typographiques de "
"l'imprimรฉ ancien (XVIแต‰-XVIIIแต‰) : ligatures ๏ฌ ๏ฌ‚ ๏ฌ€, s long ลฟ, "
"i sans point ฤฑ, esperluette &, tildes nasaux รฃ รต. Critรจre "
"รฉditorial pour les รฉditions diplomatiques d'imprimรฉs anciens."
),
higher_is_better=True,
tags={"text", "typography", "early_modern", "philology"},
)
def _registered_early_modern(reference: str, hypothesis: str) -> float:
return early_modern_preservation(reference, hypothesis)
__all__ = [
"LIGATURES",
"LONG_S",
"DOTLESS_I",
"AMPERSAND",
"NASAL_TILDE_PRECOMPOSED",
"detect_markers",
"get_category",
"compute_early_modern_metrics",
"early_modern_preservation",
]