feat: descrição arquivística NOBRADE multinível com exportação EAD 3, EAC-CPF e AtoM

- pipeline/nobrade: normalizadores de datas (ISO 8601-2), séries, autoridades,
  espécie/suporte, direitos, lugar e assuntos; montagem dos níveis 0–5 com
  código de referência BR RJXYZ.BF.<seção>.<série>.<handle>.<imagem>
- validador dos elementos obrigatórios: 0 pendências em 26.454 unidades
- EAD 3 e 457 EAC-CPF validados contra os XSD
- mural: cartão com código, data normalizada, série e reprodução; filtro por série
This commit is contained in:
2026-09-30 21:22:00 +02:00
parent e92df83a78
commit 23b56e8c87
46 changed files with 19562 additions and 68 deletions
View File
+53
View File
@@ -0,0 +1,53 @@
"""python -m nobrade (de dentro de pipeline/)
Gera a descrição NOBRADE completa em descricao/: unidades, autoridades,
tabelas de revisão, pendências, EAD 3, EAC-CPF e CSV do AtoM, e valida os XML
contra os XSD de nobrade/xsd/ quando o xmllint está disponível.
"""
import shutil
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
from . import exporta, montar, valida
from .config import SAIDA
def main():
print('montando unidades…', flush=True)
unidades, aut = montar.monta()
lista = sorted(unidades.values(), key=lambda u: (u['nivel'], u['id']))
lista = [{k: v for k, v in u.items() if not k.startswith('_') and v is not None} for u in lista]
pend = valida.valida(lista)
print(f'{len(lista)} unidades, {len(aut.registros)} autoridades, {pend} pendências obrigatórias')
print('exportando EAD 3, EAC-CPF e AtoM…', flush=True)
exporta.ead(lista, aut.registros)
usos = defaultdict(list)
for u in lista:
if u['nivel'] == 4:
for p in u['2.1']:
if p.get('id'):
usos[p['id']].append(u['id'])
exporta.eac(list(aut.registros.values()), usos)
exporta.atom(lista)
if shutil.which('xmllint') and '--sem-xsd' not in sys.argv:
xsd = Path(__file__).parent / 'xsd'
r = subprocess.run(['xmllint', '--noout', '--huge', '--schema', xsd / 'ead3.xsd',
SAIDA / 'ead' / 'brasiliana.xml'], capture_output=True, text=True)
print('EAD 3:', (r.stderr.strip().splitlines() or ['?'])[-1])
eacs = sorted((SAIDA / 'eac').glob('*.xml'))
r = subprocess.run(['xmllint', '--noout', '--schema', xsd / 'cpf.xsd', *eacs],
capture_output=True, text=True)
falhas = [x for x in r.stderr.splitlines() if 'fails to validate' in x or 'error' in x.lower()]
print(f'EAC-CPF: {len(eacs)} arquivos, {len(falhas)} com erro')
for x in falhas[:10]:
print(' ', x)
return 1 if pend else 0
if __name__ == '__main__':
sys.exit(main())
+119
View File
@@ -0,0 +1,119 @@
"""Registros de autoridade (ISAAR(CPF)) para produtores — elemento 2.1 e 8.1.
Junta as grafias de `dc.contributor` ("Malta, Augusto", "Malta, Augusto,
1864-1957", "Marc Ferrez" e "Ferrez, Marc") numa forma autorizada, com as
variantes como remissivas (proc. 8.1.4).
"""
from __future__ import annotations
import re
import unicodedata
from collections import Counter, defaultdict
ANONIMO = re.compile(r'(?i)^\s*(an[oô]nimo|autoria n[aã]o identificada|s/\s*autor|sem autor|desconhecid[oa]|'
r'n[aã]o identificad[oa]|autor n[aã]o identificado|autor desconhecido)\s*\.?$')
COLETIVA = re.compile(r'(?i)\b(escola|officina|oficina|photographia|fotografia|photo|foto|studio|est[uú]dio|atelier|'
r'casa|companhia|cia\.?|museu|instituto|minist[eé]rio|servi[cç]o|diretoria|departamento|'
r'comiss[aã]o|biblioteca|arquivo|editora|lith|litografia|tipografia|typ|&|irm[aã]os|'
r'sociedade|empresa|jornal|revista|ag[eê]ncia|governo|prefeitura|marinha|ex[eé]rcito|'
r'for[cç]a|aeron[aá]utica|avia[cç][aã]o|universidade|fundação)\b')
DATAS = re.compile(r',?\s*((?:ca\.\s*|fl\.\s*|m\.\s*|n\.\s*)?\d{4}\??(?:\s*-\s*(?:ca\.\s*)?\d{0,4}\??)?)\.?\s*$')
PARTICULAS = {'de', 'da', 'do', 'das', 'dos', 'e', 'von', 'van', 'du', 'del', 'di'}
def _chave(s: str) -> str:
s = ''.join(c for c in unicodedata.normalize('NFD', s.lower()) if unicodedata.category(c) != 'Mn')
s = re.sub(r'\(.*?\)', '', s)
return re.sub(r'[^\w,]+', ' ', s).strip(' ,')
def _separa_datas(nome: str) -> tuple[str, str | None]:
nome = nome.strip().rstrip('.')
if m := DATAS.search(nome):
if ',' in nome[:m.start()] or m[1].startswith(('fl', 'm.', 'n.', 'ca')):
return nome[:m.start()].strip(' ,'), m[1]
return nome, None
def _inverte(nome: str) -> str | None:
"""'Marc Ferrez' → 'Ferrez, Marc'. Só para nomes de pessoa sem vírgula."""
partes = nome.split()
if len(partes) < 2 or ',' in nome:
return None
i = len(partes) - 1
while i > 1 and partes[i - 1].lower() in PARTICULAS:
i -= 1
return f"{' '.join(partes[i:])}, {' '.join(partes[:i])}"
def periodo(datas: str | None) -> tuple[int, int] | None:
"""Anos prováveis de atividade: fl. A-B, ou nascimento+15 até a morte."""
if not datas:
return None
anos = [int(a) for a in re.findall(r'\d{4}', datas)]
if not anos:
return None
if datas.startswith('fl'):
return (anos[0], anos[-1]) if len(anos) > 1 else (anos[0], anos[0])
if datas.startswith('m.'):
return None
if len(anos) == 2:
return anos[0] + 15, anos[1]
return None
class Autoridades:
def __init__(self):
self.contagem: Counter = Counter()
def registra(self, nome: str):
if nome and nome.strip() and not ANONIMO.match(nome):
self.contagem[nome.strip()] += 1
def resolve(self):
"""Agrupa as grafias. Depois disso `forma(nome)` devolve o id da autoridade."""
grupos: dict[str, Counter] = defaultdict(Counter)
for nome, n in self.contagem.items():
base, _ = _separa_datas(nome)
grupos[_chave(base)][nome] += n
# grafias diretas ("Marc Ferrez") que batem com uma invertida já conhecida
for k in list(grupos):
if ',' in k:
continue
nome = next(iter(grupos[k]))
if COLETIVA.search(nome):
continue
inv = _inverte(_separa_datas(nome)[0])
if inv and _chave(inv) in grupos and _chave(inv) != k:
grupos[_chave(inv)].update(grupos.pop(k))
self.registros: dict[str, dict] = {}
self._id_de: dict[str, str] = {}
for k, variantes in sorted(grupos.items(), key=lambda kv: -sum(kv[1].values())):
# forma autorizada: a variante mais completa (com datas) e mais usada
# (datas com início e fim > só uma data > nenhuma), depois frequência
def peso(v):
dt = _separa_datas(v)[1] or ''
return (len(re.findall(r'\d{4}', dt)), variantes[v], v)
forma = max(variantes, key=peso)
forma = forma.strip().rstrip('.')
base, datas = _separa_datas(forma)
tipo = 'entidade coletiva' if COLETIVA.search(base) else ('pessoa' if ',' in base else 'pessoa ou entidade')
if re.match(r'(?i)fam[ií]lia\b', base):
tipo = 'família'
aid = 'A' + str(len(self.registros) + 1).zfill(4)
self.registros[aid] = {
'id': aid, 'forma_autorizada': forma, 'nome': base, 'datas_existencia': datas,
'tipo': tipo, 'variantes': sorted(v for v in variantes if v.strip().rstrip('.') != forma),
'ocorrencias': sum(variantes.values()), 'periodo_atividade': periodo(datas),
'fontes': [],
}
for v in variantes:
self._id_de[v] = aid
def id(self, nome: str) -> str | None:
return self._id_de.get((nome or '').strip())
def __getitem__(self, aid: str) -> dict:
return self.registros[aid]
+177
View File
@@ -0,0 +1,177 @@
"""Normalizadores de campo único: espécie/suporte (1.5), características
físicas (1.5/4.4), direitos (4.2), idioma (4.3), lugar (1.3.1 data tópica) e
assuntos (8.1)."""
from __future__ import annotations
import re
import unicodedata
CORROMPIDO = re.compile(r'[<>{}]|ns:|PALAVRAS-CHAVE|^[0-9a-f]{6,}|\d[a-f]\d[a-f]\d')
def sem_acento(s: str) -> str:
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
def corrompido(v: str | None) -> bool:
return bool(v) and bool(CORROMPIDO.search(v))
# ── 1.5 espécie e suporte (a partir de dc.type) ─────────────────────────────
ESPECIES = [ # (padrão, gênero, espécie)
(r'^negativo', 'iconográfico', 'negativo fotográfico'),
(r'^diapositivo', 'iconográfico', 'diapositivo'),
(r'reprodu[cç][aã]o fotomec', 'iconográfico', 'reprodução fotomecânica'),
(r'^(fotografia|vista panor)', 'iconográfico', 'fotografia'),
(r'^gravura', 'iconográfico', 'gravura'),
(r'^desenho', 'iconográfico', 'desenho'),
(r'^[áa]lbum', 'iconográfico', 'álbum fotográfico'),
(r'^manuscrito', 'textual', 'manuscrito'),
(r'^livro', 'bibliográfico', 'livro'),
(r'^artigo de peri', 'bibliográfico', 'periódico'),
]
def especie(tipo: str | None) -> dict:
"""{'genero', 'especie', 'suporte', 'presumida'}. Sem dc.type (ou com lixo),
presume fotografia — o acervo inteiro é um repositório fotográfico — e marca."""
t = (tipo or '').strip()
if t and not corrompido(t):
base, _, sup = t.partition('/')
b = sem_acento(base.strip().lower())
for pat, gen, esp in ESPECIES:
if re.search(pat, b) or re.search(pat, base.strip().lower()):
suporte = sup.strip().lower() or None
if esp == 'negativo fotográfico' and 'flex' in b:
suporte = 'flexível'
return {'genero': gen, 'especie': esp, 'suporte': suporte, 'presumida': False}
return {'genero': 'iconográfico', 'especie': 'fotografia', 'suporte': None, 'presumida': True,
'original': t or None}
# ── características físicas que algumas instituições põem em dc.description ──
FISICO = re.compile(
r'(?i)^\s*(p\s*&\s*b|p/b|preto e branco|cor(ida)?|colorid[ao]|s[ée]pia|'
r'albumina|gelatina|prata|col[oó]dio|cianotipia|platina|carv[aã]o|ambr[oó]tipo|daguerre[oó]tipo|'
r'negativo|acetato|nitrato|vidro|papel|cart[aã]o|\d+[,.]?\d*\s*x\s*\d+|processo:|segunda gera)')
def e_fisico(v: str) -> bool:
return bool(FISICO.match(v)) and len(v) < 120
def cor(fisico: list[str]) -> str | None:
t = ' '.join(fisico).lower()
if re.search(r'p\s*&\s*b|p/b|preto e branco', t):
return 'p&b'
if re.search(r'colorid|\bcor\b', t):
return 'cor'
return None
# ── 4.2 condições de reprodução ─────────────────────────────────────────────
def direitos(textos: list[str], instituicao: str) -> dict:
"""{'categoria', 'detentor', 'texto', 'originais'}."""
limpos = list(dict.fromkeys(t.strip() for t in textos if t and t.strip() and t.strip() != 'None'))
junto = sem_acento(' '.join(limpos).lower())
if re.search(r'dominio publico|omínio público|omi?nio publico', junto):
cat = 'domínio público'
elif 'liberado para uso de natureza cultural' in junto:
cat = 'uso cultural liberado'
elif re.search(r'solicitar|requer libera', junto):
cat = 'requer autorização do detentor'
else:
cat = 'consultar o detentor'
detentor = None
for t in limpos:
a = sem_acento(t.lower())
if corrompido(t) or re.search(r'dominio publico|solicitar|requer|liberado', a) or len(t) < 6:
continue
if 'bibli' in a and 'nac' in a:
detentor = 'Biblioteca Nacional (Brasil)'
elif 'moreira' in a and 'salles' in a and '/' not in t:
detentor = 'Instituto Moreira Salles'
else:
detentor = t
break
detentor = detentor or instituicao
frases = {
'domínio público': 'Imagem em domínio público.',
'uso cultural liberado': 'Uso liberado para fins de natureza cultural.',
'requer autorização do detentor': 'Reprodução sujeita a autorização do detentor dos direitos.',
'consultar o detentor': 'Condições de reprodução não informadas na origem; consultar o detentor.',
}
return {'categoria': cat, 'detentor': detentor,
'texto': f'{frases[cat]} Detentor indicado: {detentor}.',
'originais': limpos or None}
# ── 4.3 idioma ───────────────────────────────────────────────────────────────
NOMES_IDIOMA = {'por': 'português', 'fre': 'francês', 'eng': 'inglês', 'lat': 'latim', 'ger': 'alemão',
'ita': 'italiano', 'ukr': 'ucraniano', 'hun': 'húngaro', 'spa': 'espanhol'}
def idiomas(vs: list[str]) -> list[dict]:
out = []
for v in vs:
c = (v or '').strip().lower()
if c in NOMES_IDIOMA and c not in [o['codigo'] for o in out]:
out.append({'codigo': c, 'nome': NOMES_IDIOMA[c]})
return out
# ── 1.3.1 data tópica e 8.1 lugares ─────────────────────────────────────────
UF = {'AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MT', 'MS', 'MG', 'PA', 'PB', 'PR', 'PE',
'PI', 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE', 'TO'}
ESTADOS = {'rio de janeiro': 'RJ', 'sao paulo': 'SP', 'bahia': 'BA', 'pernambuco': 'PE', 'minas gerais': 'MG',
'para': 'PA', 'amazonas': 'AM', 'parana': 'PR', 'rio grande do sul': 'RS', 'santa catarina': 'SC',
'ceara': 'CE', 'maranhao': 'MA', 'espirito santo': 'ES', 'goias': 'GO', 'mato grosso': 'MT',
'paraiba': 'PB', 'alagoas': 'AL', 'sergipe': 'SE', 'piaui': 'PI', 'rio grande do norte': 'RN'}
PAISES = {'brasil', 'brazil'}
def lugar(coberturas: list[str]) -> str | None:
"""A forma mais específica de cidade: 'Cidade (UF)'. Junta 'Rio de Janeiro' + 'RJ'."""
cs = [c.strip() for c in coberturas if c and c.strip() and not corrompido(c)]
for c in cs:
if m := re.fullmatch(r'(.+?)\s*(?:\(|,\s*|\s-\s*)([A-Z]{2})\)?', c):
if m[2] in UF and sem_acento(m[1].lower()) not in PAISES:
return f'{m[1].strip()} ({m[2]})'
ufs = [c for c in cs if c in UF]
for c in cs:
k = sem_acento(c.lower())
if k in PAISES or c in UF or '(estado)' in k or 'state' in k:
continue
if re.search(r'\(([^)]+)\)$', c): # "Paris (França)", "Rio de Janeiro (Brazil)"
nome, pais = re.match(r'(.+?)\s*\(([^)]+)\)$', c).groups()
if sem_acento(pais.lower()) in PAISES:
uf = ESTADOS.get(sem_acento(nome.lower()))
return f'{nome} ({uf})' if uf else nome
return c
uf = ufs[0] if ufs else ESTADOS.get(k)
return f'{c} ({uf})' if uf else c
return None
# ── 8.1 assuntos: separa as traduções inglesas que o DSpace da BN repete ─────
EN = {'the', 'and', 'of', 'in', 'on', 'women', 'men', 'children', 'streets', 'street', 'buildings', 'building',
'churches', 'church', 'views', 'view', 'slaves', 'railroads', 'railroad', 'harbors', 'harbor', 'landscapes',
'portraits', 'bridges', 'rivers', 'houses', 'squares', 'parks', 'gardens', 'mountains', 'beaches',
'ships', 'soldiers', 'history', 'photographs', 'people', 'indians', 'coffee', 'workers', 'palaces',
'monuments', 'waterfalls', 'boats', 'plantations', 'aerial', 'military', 'city', 'cities', 'public',
'social', 'life', 'customs', 'description', 'travel', 'transportation', 'agriculture', 'industry',
'architecture', 'fountains', 'hospitals', 'schools', 'markets', 'lakes', 'bays', 'islands', 'forts',
'wars', 'war', 'emperors', 'kings', 'queens', 'nobility', 'family', 'families', 'politicians'}
PT = {'de', 'da', 'do', 'das', 'dos', 'e', 'em', 'no', 'na', 'nos', 'nas', 'para', 'com'}
def idioma_termo(t: str) -> str:
if re.search(r'[ãõçáéíóúâêô]', t.lower()):
return 'por'
palavras = set(re.findall(r'[a-z]+', t.lower()))
if palavras & PT:
return 'por'
if palavras & EN or re.search(r'\((?:brazil|state)', t.lower()):
return 'eng'
return 'por'
+72
View File
@@ -0,0 +1,72 @@
"""Constantes da descrição NOBRADE — decisões registradas em CONARQ.md §5."""
from pathlib import Path
RAIZ = Path(__file__).resolve().parents[2]
PIPELINE = RAIZ / 'pipeline'
CATALOGO = PIPELINE / 'catalogo.jsonl'
LEDGER = PIPELINE / 'subidor.sqlite'
POSTS = PIPELINE / 'wordpress-posts.jsonl'
ERROS = RAIZ / 'errors.md'
AJUSTES = Path(__file__).resolve().parent / 'ajustes'
SAIDA = RAIZ / 'descricao'
TEXTOS = SAIDA / 'textos'
REVISAO = SAIDA / 'revisao'
# 1.1 — código da entidade custodiadora. PROVISÓRIO até o CONARQ atribuir um
# (proc. 1.1.1). Trocar aqui regenera todos os códigos de referência.
# Formato do CODEARQ, como o AGCRJ usa: país, espaço, UF + sigla, e as partes
# da unidade separadas por ponto — BR RJAGCRJ.SOP.NL.AVE.03.01
PAIS = 'BR'
ENTIDADE = 'RJXYZ'
ENTIDADE_NOME = 'xyz — cópia digital de acervos fotográficos'
ENTIDADE_SITE = 'https://xn--2dk.xyz/brasiliana/'
COLECAO = 'BF'
COLECAO_TITULO = 'Coleção Brasiliana Fotográfica (cópia digital)'
RESPONSAVEL = 'rafapolo'
BUCKET = 's3://brasiliana'
REGRAS = ('NOBRADE: Norma brasileira de descrição arquivística (CONARQ, 2006); '
'ISAD(G) 2ª ed. (CIA, 2000); ISAAR(CPF) 2ª ed. (CIA, 2004); '
'datas em ISO 8601-2 (EDTF); idiomas em ISO 639-2; países em ISO 3166.')
ORIGEM_NOME = 'Brasiliana Fotográfica'
ORIGEM_SITE = 'https://brasilianafotografica.bn.gov.br/'
HANDLE_URL = 'https://brasilianafotografica.bn.gov.br/brasiliana/handle/{}'
# Nível 2 — uma seção por instituição de origem (campo `community` do catálogo).
# sigla → (nome autorizado, cidade)
INSTITUICOES = {
'Instituto Moreira Salles': ('IMS', 'Instituto Moreira Salles', 'Rio de Janeiro (RJ)'),
'Biblioteca Nacional': ('BN', 'Biblioteca Nacional (Brasil)', 'Rio de Janeiro (RJ)'),
'Museu Aeroespacial': ('MUSAL', 'Museu Aeroespacial', 'Rio de Janeiro (RJ)'),
'Museu da Republica': ('MR', 'Museu da República', 'Rio de Janeiro (RJ)'),
'Museu Historico Nacional': ('MHN', 'Museu Histórico Nacional', 'Rio de Janeiro (RJ)'),
'Fiocruz': ('COC', 'Fundação Oswaldo Cruz. Casa de Oswaldo Cruz', 'Rio de Janeiro (RJ)'),
'Diretoria do Patrimonio Historico e Documentacao da Marinha':
('DPHDM', 'Diretoria do Patrimônio Histórico e Documentação da Marinha', 'Rio de Janeiro (RJ)'),
'Arquivo Nacional': ('AN', 'Arquivo Nacional (Brasil)', 'Rio de Janeiro (RJ)'),
'Arquivo Geral da Cidade do Rio de Janeiro':
('AGCRJ', 'Arquivo Geral da Cidade do Rio de Janeiro', 'Rio de Janeiro (RJ)'),
'Leibniz-Institut fuer Laenderkunde, Leipzig':
('IFL', 'Leibniz-Institut für Länderkunde', 'Leipzig (Alemanha)'),
'Instituto Historico e Geografico Brasileiro':
('IHGB', 'Instituto Histórico e Geográfico Brasileiro', 'Rio de Janeiro (RJ)'),
'Fundacao Joaquim Nabuco': ('FUNDAJ', 'Fundação Joaquim Nabuco', 'Recife (PE)'),
'Museu Paraense Emilio Goeldi': ('MPEG', 'Museu Paraense Emílio Goeldi', 'Belém (PA)'),
'Escola de Ciencias Sociais (FGV CPDOC)':
('CPDOC', 'Fundação Getulio Vargas. Escola de Ciências Sociais (FGV CPDOC)', 'Rio de Janeiro (RJ)'),
'Instituto Brasileiro de Geografia e Estatistica':
('IBGE', 'Instituto Brasileiro de Geografia e Estatística', 'Rio de Janeiro (RJ)'),
}
# 4.1 — obrigatório nos níveis 0 e 1
ACESSO = ('Sem restrição de acesso à descrição. As cópias digitais são mantidas '
f'para preservação em {BUCKET} e consultáveis pelo mural em {ENTIDADE_SITE}. '
'O acesso aos originais segue as regras de cada instituição de origem '
'(ver 5.1 em cada dossiê).')
def codigo(*partes: str) -> str:
"""BR RJXYZ, BR RJXYZ.BF, BR RJXYZ.BF.IMS.GF.12345.01 …"""
return f'{PAIS} ' + '.'.join((ENTIDADE,) + tuple(p for p in partes if p))
+333
View File
@@ -0,0 +1,333 @@
"""Normalização de dc.date para o elemento 1.3 da NOBRADE.
Saída de `interpreta(texto)`:
forma — como a NOBRADE manda exibir: `1914`, `[1860]`, `[1902?]`,
`[ca. 1905]`, `[193-]`, `[18--?]`, `8/2/1890-18/4/1890`
iso — ISO 8601-2 (EDTF), para indexação e intercâmbio (proc. 1.3.2.7):
`1914`, `1902?`, `1905~`, `193X`, `18XX?`, `1931/1937`, `../1921`
inicio, fim — anos inteiros (ou None) para filtrar e ordenar
qualidade — `exata` | `atribuida` | `incerta` | `aproximada`
tipo — `produção` (padrão) ou `publicação`
motivo — quando não deu para interpretar: `sem data`, `registro de sistema`,
`ilegível`
A regra geral: tira do texto as marcas (colchetes, `?`, `circa`, qualificadores
entre parênteses), interpreta o que sobra como ponto ou intervalo e remonta a
forma com as marcas no padrão da norma (proc. 1.3.2.1–1.3.2.4).
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
MESES = {
'jan': 1, 'janeiro': 1, 'fev': 2, 'fevereiro': 2, 'mar': 3, 'março': 3, 'marco': 3,
'abr': 4, 'abril': 4, 'mai': 5, 'maio': 5, 'jun': 6, 'junho': 6,
'jul': 7, 'julho': 7, 'ago': 8, 'agosto': 8, 'set': 9, 'setembro': 9,
'out': 10, 'outubro': 10, 'nov': 11, 'novembro': 11, 'dez': 12, 'dezembro': 12,
}
MES_RE = '(' + '|'.join(sorted(MESES, key=len, reverse=True)) + r')\.?'
SEM_DATA = re.compile(r'^(s\.?\s*/?\s*d\.?|s/data|sem data|s\.?\s*n\.?|n[aã]o identificad[ao])$', re.I)
@dataclass
class Ponto:
ano: int
mes: int | None = None
dia: int | None = None
prec: str = 'ano' # dia | mes | ano | decada | seculo
def forma(self) -> str:
if self.prec == 'dia':
return f'{self.dia}/{self.mes}/{self.ano}'
if self.prec == 'mes':
return f'{self.mes}/{self.ano}'
if self.prec == 'decada':
return f'{self.ano // 10}-'
if self.prec == 'seculo':
return f'{self.ano // 100}--'
return str(self.ano)
def iso(self) -> str:
if self.prec == 'dia':
return f'{self.ano:04d}-{self.mes:02d}-{self.dia:02d}'
if self.prec == 'mes':
return f'{self.ano:04d}-{self.mes:02d}'
if self.prec == 'decada':
return f'{self.ano // 10}X'
if self.prec == 'seculo':
return f'{self.ano // 100}XX'
return f'{self.ano:04d}'
def limites(self) -> tuple[int, int]:
if self.prec == 'decada':
return self.ano, self.ano + 9
if self.prec == 'seculo':
return self.ano, self.ano + 99
return self.ano, self.ano
@dataclass
class Data:
original: str
inicio_p: Ponto | None = None
fim_p: Ponto | None = None
relacao: str = '' # '' | 'antes' | 'depois'
colchete: bool = False
incerta: bool = False
aproximada: bool = False
tipo: str = 'produção'
motivo: str = ''
notas: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return self.inicio_p is not None
@property
def qualidade(self) -> str:
if self.aproximada:
return 'aproximada'
if self.incerta:
return 'incerta'
if self.colchete:
return 'atribuida'
return 'exata'
def forma(self) -> str:
if not self.ok:
return '[s.d.]'
a = self.inicio_p.forma()
if self.relacao == 'antes':
s = f'antes de {a}'
elif self.relacao == 'depois':
s = f'depois de {a}'
elif self.fim_p and self.fim_p != self.inicio_p:
s = f'{a}-{self.fim_p.forma()}'
else:
s = a
if self.aproximada:
s = 'ca. ' + s
if self.incerta:
s += '?'
if self.colchete or self.aproximada or self.incerta or self.relacao:
s = f'[{s}]'
return s
def iso(self) -> str | None:
if not self.ok:
return None
q = ('%' if self.incerta else '~') if self.aproximada else ('?' if self.incerta else '')
a = self.inicio_p.iso() + q
if self.relacao == 'antes':
return f'../{a}'
if self.relacao == 'depois':
return f'{a}/..'
if self.fim_p and self.fim_p != self.inicio_p:
return f'{a}/{self.fim_p.iso()}{q}'
return a
def anos(self) -> tuple[int | None, int | None]:
if not self.ok:
return None, None
i, f = self.inicio_p.limites()
if self.fim_p:
f = self.fim_p.limites()[1]
if self.relacao == 'antes':
return None, f
if self.relacao == 'depois':
return i, None
return i, f
def registro(self) -> dict:
i, f = self.anos()
return {
'original': self.original, 'forma': self.forma(), 'iso': self.iso(),
'inicio': i, 'fim': f, 'qualidade': self.qualidade if self.ok else None,
'tipo': self.tipo, 'motivo': self.motivo or None,
'notas': self.notas or None,
}
def _sem_acento(s: str) -> str:
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
def _ano(s: str, base: int | None = None) -> int:
n = int(s)
if len(s) == 2:
return (base // 100 * 100 if base else 1900) + n
return n
def _ponto(s: str) -> tuple[Ponto | None, bool]:
"""Um ponto no tempo. Devolve (ponto, incerto) — incerto quando o próprio
formato carrega a dúvida, como `193?` (década) ou `?/08/1938` (dia)."""
s = s.strip(' .,:;')
s = re.sub(r'^[-–]\s*', '', s)
if not s:
return None, False
if m := re.fullmatch(r'(\d{4})', s):
return Ponto(int(m[1])), False
if m := re.fullmatch(r'(\d{3})-', s):
return Ponto(int(m[1]) * 10, prec='decada'), False
if m := re.fullmatch(r'(\d{3})-?\?', s):
return Ponto(int(m[1]) * 10, prec='decada'), True
if m := re.fullmatch(r'(\d{2})(?:-{2,3}|_)', s):
return Ponto(int(m[1]) * 100, prec='seculo'), False
if m := re.fullmatch(r'(\d{2})(?:-{2})?\?', s):
return Ponto(int(m[1]) * 100, prec='seculo'), True
if m := re.fullmatch(r'(\d{3})--', s): # [186--]: década escrita com traço a mais
return Ponto(int(m[1]) * 10, prec='decada'), False
# dia/mês/ano, com separadores tortos: 22-12--1938, 14 -07-1928, 05/12-1934
if m := re.fullmatch(r'(\d{1,2})\s*[-/.]\s*(\d{1,2})\s*[-/.]+\s*(\d{4}|\d{2})', s):
d, mo, a = int(m[1]), int(m[2]), _ano(m[3])
if 1 <= mo <= 12 and 1 <= d <= 31:
return Ponto(a, mo, d, 'dia'), len(m[3]) == 2
if m := re.fullmatch(r'(\d{4})\s*[-/]\s*(\d{1,2})\s*[-/]\s*(\d{1,2})', s):
a, mo, d = int(m[1]), int(m[2]), int(m[3])
if 1 <= mo <= 12 and 1 <= d <= 31:
return Ponto(a, mo, d, 'dia'), False
# dia desconhecido: 2-/12/1923, ?/08/1938
if m := re.fullmatch(r'(?:\d?-|\?)\s*/\s*(\d{1,2})\s*/\s*(\d{4})', s):
return Ponto(int(m[2]), int(m[1]), prec='mes'), False
if m := re.fullmatch(r'(\d{1,2})\s*/\s*(\d{4})', s):
if 1 <= int(m[1]) <= 12:
return Ponto(int(m[2]), int(m[1]), prec='mes'), False
if m := re.fullmatch(r'(\d{4})\s*/\s*(\d{1,2})', s):
if 1 <= int(m[2]) <= 12:
return Ponto(int(m[1]), int(m[2]), prec='mes'), False
t = _sem_acento(s.lower())
if m := re.fullmatch(rf'(\d{{1,2}})\s*(?:de\s+)?{MES_RE}\s*,?\s*(?:de\s+)?(\d{{4}})', t):
return Ponto(int(m[3]), MESES[m[2]], int(m[1]), 'dia'), False
if m := re.fullmatch(rf'{MES_RE}\s*,?\s*(?:de\s+)?(\d{{4}})', t):
return Ponto(int(m[2]), MESES[m[1]], prec='mes'), False
if m := re.fullmatch(r'(?:decada de )?(\d{3})\d(?: decada)?( provavel)?', t):
if 'decada' in t:
return Ponto(int(m[1]) * 10, prec='decada'), bool(m[2])
if m := re.fullmatch(r'(\d{2}) seculo', t):
return Ponto((int(m[1]) - 1) * 100, prec='seculo'), False
return None, False
def _intervalo(s: str) -> tuple[Ponto | None, Ponto | None, bool]:
t = _sem_acento(s.lower()).strip(' .,:;')
t = re.sub(r'^entre\s+', '', t)
p, inc = _ponto(t)
if p:
return p, None, inc
# dias ou meses coordenados: "15 e 16 de outubro de 1930", "outubro e novembro de 1930"
if m := re.fullmatch(rf'(\d{{1,2}})\s+e\s+(\d{{1,2}})\s+(?:de\s+)?{MES_RE}\s+(?:de\s+)?(\d{{4}})', t):
mo, a = MESES[m[3]], int(m[4])
return Ponto(a, mo, int(m[1]), 'dia'), Ponto(a, mo, int(m[2]), 'dia'), False
if m := re.fullmatch(rf'{MES_RE}\s+e\s+{MES_RE}\s+(?:de\s+)?(\d{{4}})', t):
a = int(m[3])
return Ponto(a, MESES[m[1]], prec='mes'), Ponto(a, MESES[m[2]], prec='mes'), False
# 1859-61: segundo ano abreviado
if m := re.fullmatch(r'(\d{4})\s*-\s*(\d{2})', t):
a = int(m[1])
b = _ano(m[2], a)
if b > a:
return Ponto(a), Ponto(b), False
# o traço separa só entre um ano e o início do outro — `193-` é década, não intervalo
for sep in (r'\s+e\s+', r'\s+a\s+', r'(?<=[\d?])\s*[-–]\s*(?=\d)', r'\s*/\s*'):
partes = re.split(sep, t)
if len(partes) != 2:
continue
# "/" só separa anos inteiros (1952/1953); com meses é data (1910/12)
if sep == r'\s*/\s*' and not all(re.fullmatch(r'\d{4}', p) for p in partes):
continue
a, ia = _ponto(partes[0])
b, ib = _ponto(partes[1])
if a and b and b.limites()[1] >= a.limites()[0]:
return a, b, ia or ib
return None, None, False
def interpreta(texto: str | None) -> Data:
original = (texto or '').strip()
d = Data(original)
s = original
if not s:
d.motivo = 'sem data'
return d
if re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z', s):
# carimbo de registro no DSpace gravado no campo errado — não é produção
d.motivo = 'registro de sistema'
d.notas.append(f'dc.date traz a data de registro no sistema de origem ({s}), não a de produção')
return d
# partes separadas por ";" com qualificador: fica a de produção, o resto vira nota
partes = [p.strip() for p in re.split(r';(?![^(]*\))', s) if p.strip()]
if len(partes) > 1:
principal = next((p for p in partes if re.search(r'produ', p, re.I)), partes[0])
d.notas += [f'dc.date também registra: {p}' for p in partes if p is not principal]
s = principal
if m := re.search(r'\(([^)]*)\)\s*$', s):
q = m[1].lower()
if 'publica' in q:
d.tipo = 'publicação'
if 'atribu' in q:
d.colchete = True
s = s[:m.start()].strip()
if re.search(r'[\[\]{]', s):
d.colchete = True
s = re.sub(r'[\[\]{}]', '', s)
s = re.sub(r'^\s*-\s*', '', s)
s = re.sub(r'^(RJ|[A-Z]{2}),\s*', '', s) # [RJ, ca. 1860]: lugar vai na data tópica
s = re.sub(r'(\d)\.(\d{3})\b', r'\1\2', s) # 1.885 → 1885
s = re.sub(r'\s*:?\.\s*$', '', s).strip()
if SEM_DATA.fullmatch(s):
d.motivo = 'sem data'
return d
s, n = re.subn(r'\b(circa|cica|cira|ca\.?|c\.)\s*|(?<=\[)c(?=\d)|^c(?=\d)', '', s, flags=re.I)
if n:
d.aproximada = True
s, n = re.subn(r'\s*\b(anterior|antes de)\b\s*', ' ', s, flags=re.I)
if n:
d.relacao = 'antes'
s, n = re.subn(r'\s*\b(posterior|depois de)\b\s*', ' ', s, flags=re.I)
if n:
d.relacao = 'depois'
s = s.strip()
# interrogação no fim vale para a data inteira; dentro de um ponto (193?) é o ponto que diz
if re.search(r'\d{4}\s*\?\s*$', s) or re.search(r'\d{4}\?\s*\]?$', original):
d.incerta = True
s = re.sub(r'\s*\?\s*$', '', s)
# "Foto aérea da Fábrica ... - SP - 23-06-1933": só a data do fim interessa
if not re.match(r'[\d?]', s) and not re.match(rf'(?i){MES_RE}|entre|decada|década', _sem_acento(s)):
if m := re.search(r'(\d{1,2}-\d{1,2}-\d{4}|\d{4})\s*$', s):
d.notas.append(f'data extraída do texto: {original}')
s = m[1]
p, inc = _ponto(s)
if p:
d.inicio_p, d.incerta = p, d.incerta or inc
return d
a, b, inc = _intervalo(s)
if a:
d.inicio_p, d.fim_p, d.incerta = a, b, d.incerta or inc
return d
# "1900-" (colchete aberto): dali em diante
if m := re.fullmatch(r'(\d{4})\s*-', s):
d.inicio_p, d.relacao = Ponto(int(m[1])), 'depois'
return d
d.motivo = 'ilegível'
return d
def atribuida(inicio: int, fim: int, base: str) -> dict:
"""Data atribuída pelo arquivista a partir de outra fonte (proc. 1.3.3.2)."""
d = Data('', Ponto(inicio), Ponto(fim) if fim != inicio else None, incerta=True, colchete=True)
r = d.registro()
r['original'] = None
r['notas'] = [f'data atribuída pelo arquivista: {base}']
return r
+352
View File
@@ -0,0 +1,352 @@
"""Exportação da descrição: EAD 3 (descrições), EAC-CPF (autoridades) e CSV
de importação do AtoM (modelo ISAD). A NOBRADE não impõe formato; recomenda o
EAD para intercâmbio (nota 13).
Correspondência NOBRADE → EAD 3:
1.1 unitid · 1.2 unittitle · 1.3 unitdate (@normal em ISO 8601-2) ·
1.4 @level · 1.5 physdesc + physdescstructured · 2.1 origination ·
2.2 bioghist · 2.3 custodhist · 2.4 acqinfo · 3.1 scopecontent ·
3.2 appraisal · 3.3 accruals · 3.4 arrangement · 4.1 accessrestrict ·
4.2 userestrict · 4.3 langmaterial · 4.4 phystech · 4.5 otherfindaid ·
5.1 originalsloc · 5.2 altformavail + dao · 5.3 relatedmaterial ·
5.4 bibliography · 6.1 odd[@localtype=conservacao] · 6.2 odd ·
7.1 processinfo · 7.2 control/conventiondeclaration · 7.3 control/maintenancehistory ·
8.1 controlaccess
"""
from __future__ import annotations
import csv
import re
import xml.etree.ElementTree as ET
from collections import defaultdict
from datetime import date
from .config import ENTIDADE, ENTIDADE_NOME, PAIS, REGRAS, SAIDA
EAD = 'http://ead3.archivists.org/schema/'
CPF = 'urn:isbn:1-931666-33-4'
XLINK = 'http://www.w3.org/1999/xlink'
NIVEL_EAD = {1: ('collection', None), 2: ('otherlevel', 'seção'), 3: ('series', None),
4: ('file', None), 5: ('item', None)}
HOJE = date.today().isoformat()
# ── texto plano de cada elemento (usado por EAD, AtoM e mural) ──────────────
def texto(u: dict, el: str) -> str | None:
v = u.get(el)
if v is None:
return None
if el == '1.2':
return v['titulo']
if el == '1.3':
p = v.get('producao') or {}
return p.get('forma')
if el == '1.5':
return v.get('texto')
if el == '2.1':
return '; '.join(p['forma'] for p in v)
if el == '4.2':
return v['texto'] if isinstance(v, dict) else v
if el == '4.3':
return '; '.join(i['nome'] for i in v) if isinstance(v, list) else v
if el == '5.1':
return v.get('texto') or v.get('instituicao') if isinstance(v, dict) else v
if el == '5.2':
if isinstance(v, str):
return v
partes = [f"{c['tipo']}: {c['chave']} (SHA-256 {c['sha256']})" for c in v.get('nesta_entidade') or []]
partes += [f"{c['entidade']}: {c['url']}" for c in v.get('em_outras_entidades') or []]
return '; '.join(partes) or None
if el in ('5.3', '5.4', '6.2') and isinstance(v, list):
return '\n'.join(v)
if el == '7.3':
return '; '.join(f'{k}: {x}' for k, x in v.items())
if el == '8.1' and isinstance(v, dict):
return None
return v if isinstance(v, str) else None
# ── EAD 3 ────────────────────────────────────────────────────────────────────
def _sub(pai, tag, txt=None, **attrs):
e = ET.SubElement(pai, f'{{{EAD}}}{tag}', {k.rstrip('_'): str(x) for k, x in attrs.items() if x is not None})
if txt is not None:
e.text = txt
return e
def _paragrafos(pai, tag, txt, head=None, **attrs):
if not txt:
return
e = _sub(pai, tag, **attrs)
if head:
_sub(e, 'head', head)
for par in [p.strip() for p in re.split(r'\n\s*\n|\n(?=[-\d])', txt) if p.strip()]:
_sub(e, 'p', re.sub(r'\s+', ' ', par))
def _nome(pai, forma: str, aid: str | None, tipo: str | None, relator=None):
tag = {'entidade coletiva': 'corpname', 'família': 'famname'}.get(tipo, 'persname')
e = _sub(pai, tag, identifier=aid, relator=relator)
_sub(e, 'part', forma)
return e
def _componente(pai, u: dict, aut: dict, tag='c'):
nivel, outro = NIVEL_EAD[u['nivel']]
c = pai if tag == 'archdesc' else _sub(pai, 'c', level=nivel, otherlevel=outro, id=_xmlid(u['id']))
did = _sub(c, 'did')
_sub(did, 'unitid', u['1.1'], countrycode=PAIS, repositorycode=f'{PAIS}-{ENTIDADE}')
_sub(did, 'unittitle', u['1.2']['titulo'])
p = u['1.3'].get('producao') or {}
cert = {'aproximada': 'approximate', 'incerta': 'uncertain', 'atribuida': 'inferred',
'atribuída pelo arquivista': 'inferred'}.get(p.get('qualidade'))
_sub(did, 'unitdate', p.get('forma') or '[s.d.]', normal=p.get('iso'),
datechar='publication' if p.get('tipo') == 'publicação' else 'creation', certainty=cert,
unitdatetype='inclusive' if u['nivel'] < 4 else None)
orig = _sub(did, 'origination')
for pr in u['2.1']:
_nome(orig, pr['forma'], pr.get('id'), (aut.get(pr.get('id')) or {}).get('tipo'),
relator=pr.get('funcao'))
dim = u['1.5']
_sub(did, 'physdesc', dim['texto'])
for esp, n in (dim.get('por_especie') or {}).items():
ps = _sub(did, 'physdescstructured', physdescstructuredtype='materialtype', coverage='part')
_sub(ps, 'quantity', str(n))
_sub(ps, 'unittype', esp)
if isinstance(u.get('4.3'), list):
lm = _sub(did, 'langmaterial')
for i in u['4.3']:
_sub(lm, 'language', i['nome'], langcode=i['codigo'])
if u['nivel'] == 5:
for cp in (u.get('5.2') or {}).get('nesta_entidade') or []:
_sub(did, 'dao', daotype='derived', href=cp['chave'], linktitle=cp['tipo'],
identifier=f"sha256:{cp['sha256']}")
_paragrafos(c, 'bioghist', u.get('2.2'))
_paragrafos(c, 'custodhist', u.get('2.3'))
_paragrafos(c, 'acqinfo', u.get('2.4'))
_paragrafos(c, 'scopecontent', u.get('3.1'))
_paragrafos(c, 'appraisal', u.get('3.2'))
_paragrafos(c, 'accruals', u.get('3.3'))
_paragrafos(c, 'arrangement', u.get('3.4'))
_paragrafos(c, 'accessrestrict', u.get('4.1'))
_paragrafos(c, 'userestrict', texto(u, '4.2'))
_paragrafos(c, 'phystech', u.get('4.4'))
_paragrafos(c, 'otherfindaid', u.get('4.5'))
_paragrafos(c, 'originalsloc', texto(u, '5.1'))
if u['nivel'] != 5:
_paragrafos(c, 'altformavail', texto(u, '5.2'))
if isinstance(u.get('5.3'), list):
rm = _sub(c, 'relatedmaterial')
for x in u['5.3']:
_sub(rm, 'p', x)
if isinstance(u.get('5.4'), list):
bib = _sub(c, 'bibliography')
for x in u['5.4']:
_sub(bib, 'bibref', x)
elif u.get('5.4'):
_paragrafos(c, 'bibliography', u['5.4'])
_paragrafos(c, 'odd', u.get('6.1'), head='Notas sobre conservação', localtype='conservacao')
_paragrafos(c, 'odd', texto(u, '6.2'), head='Notas gerais')
_paragrafos(c, 'processinfo', u.get('7.1'))
ca = u.get('8.1')
if isinstance(ca, dict):
ctl = _sub(c, 'controlaccess')
for s in ca.get('assuntos') or []:
_sub(_sub(ctl, 'subject', lang='por'), 'part', s)
for s in ca.get('assuntos_eng') or []:
_sub(_sub(ctl, 'subject', lang='eng'), 'part', s)
if u['1.3'].get('topica'):
_sub(_sub(ctl, 'geogname', localtype='data tópica'), 'part', u['1.3']['topica'])
for s in ca.get('lugares') or []:
_sub(_sub(ctl, 'geogname'), 'part', s)
for pr in u['2.1']:
if pr.get('id'):
_nome(ctl, pr['forma'], pr['id'], (aut.get(pr['id']) or {}).get('tipo'))
if len(ctl) == 0:
c.remove(ctl)
elif ca:
_paragrafos(c, 'processinfo', ca, head='Pontos de acesso e indexação')
return c
def _xmlid(cod: str) -> str:
return 'u-' + re.sub(r'[^\w.-]', '_', cod)
def ead(unidades: list[dict], aut: dict):
filhos = defaultdict(list)
por_id = {}
for u in unidades:
por_id[u['id']] = u
if u.get('pai'):
filhos[u['pai']].append(u['id'])
col = next(u for u in unidades if u['nivel'] == 1)
ET.register_namespace('', EAD)
raiz = ET.Element(f'{{{EAD}}}ead')
ctrl = _sub(raiz, 'control', langencoding='iso639-2b', scriptencoding='iso15924',
dateencoding='iso8601', countryencoding='iso3166-1', repositoryencoding='iso15511')
_sub(ctrl, 'recordid', col['id'])
fd = _sub(ctrl, 'filedesc')
ts = _sub(fd, 'titlestmt')
_sub(ts, 'titleproper', col['1.2']['titulo'])
_sub(ctrl, 'maintenancestatus', value='new')
ag = _sub(ctrl, 'maintenanceagency', countrycode=PAIS)
_sub(ag, 'agencycode', f'{PAIS}-{ENTIDADE}')
_sub(ag, 'agencyname', ENTIDADE_NOME)
ld = _sub(ctrl, 'languagedeclaration')
_sub(ld, 'language', 'português', langcode='por')
_sub(ld, 'script', 'latino', scriptcode='Latn')
cd = _sub(ctrl, 'conventiondeclaration')
_sub(cd, 'citation', REGRAS)
mh = _sub(ctrl, 'maintenancehistory')
ev = _sub(mh, 'maintenanceevent')
_sub(ev, 'eventtype', value='created')
_sub(ev, 'eventdatetime', HOJE, standarddatetime=HOJE)
_sub(ev, 'agenttype', value='machine')
_sub(ev, 'agent', 'pipeline/nobrade')
arch = _sub(raiz, 'archdesc', level='collection')
_componente(arch, col, aut, tag='archdesc')
rep = arch.find(f'{{{EAD}}}did')
r = _sub(rep, 'repository')
_sub(_sub(r, 'corpname', identifier=f'{PAIS}-{ENTIDADE}'), 'part', ENTIDADE_NOME)
dsc = _sub(arch, 'dsc')
def desce(pai_xml, cod):
for fid in sorted(filhos[cod], key=lambda x: (len(x), x)):
c = _componente(pai_xml, por_id[fid], aut)
desce(c, fid)
desce(dsc, col['id'])
(SAIDA / 'ead').mkdir(exist_ok=True)
ET.indent(raiz, space=' ')
ET.ElementTree(raiz).write(SAIDA / 'ead' / 'brasiliana.xml', encoding='utf-8', xml_declaration=True)
# ── EAC-CPF ──────────────────────────────────────────────────────────────────
def _c(pai, tag, txt=None, **attrs):
e = ET.SubElement(pai, f'{{{CPF}}}{tag}', {k.rstrip('_'): str(x) for k, x in attrs.items() if x is not None})
if txt is not None:
e.text = txt
return e
def eac(autoridades: list[dict], usos: dict[str, list[str]]):
ET.register_namespace('', CPF)
ET.register_namespace('xlink', XLINK)
pasta = SAIDA / 'eac'
pasta.mkdir(exist_ok=True)
for a in autoridades:
raiz = ET.Element(f'{{{CPF}}}eac-cpf')
ctrl = _c(raiz, 'control')
_c(ctrl, 'recordId', f"{PAIS}-{ENTIDADE}-{a['id']}")
_c(ctrl, 'maintenanceStatus', 'new')
ag = _c(ctrl, 'maintenanceAgency')
_c(ag, 'agencyCode', f'{PAIS}-{ENTIDADE}')
_c(ag, 'agencyName', ENTIDADE_NOME)
ld = _c(ctrl, 'languageDeclaration')
_c(ld, 'language', 'português', languageCode='por')
_c(ld, 'script', 'latino', scriptCode='Latn')
cd = _c(ctrl, 'conventionDeclaration')
_c(cd, 'citation', 'ISAAR(CPF) 2ª ed.; NOBRADE (CONARQ, 2006), proc. 8.1.3–8.1.4')
mh = _c(ctrl, 'maintenanceHistory')
ev = _c(mh, 'maintenanceEvent')
_c(ev, 'eventType', 'created')
_c(ev, 'eventDateTime', HOJE, standardDateTime=HOJE)
_c(ev, 'agentType', 'machine')
_c(ev, 'agent', 'pipeline/nobrade')
if a['fontes']:
src = _c(ctrl, 'sources')
for f in a['fontes']:
s = _c(src, 'source')
_c(s, 'sourceEntry', f)
cpf = _c(raiz, 'cpfDescription')
ident = _c(cpf, 'identity')
_c(ident, 'entityType', {'entidade coletiva': 'corporateBody', 'família': 'family'}.get(a['tipo'], 'person'))
ne = _c(ident, 'nameEntry', localType='autorizada')
_c(ne, 'part', a['nome'])
if a['datas_existencia']:
_c(ne, 'useDates').append(_data_eac(a['datas_existencia']))
_c(ne, 'authorizedForm', 'NOBRADE')
for v in a['variantes']:
nv = _c(ident, 'nameEntry', localType='variante')
_c(nv, 'part', v)
_c(nv, 'alternativeForm', 'NOBRADE')
desc = _c(cpf, 'description')
if a['datas_existencia']:
ex = _c(desc, 'existDates')
ex.append(_data_eac(a['datas_existencia']))
bh = _c(desc, 'biogHist')
_c(bh, 'p', f"Autor de {a['ocorrencias']} dossiês da Coleção Brasiliana Fotográfica (cópia digital)."
+ (f" Tipo presumido: {a['tipo']}." if a['tipo'] == 'pessoa ou entidade' else ''))
if usos.get(a['id']):
rel = _c(cpf, 'relations')
for cod in usos[a['id']][:500]:
r = _c(rel, 'resourceRelation', resourceRelationType='creatorOf')
_c(r, 'relationEntry', cod)
ET.indent(raiz, space=' ')
ET.ElementTree(raiz).write(pasta / f"{a['id']}.xml", encoding='utf-8', xml_declaration=True)
def _data_eac(datas: str):
anos = re.findall(r'\d{4}', datas)
if len(anos) >= 2:
e = ET.Element(f'{{{CPF}}}dateRange')
_c(e, 'fromDate', datas.split('-')[0].strip(), standardDate=anos[0])
_c(e, 'toDate', datas.split('-')[-1].strip(), standardDate=anos[1])
return e
e = ET.Element(f'{{{CPF}}}date', standardDate=anos[0] if anos else None)
e.text = datas
return e
# ── AtoM (modelo CSV ISAD da versão 2.x) ────────────────────────────────────
ATOM = ['legacyId', 'parentId', 'identifier', 'title', 'levelOfDescription', 'extentAndMedium',
'repository', 'archivalHistory', 'acquisition', 'scopeAndContent', 'appraisal', 'accruals',
'arrangement', 'accessConditions', 'reproductionConditions', 'language', 'physicalCharacteristics',
'findingAids', 'locationOfOriginals', 'locationOfCopies', 'relatedUnitsOfDescription',
'publicationNote', 'generalNote', 'subjectAccessPoints', 'placeAccessPoints', 'nameAccessPoints',
'descriptionIdentifier', 'rules', 'archivistNote', 'eventDates', 'eventTypes', 'eventStartDates',
'eventEndDates', 'eventActors', 'culture']
NIVEL_ATOM = {1: 'Coleção', 2: 'Seção', 3: 'Série', 4: 'Dossiê', 5: 'Item documental'}
def atom(unidades: list[dict]):
with (SAIDA / 'atom.csv').open('w', encoding='utf-8', newline='') as f:
w = csv.DictWriter(f, fieldnames=ATOM)
w.writeheader()
for u in unidades:
if u['nivel'] == 0:
continue
p = u['1.3'].get('producao') or {}
ca = u.get('8.1') if isinstance(u.get('8.1'), dict) else {}
w.writerow({
'legacyId': u['id'], 'parentId': u['pai'] if u['nivel'] > 1 else '',
'identifier': u['1.1'].split('.')[-1] if u['nivel'] > 1 else u['1.1'],
'title': u['1.2']['titulo'], 'levelOfDescription': NIVEL_ATOM[u['nivel']],
'extentAndMedium': texto(u, '1.5'), 'repository': ENTIDADE_NOME,
'archivalHistory': u.get('2.3'), 'acquisition': u.get('2.4'),
'scopeAndContent': u.get('3.1'), 'appraisal': u.get('3.2'), 'accruals': u.get('3.3'),
'arrangement': u.get('3.4'), 'accessConditions': u.get('4.1'),
'reproductionConditions': texto(u, '4.2'),
'language': '|'.join(i['codigo'] for i in u['4.3']) if isinstance(u.get('4.3'), list) else '',
'physicalCharacteristics': u.get('4.4'), 'findingAids': u.get('4.5'),
'locationOfOriginals': texto(u, '5.1'), 'locationOfCopies': texto(u, '5.2'),
'relatedUnitsOfDescription': texto(u, '5.3'), 'publicationNote': texto(u, '5.4'),
'generalNote': '\n'.join(x for x in [u.get('6.1'), texto(u, '6.2')] if x),
'subjectAccessPoints': '|'.join(ca.get('assuntos') or []),
'placeAccessPoints': '|'.join(ca.get('lugares') or []),
'nameAccessPoints': '|'.join(ca.get('nomes') or []),
'descriptionIdentifier': u['id'], 'rules': u.get('7.2') or '',
'archivistNote': u.get('7.1'),
'eventDates': p.get('forma'), 'eventTypes': 'Produção',
'eventStartDates': f"{p['inicio']}-01-01" if p.get('inicio') else '',
'eventEndDates': f"{p['fim']}-12-31" if p.get('fim') else '',
'eventActors': '|'.join(x['forma'] for x in u['2.1'] if x.get('id')),
'culture': 'pt_BR',
})
+487
View File
@@ -0,0 +1,487 @@
"""Monta a descrição multinível (níveis 0–5) a partir do catálogo, do ledger do
bucket e dos posts do blog. Escreve descricao/unidades.jsonl,
descricao/autoridades.jsonl e as tabelas de revisão.
Cada unidade é um objeto com `id` (= código de referência), `pai`, `nivel` e os
elementos da NOBRADE pelo número: "1.1", "1.2", … "8.1".
"""
from __future__ import annotations
import ast
import json
import re
import sqlite3
from collections import Counter, defaultdict
from datetime import date, datetime
import polars as pl
from . import campos, datas, series
from .autoridades import Autoridades
from .config import (ACESSO, BUCKET, CATALOGO, COLECAO, COLECAO_TITULO, ENTIDADE_NOME, HANDLE_URL,
INSTITUICOES, LEDGER, ORIGEM_NOME, ORIGEM_SITE, POSTS, REGRAS, RESPONSAVEL, REVISAO,
SAIDA, TEXTOS, codigo)
NIVEIS = {0: 'acervo da entidade custodiadora', 1: 'coleção', 2: 'seção', 3: 'série',
4: 'dossiê', 5: 'item documental'}
MESES_ABNT = ['jan.', 'fev.', 'mar.', 'abr.', 'maio', 'jun.', 'jul.', 'ago.', 'set.', 'out.', 'nov.', 'dez.']
HOJE = date.today().isoformat()
# ── leitura das fontes ──────────────────────────────────────────────────────
def _valores(item: dict) -> dict[str, list[str]]:
vs = defaultdict(list)
for row in item.get('full_table') or []:
v = (row.get('value') or '').strip()
if v and v not in vs[row['field']]:
vs[row['field']].append(v)
return vs
def _ledger() -> dict[str, list[dict]]:
arquivos = defaultdict(list)
if not LEDGER.exists():
return arquivos
con = sqlite3.connect(LEDGER)
for h, nome, b, sha, quando, tipo in con.execute(
'select handle, nome, bytes, sha256, quando, tipo from enviados order by handle, tipo, nome'):
arquivos[h].append({'nome': nome, 'bytes': b, 'sha256': sha, 'quando': quando, 'tipo': tipo})
return arquivos
def _abnt(post: dict) -> str:
autor = (post.get('autor') or '').strip()
partes = autor.split()
autor_abnt = f'{partes[-1].upper()}, {" ".join(partes[:-1])}' if len(partes) > 1 else autor.upper()
d = datetime.fromisoformat(post['data_iso'][:10])
acesso = (post.get('fetched_at') or HOJE)[:10]
a = datetime.fromisoformat(acesso)
return (f"{autor_abnt}. {post['titulo']}. {ORIGEM_NOME}, Rio de Janeiro, "
f"{d.day} {MESES_ABNT[d.month - 1]} {d.year}. Disponível em: {post['url']}. "
f"Acesso em: {a.day} {MESES_ABNT[a.month - 1]} {a.year}.")
def _posts() -> tuple[dict[str, list[dict]], list[dict]]:
por_handle, todos = defaultdict(list), []
if not POSTS.exists():
return por_handle, todos
for linha in POSTS.open(encoding='utf-8'):
p = json.loads(linha)
if p.get('erro') or not p.get('titulo'):
continue
hs = p.get('handles_referenciados') or []
if isinstance(hs, str):
hs = ast.literal_eval(hs)
cats = p.get('categorias') or []
if isinstance(cats, str):
cats = ast.literal_eval(cats)
ref = {'titulo': p['titulo'], 'url': p['url'], 'autor': p.get('autor'), 'data': p['data_iso'][:10],
'categorias': cats, 'abnt': _abnt(p)}
todos.append(ref)
for h in hs:
por_handle[h].append(ref)
return por_handle, todos
def _textos(caminho) -> dict[str, str]:
"""Um .md com seções "## 2.3 História arquivística" → {'2.3': texto}."""
if not caminho.exists():
return {}
out, atual, buf = {}, None, []
for linha in caminho.read_text(encoding='utf-8').splitlines():
if m := re.match(r'^##\s+(\d\.\d)\b', linha):
if atual:
out[atual] = '\n'.join(buf).strip()
atual, buf = m[1], []
elif atual:
buf.append(linha)
if atual:
out[atual] = '\n'.join(buf).strip()
return {k: v for k, v in out.items() if v}
def _mb(b: int) -> str:
return f'{b / 1e6:.1f} MB'.replace('.', ',') if b < 1e9 else f'{b / 1e9:.1f} GB'.replace('.', ',')
def _formato(nome: str) -> str:
ext = nome.rsplit('.', 1)[-1].lower() if '.' in nome else ''
return {'jpg': 'JPEG', 'jpeg': 'JPEG', 'png': 'PNG', 'tif': 'TIFF', 'tiff': 'TIFF', 'webp': 'WebP',
'pdf': 'PDF', 'gif': 'GIF'}.get(ext, ext.upper() or 'desconhecido')
def _limites(ds: list[dict]) -> dict:
ini = [d['inicio'] for d in ds if d and d.get('inicio')]
fim = [d['fim'] for d in ds if d and d.get('fim')]
if not ini and not fim:
return {'forma': '[s.d.]', 'iso': None, 'inicio': None, 'fim': None, 'qualidade': None,
'tipo': 'produção'}
a, b = min(ini or fim), max(fim or ini)
return {'forma': f'{a}-{b}' if a != b else str(a), 'iso': f'{a}/{b}' if a != b else str(a),
'inicio': a, 'fim': b, 'qualidade': 'datas-limite', 'tipo': 'produção'}
def _dimensao(especies: Counter, n_itens: int, bytes_: int) -> dict:
partes = [f'{n} {e}' for e, n in especies.most_common()]
return {'por_especie': dict(especies), 'arquivos_digitais': n_itens, 'bytes': bytes_,
'texto': f"iconográfico: {', '.join(partes)}; eletrônico: {n_itens} arquivos digitais ({_mb(bytes_)})"}
# ── montagem ────────────────────────────────────────────────────────────────
def monta():
SAIDA.mkdir(exist_ok=True)
REVISAO.mkdir(parents=True, exist_ok=True)
ledger = _ledger()
posts_de, posts = _posts()
itens = []
for linha in CATALOGO.open(encoding='utf-8'):
if linha.strip():
it = json.loads(linha)
it['_v'] = _valores(it)
itens.append(it)
# séries (nível 3)
fontes = [(it['handle'], it['community'], (it['_v']['dc.source'] or [None])[0]) for it in itens]
serie_de, series_ = series.monta(fontes)
# autoridades
aut = Autoridades()
for it in itens:
for f in ('dc.contributor', 'dc.contributor.author', 'dc.contributor.other'):
for v in it['_v'][f]:
aut.registra(v)
aut.resolve()
for reg in aut.registros.values(): # biografias no blog (3.4) → fontes da autoridade
if reg['tipo'] == 'pessoa' and ',' in reg['nome']:
sob, nome = [x.strip() for x in reg['nome'].split(',', 1)]
primeiro = nome.split()[0] if nome else ''
for p in posts:
if 'Biografia' in p['categorias'] and sob in p['titulo'] and primeiro in p['titulo']:
reg['fontes'].append(p['abnt'])
unidades: dict[str, dict] = {}
filhos = defaultdict(list)
rev_datas, rev_tipos, rev_direitos, rev_lugares = [], [], [], []
def add(u: dict):
unidades[u['id']] = u
if u['pai']:
filhos[u['pai']].append(u['id'])
# níveis 4 e 5
for it in itens:
v, h = it['_v'], it['handle']
inst = it['community']
sigla_i, nome_i, cidade_i = INSTITUICOES[inst]
sg, notacao = serie_de[h]
num = h.rsplit('/', 1)[-1]
cod = codigo(COLECAO, sigla_i, sg, num)
pasta = h.replace('/', '_')
titulo = (v['dc.title'] or [it.get('title') or ''])[0].strip()
titulo_atribuido = False
if not titulo: # proc. 1.2: título atribuído a partir da própria unidade, entre colchetes
base = next((x for x in v['dc.description'] + v['dc.subject'] + v['dc.coverage']
if not campos.corrompido(x)), 'Sem título')
titulo, titulo_atribuido = f"[{base.split('. ')[0][:90]}]", True
d = datas.interpreta((v['dc.date'] or [''])[0])
data = d.registro()
if len(v['dc.date']) > 1:
data['notas'] = (data['notas'] or []) + [f'outra data na origem: {x}' for x in v['dc.date'][1:]]
produtores = []
for f in ('dc.contributor', 'dc.contributor.author', 'dc.contributor.other'):
for nome in v[f]:
aid = aut.id(nome)
if aid and aid not in [p['id'] for p in produtores]:
produtores.append({'id': aid, 'forma': aut[aid]['forma_autorizada'],
'funcao': 'autor' if f != 'dc.contributor.other' else 'outro'})
if not produtores:
produtores = [{'id': None, 'forma': 'Autoria não identificada', 'funcao': 'autor'}]
descricoes = [x for x in v['dc.description'] if not campos.corrompido(x)]
fisico = [x for x in descricoes if campos.e_fisico(x)]
conteudo = [x for x in descricoes if x not in fisico]
esp = campos.especie((v['dc.type'] or [None])[0])
cor = campos.cor(fisico)
arquivos = ledger.get(h, [])
preserv = sorted((a for a in arquivos if a['tipo'] == 'preservacao'),
key=lambda a: (a['nome'] != 'imagem.jpg', a['nome']))
imagens = preserv or [a for a in arquivos if a['tipo'] == 'thumbnail']
total = sum(a['bytes'] or 0 for a in imagens)
direitos = campos.direitos(v['dc.rights'] + [str(it.get('rights') or '')], nome_i)
lugar = campos.lugar(v['dc.coverage'])
assuntos = [s for s in v['dc.subject'] if not campos.corrompido(s)]
codigo_origem = next((re.sub(r'\.\w{3,4}$', '', x) for x in v['dc.identifier'] + v['dc.description']
if re.match(r'BR ?[A-Z]{2}[A-Z]{2,}\s', x)), None)
sophia = next((x for x in v['dc.relation'] if 'sophia' in x), None)
objdigital = [x for x in v['dc.identifier'] if 'objdigital' in x]
notas = ['Título atribuído pelo arquivista: a origem não informa título.'] if titulo_atribuido else []
if d.original and data['forma'] != d.original:
notas.append(f'Data como consta na origem: {d.original}')
notas += data['notas'] or []
if esp['presumida']:
notas.append('Espécie documental não informada na origem; presumida fotografia'
+ (f" (valor na origem: {esp['original']})" if esp.get('original') else '') + '.')
if direitos['originais']:
notas.append('Direitos como constam na origem: ' + ' | '.join(direitos['originais']))
lixo = [f"{f}: {x[:60]}" for f, xs in v.items() for x in xs if campos.corrompido(x)
and f in ('dc.type', 'dc.rights', 'dc.source', 'dc.subject', 'dc.date', 'dc.coverage')]
if lixo:
notas.append('Valores corrompidos na origem, desconsiderados: ' + '; '.join(lixo))
conservacao = None
if not preserv:
conservacao = ('Arquivo de preservação indisponível na origem (o repositório responde HTTP 500 '
'ao bitstream; ver errors.md). A cópia guarda apenas a miniatura gerada pelo DSpace.')
dossie = {
'id': cod, 'pai': codigo(COLECAO, sigla_i, sg), 'nivel': 4, 'handle': h,
'1.1': cod,
'1.2': {'titulo': titulo, 'atribuido': titulo.startswith('[')},
'1.3': {'producao': data, 'topica': lugar},
'1.4': {'nivel': 4, 'nome': NIVEIS[4]},
'1.5': {'genero': esp['genero'], 'especie': esp['especie'], 'suporte': esp['suporte'], 'cor': cor,
'fisico': fisico or None, 'arquivos_digitais': len(imagens), 'bytes': total,
'texto': (f"1 {esp['especie']}" + (f' {cor}' if cor else '')
+ (f", {esp['suporte']}" if esp['suporte'] else '')
+ (f" ({'; '.join(fisico)})" if fisico else '')
+ f"; {len(imagens)} arquivo{'s' if len(imagens) != 1 else ''} digita"
+ f"{'is' if len(imagens) != 1 else 'l'} ({_mb(total)})")},
'2.1': produtores,
'2.3': (f"Registrado no repositório DSpace da {ORIGEM_NOME}"
+ (f" em {v['dc.date.accessioned'][0][:10]}" if v['dc.date.accessioned'] else '')
+ f". Cópia digital capturada em {it['fetched_at'][:10]} e armazenada em "
f"{BUCKET}/itens/{pasta}/ com SHA-256 de cada arquivo."),
'2.4': (f"{ORIGEM_NOME} ({ORIGEM_SITE}), repositório mantido pela Biblioteca Nacional; "
f"coleta combinada com a Biblioteca Nacional, em {it['fetched_at'][:10]}. Handle {h}."),
'3.1': ' '.join(conteudo) or None,
'4.2': direitos,
'4.3': campos.idiomas(v['dc.language']) or None,
'4.4': (f"Arquivo{'s' if len(imagens) > 1 else ''} digita{'is' if len(imagens) > 1 else 'l'} "
f"{', '.join(sorted({_formato(a['nome']) for a in imagens})) or '—'}; "
'abre em qualquer visualizador de imagem.'
+ (f" Original: {'; '.join(fisico)}." if fisico else '')),
'5.1': {'instituicao': nome_i, 'local': cidade_i, 'url': HANDLE_URL.format(h),
'codigo_na_origem': codigo_origem, 'notacao_na_origem': notacao, 'sophia': sophia,
'texto': f"Original sob a guarda de {nome_i}, {cidade_i}"
+ (f", código {codigo_origem}" if codigo_origem else '')
+ (f", notação {notacao}" if notacao else '') + f". {HANDLE_URL.format(h)}"},
'5.2': {'nesta_entidade': [{'tipo': a['tipo'], 'chave': (f"{BUCKET}/derivados/thumb256/{a['nome']}"
if a['tipo'] == 'derivado-thumb256'
else f"{BUCKET}/itens/{pasta}/{a['tipo']}/{a['nome']}"),
'bytes': a['bytes'], 'sha256': a['sha256']} for a in arquivos],
'em_outras_entidades': [{'entidade': 'Biblioteca Nacional (Brasil) — BNDigital', 'url': u}
for u in objdigital] or None},
'5.3': (v['dc.relation.ispartof'] + v['dcterms.relation']) or None,
'5.4': [p['abnt'] for p in posts_de.get(h, [])] or None,
'6.1': conservacao,
'6.2': notas or None,
'7.1': (f'Descrição gerada pelo pipeline/nobrade a partir dos metadados Dublin Core colhidos '
f'da {ORIGEM_NOME} ({it["source_url"]}); responsável: {RESPONSAVEL}.'),
'7.3': {'coleta': it['fetched_at'][:10], 'descricao': HOJE},
'8.1': {'assuntos': [s for s in assuntos if campos.idioma_termo(s) == 'por'] or None,
'assuntos_eng': [s for s in assuntos if campos.idioma_termo(s) == 'eng'] or None,
'lugares': [c for c in v['dc.coverage'] if not campos.corrompido(c)] or None,
'nomes': [p['forma'] for p in produtores if p['id']] or None,
'periodo': data['iso']},
'_sd': not d.ok, '_especie': esp['especie'], '_serie': (inst, sg),
}
add(dossie)
rev_datas.append({'handle': h, 'original': d.original, 'forma': data['forma'], 'iso': data['iso'],
'inicio': data['inicio'], 'fim': data['fim'], 'qualidade': data['qualidade'],
'motivo': data['motivo']})
rev_tipos.append({'instituicao': inst, 'dc_type': (v['dc.type'] or [''])[0], 'genero': esp['genero'],
'especie': esp['especie'], 'suporte': esp['suporte'] or '', 'presumida': esp['presumida']})
rev_direitos.append({'instituicao': inst, 'categoria': direitos['categoria'], 'detentor': direitos['detentor'],
'originais': ' | '.join(direitos['originais'] or [])})
rev_lugares.append({'coverage': ' | '.join(v['dc.coverage']), 'data_topica': lugar or ''})
for i, a in enumerate(imagens, 1):
ci = f'{cod}.{i:02d}'
add({
'id': ci, 'pai': cod, 'nivel': 5, 'handle': h,
'1.1': ci,
'1.2': {'titulo': f"{titulo} [imagem {i} de {len(imagens)}]", 'atribuido': True},
'1.3': {'producao': data},
'1.4': {'nivel': 5, 'nome': NIVEIS[5]},
'1.5': {'genero': 'eletrônico', 'especie': 'arquivo de imagem', 'bytes': a['bytes'],
'texto': f"1 arquivo digital {_formato(a['nome'])} ({_mb(a['bytes'] or 0)})"},
'2.1': produtores,
'4.4': f"{_formato(a['nome'])}; SHA-256 {a['sha256']}"
+ ('; miniatura — preservação indisponível na origem' if a['tipo'] == 'thumbnail' else ''),
'5.2': {'nesta_entidade': [{'tipo': a['tipo'], 'chave': f"{BUCKET}/itens/{pasta}/{a['tipo']}/{a['nome']}",
'bytes': a['bytes'], 'sha256': a['sha256']}]},
'7.3': {'descricao': HOJE},
})
# níveis 3, 2, 1, 0 — de baixo para cima, agregando os filhos
textos1 = _textos(TEXTOS / 'nivel1.md')
textos0 = _textos(TEXTOS / 'nivel0.md')
por_serie = defaultdict(list)
for u in unidades.values():
if u['nivel'] == 4:
por_serie[u['_serie']].append(u)
# datas atribuídas aos s.d. (proc. 1.3.3.2): período do fotógrafo, senão datas-limite da série
rev_por_handle = {r['handle']: r for r in rev_datas}
lim_secao = {inst: _limites([u['1.3']['producao'] for (i, _), us in por_serie.items() if i == inst
for u in us if not u['_sd']]) for inst, _ in por_serie}
lim_serie = {k: _limites([u['1.3']['producao'] for u in us if not u['_sd']]) for k, us in por_serie.items()}
for k, us in por_serie.items():
for u in us:
if not u['_sd']:
continue
per = next((aut[p['id']]['periodo_atividade'] for p in u['2.1']
if p['id'] and aut[p['id']]['periodo_atividade']), None)
if per:
nova = datas.atribuida(*per, 'período de atividade do autor')
elif lim_serie[k]['inicio']:
nova = datas.atribuida(lim_serie[k]['inicio'], lim_serie[k]['fim'], 'datas-limite da série')
elif lim_secao[k[0]]['inicio']:
nova = datas.atribuida(lim_secao[k[0]]['inicio'], lim_secao[k[0]]['fim'],
'datas-limite da instituição de origem')
else:
continue
nova['original'] = u['1.3']['producao']['original']
nova['notas'] = nova['notas'] + (u['1.3']['producao']['notas'] or [])
u['1.3']['producao'] = nova
u['8.1']['periodo'] = nova['iso']
for fid in filhos[u['id']]:
unidades[fid]['1.3']['producao'] = nova
r = rev_por_handle[u['handle']]
r.update(forma=nova['forma'], iso=nova['iso'], inicio=nova['inicio'], fim=nova['fim'],
qualidade='atribuída pelo arquivista')
def agrega(us: list[dict]) -> dict:
esp = Counter(u['_especie'] for u in us)
n = sum(u['1.5']['arquivos_digitais'] for u in us)
b = sum(u['1.5']['bytes'] for u in us)
autores = Counter(p['forma'] for u in us for p in u['2.1'] if p['id'])
lugares = Counter(u['1.3']['topica'] for u in us if u['1.3'].get('topica'))
return {'data': _limites([u['1.3']['producao'] for u in us]), 'dim': _dimensao(esp, n, b),
'autores': autores, 'lugares': lugares, 'dossies': len(us)}
def resumo(a: dict) -> str:
s = f"{a['dossies']} dossiês, {a['data']['forma']}."
if a['autores']:
s += ' Principais autores: ' + '; '.join(f'{k} ({n})' for k, n in a['autores'].most_common(5)) + '.'
if a['lugares']:
s += ' Principais lugares: ' + '; '.join(f'{k} ({n})' for k, n in a['lugares'].most_common(5)) + '.'
return s
rev_series = []
por_secao = defaultdict(list)
for (inst, sg), us in sorted(por_serie.items()):
sigla_i, nome_i, cidade_i = INSTITUICOES[inst]
info = series_[(inst, sg)]
cod = codigo(COLECAO, sigla_i, sg)
a = agrega(us)
t = _textos(TEXTOS / 'series' / f'{sigla_i}.{sg}.md')
titulo = info['titulo'] if sg != 'AV' else f'Avulsos — {nome_i}'
produtor = re.sub(r'(?i)^(cole[cç][aã]o( particular)?|fundo|arquivo|s[ée]rie|conv[eê]nio)\s+(d[aeo]s?\s+)?', '', titulo)
add({
'id': cod, 'pai': codigo(COLECAO, sigla_i), 'nivel': 3,
'1.1': cod, '1.2': {'titulo': titulo, 'atribuido': sg == 'AV'},
'1.3': {'producao': a['data']}, '1.4': {'nivel': 3, 'nome': NIVEIS[3]},
'1.5': a['dim'],
'2.1': [{'id': None, 'forma': nome_i if sg == 'AV' else produtor, 'funcao': 'colecionador'}],
'2.2': t.get('2.2'),
'3.1': t.get('3.1') or resumo(a),
'3.4': t.get('3.4') or ('Dossiês ordenados pelo número do handle na origem.' if sg != 'AV' else
'Reúne os dossiês sem coleção/fundo de origem identificável em dc.source; '
'a notação da origem, quando há, fica em 5.1 de cada dossiê.'),
'5.1': {'instituicao': nome_i, 'local': cidade_i,
'variantes_na_origem': dict(info['variantes'].most_common()) or None},
'7.3': {'descricao': HOJE},
})
por_secao[inst].extend(us)
for var, n in (info['variantes'].most_common() or [('', info['n'])]):
rev_series.append({'instituicao': inst, 'codigo': cod, 'titulo': titulo, 'dc_source': var, 'n': n})
todos = []
for inst, us in sorted(por_secao.items(), key=lambda kv: -len(kv[1])):
sigla_i, nome_i, cidade_i = INSTITUICOES[inst]
cod = codigo(COLECAO, sigla_i)
a = agrega(us)
t = _textos(TEXTOS / 'secoes' / f'{sigla_i}.md')
add({
'id': cod, 'pai': codigo(COLECAO), 'nivel': 2,
'1.1': cod, '1.2': {'titulo': f'Seção {nome_i}', 'atribuido': True},
'1.3': {'producao': a['data']}, '1.4': {'nivel': 2, 'nome': NIVEIS[2]},
'1.5': a['dim'],
'2.1': [{'id': None, 'forma': nome_i, 'funcao': 'instituição custodiadora dos originais'}],
'2.2': t.get('2.2'),
'3.1': t.get('3.1') or resumo(a),
'3.4': f"{len(filhos[cod])} séries, por coleção ou fundo de origem.",
'5.1': {'instituicao': nome_i, 'local': cidade_i},
'7.3': {'descricao': HOJE},
})
todos.extend(us)
a = agrega(todos)
c1 = codigo(COLECAO)
add({
'id': c1, 'pai': codigo(), 'nivel': 1,
'1.1': c1, '1.2': {'titulo': COLECAO_TITULO, 'atribuido': False},
'1.3': {'producao': a['data'], 'acumulacao': {'forma': '2026', 'iso': '2026'}},
'1.4': {'nivel': 1, 'nome': NIVEIS[1]}, '1.5': a['dim'],
'2.1': [{'id': None, 'forma': ENTIDADE_NOME, 'funcao': 'colecionador'}],
'2.2': textos1.get('2.2'), '2.3': textos1.get('2.3'), '2.4': textos1.get('2.4'),
'3.1': textos1.get('3.1') or resumo(a), '3.2': textos1.get('3.2'), '3.3': textos1.get('3.3'),
'3.4': textos1.get('3.4'),
'4.1': textos1.get('4.1') or ACESSO, '4.2': textos1.get('4.2'),
'4.3': textos1.get('4.3'), '4.4': textos1.get('4.4'), '4.5': textos1.get('4.5'),
'5.1': textos1.get('5.1'), '5.4': textos1.get('5.4'),
'6.2': textos1.get('6.2'),
'7.1': textos1.get('7.1'), '7.2': REGRAS, '7.3': {'descricao': HOJE},
'8.1': textos1.get('8.1'),
})
c0 = codigo()
add({
'id': c0, 'pai': None, 'nivel': 0,
'1.1': c0, '1.2': {'titulo': f'Acervo {ENTIDADE_NOME}', 'atribuido': False},
'1.3': {'producao': a['data']}, '1.4': {'nivel': 0, 'nome': NIVEIS[0]}, '1.5': a['dim'],
'2.1': [{'id': None, 'forma': ENTIDADE_NOME, 'funcao': 'entidade custodiadora'}],
'2.2': textos0.get('2.2'), '3.1': textos0.get('3.1'),
'4.1': textos0.get('4.1') or ACESSO, '7.2': REGRAS, '7.3': {'descricao': HOJE},
})
# 1.4: títulos dos níveis superiores (proc. 1.1.4)
for u in unidades.values():
cad, p = [], u['pai']
while p:
cad.append(unidades[p]['1.2']['titulo'])
p = unidades[p]['pai']
u['1.4']['hierarquia'] = list(reversed(cad)) or None
ordem = sorted(unidades.values(), key=lambda u: (u['nivel'], u['id']))
with (SAIDA / 'unidades.jsonl').open('w', encoding='utf-8') as f:
for u in ordem:
f.write(json.dumps({k: x for k, x in u.items() if not k.startswith('_') and x is not None},
ensure_ascii=False) + '\n')
with (SAIDA / 'autoridades.jsonl').open('w', encoding='utf-8') as f:
for r in aut.registros.values():
f.write(json.dumps(r, ensure_ascii=False) + '\n')
# tabelas de revisão
pl.DataFrame(rev_datas, infer_schema_length=None).filter(pl.col('qualidade').is_null() | (pl.col('qualidade') != 'exata')) \
.sort('motivo', 'original', nulls_last=True).write_csv(REVISAO / 'datas.tsv', separator='\t')
pl.DataFrame(rev_series, infer_schema_length=None).sort('codigo', 'n', descending=[False, True]) \
.write_csv(REVISAO / 'series.tsv', separator='\t')
pl.DataFrame(rev_tipos, infer_schema_length=None).group_by(pl.all()).len('n').sort('n', descending=True) \
.write_csv(REVISAO / 'tipos.tsv', separator='\t')
pl.DataFrame(rev_direitos, infer_schema_length=None).group_by(pl.all()).len('n').sort('n', descending=True) \
.write_csv(REVISAO / 'direitos.tsv', separator='\t')
pl.DataFrame(rev_lugares, infer_schema_length=None).group_by(pl.all()).len('n').sort('n', descending=True) \
.write_csv(REVISAO / 'lugares.tsv', separator='\t')
pl.DataFrame([{'id': r['id'], 'forma_autorizada': r['forma_autorizada'], 'tipo': r['tipo'],
'ocorrencias': r['ocorrencias'], 'variantes': ' | '.join(r['variantes'])}
for r in aut.registros.values()], infer_schema_length=None).write_csv(REVISAO / 'autoridades.tsv', separator='\t')
return unidades, aut
+51
View File
@@ -0,0 +1,51 @@
"""Leva a descrição NOBRADE para o mural: grava no `meta` de cada peça do
web/atlas/atlas-map.json o código de referência, a data normalizada, a série
e o suporte, sem re-renderizar os atlas .webp.
python -m nobrade.mural (de dentro de pipeline/, depois de python -m nobrade)
Campos novos em meta (curtos: o mapa inteiro desce para o navegador):
ref código de referência (1.1) dt data na forma da NOBRADE (1.3)
ano ano inicial, para o histograma si índice em `series` do mapa (nível 3)
sup espécie e suporte (1.5) rep categoria de reprodução (4.2)
"""
import json
from .config import RAIZ, SAIDA
MAPA = RAIZ / 'web' / 'atlas' / 'atlas-map.json'
def main():
series, dossies = {}, {}
for linha in (SAIDA / 'unidades.jsonl').open(encoding='utf-8'):
u = json.loads(linha)
if u['nivel'] == 3:
series[u['id']] = u['1.2']['titulo']
elif u['nivel'] == 4:
dossies[u['handle']] = u
d = json.loads(MAPA.read_text(encoding='utf-8'))
titulos = sorted(set(series.values()), key=str.casefold)
idx = {t: i for i, t in enumerate(titulos)}
d['series'] = titulos
feitos = 0
for h, e in d['map'].items():
u = dossies.get(h)
if not u:
continue
p = u['1.3']['producao']
dim = u['1.5']
sup = dim['especie'] + (f" {dim['cor']}" if dim.get('cor') else '') + (f", {dim['suporte']}" if dim.get('suporte') else '')
e['meta'].update({
'ref': u['1.1'], 'dt': p.get('forma'), 'ano': p.get('inicio') or p.get('fim'),
'si': idx[series[u['pai']]], 'sup': sup, 'rep': u['4.2']['categoria'],
})
feitos += 1
MAPA.write_text(json.dumps(d, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
print(f'{feitos} de {len(d["map"])} peças com descrição NOBRADE; {len(titulos)} séries')
if __name__ == '__main__':
main()
+165
View File
@@ -0,0 +1,165 @@
"""Nível 3 — séries a partir de `dc.source`.
O campo traz, misturados, o nome da coleção/fundo de origem ("Coleção Gilberto
Ferrez" e "Gilberto Ferrez"), sufixos da instituição ("… / Museu da
República"), notações de caixa ("CX 14") e lixo de extração. A regra:
1. limpa o valor até uma chave (sem prefixo Coleção/Fundo/Arquivo, sem sufixo
institucional, sem notação no fim, sem acento, sem pontuação);
2. chaves com pelo menos MINIMO itens viram série;
3. valor raro que contém a chave de uma série vai para ela (os "Negativo de
vidro MPEG639, Coleção Fotográfica do Museu Paraense…");
4. o resto vai para a série AV (Avulsos) da instituição, e o valor original
fica como notação na origem (5.1).
Correções manuais em `ajustes/series.tsv` (instituicao, dc.source, titulo)
prevalecem sobre a regra.
"""
from __future__ import annotations
import csv
import re
import unicodedata
from collections import Counter, defaultdict
from .config import AJUSTES
MINIMO = 3
# variantes que a limpeza não junta sozinha: chave → chave canônica
ALIAS = {
'd teresa cristina maria': 'thereza christina maria',
'teresa cristina maria': 'thereza christina maria',
'federacao brasileira para o progresso feminino fbpf': 'federacao brasileira pelo progresso feminino',
'federacao brasileira para o progresso feminino': 'federacao brasileira pelo progresso feminino',
'fotografica mpeg': 'fotografica do museu paraense emilio goeldi',
'brascan cem anos no brasil': 'brascan cem anos no brasil',
'escola de ciencias sociais fgv cpdoc': 'escola de ciencias sociais fgv cpdoc',
'escola de ciencias sociais': 'escola de ciencias sociais fgv cpdoc',
'dos eventos institucionais': 'eventos institucionais',
'mestres do sec xix': 'mestres do seculo xix',
'cristovao barcellos': 'cristovao barcelos',
}
# notação de caixa/negativo/foto: é localização na origem (5.1), não série
NOTACAO = re.compile(r'(?i)^\s*(cx|caixas?|nv|foto|negativo|estampa)\b')
PREFIXO = re.compile(r'^(convenio|colecao particular|colecao|fundo|arquivo|acervo|serie)\s+(d[aeo]s?\s+)?')
LIXO = re.compile(r'[<>{}]|ns:|^[0-9a-f]{12,}')
def sem_acento(s: str) -> str:
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
def limpa(v: str) -> str:
s = v.strip()
s = re.sub(r'\s*/\s*(acervo|museu|ibge|instituto moreira).*$', '', s, flags=re.I)
s = re.sub(r'\s+-\s+(museu|funda|fgv|ibge).*$', '', s, flags=re.I)
s = re.sub(r'\s*\((museu|ibram)[^)]*\)', '', s, flags=re.I)
s = re.sub(r'\s*/\s*ibge$', '', s, flags=re.I)
s = re.sub(r'\s*(?:-\s*|\()\s*\w*\d\w*\)?\s*$', '', s) # "- 1123K", "(148T)", "- EFi9"
s = sem_acento(s.lower())
s = re.sub(r'\bsec\.?\s', 'sec ', s)
s = re.sub(r'[^\w\s]', ' ', s)
s = re.sub(r'\s+', ' ', s).strip()
s = PREFIXO.sub('', s)
return ALIAS.get(s, s)
def sigla(titulo: str, usadas: set[str]) -> str:
if m := re.match(r'(?i)[áa]lbum\s+(\d+)', titulo):
base = f'A{m[1]}'
usadas.add(base)
return base
miolo = PREFIXO.sub('', sem_acento(titulo.lower()))
palavras = [p for p in re.findall(r'[a-z0-9]+', miolo) if p not in {'de', 'da', 'do', 'das', 'dos', 'e', 'a', 'o'}]
base = ''.join(p[0] for p in palavras).upper()[:6] or 'S'
if len(base) < 2 and palavras:
base = palavras[0][:4].upper()
s, n = base, 2
while s in usadas or s == 'AV':
s, n = f'{base}{n}', n + 1
usadas.add(s)
return s
def _titulo(variantes: Counter) -> str:
"""A variante mais frequente; se ela não diz o que é, herda o tipo
(Coleção/Fundo/Série) de outra variante — "Thereza Christina Maria" +
"Coleção D. Teresa…" → "Coleção Thereza Christina Maria"."""
tipo_re = r'(?i)^(cole[cç][aã]o( particular)?|fundo|arquivo|s[ée]rie|conv[eê]nio|[áa]lbum)\b'
_, v = max((n, v) for v, n in variantes.items())
v = re.sub(r'\s+', ' ', v).strip()
v = re.sub(r'\s*[-/]\s*(Acervo|Museu|Fundação|Instituto Moreira|IBGE).*$', '', v)
v = re.sub(r'\s*\((Museu|IBRAM)[^)]*\)', '', v)
if not re.match(tipo_re, v):
tipos = Counter(m[1].capitalize() for x in variantes if (m := re.match(tipo_re, x.strip())))
v = f"{tipos.most_common(1)[0][0] if tipos else 'Coleção'} {v}"
return v
def _ajustes() -> dict[tuple[str, str], str]:
p = AJUSTES / 'series.tsv'
if not p.exists():
return {}
with p.open(encoding='utf-8') as f:
return {(r['instituicao'], r['dc_source']): r['titulo'] for r in csv.DictReader(f, delimiter='\t')}
def monta(fontes: list[tuple[str, str, str | None]]):
"""fontes: (handle, instituição, dc.source | None).
Devolve (por_handle, series): por_handle[handle] = (sigla, notação na origem
ou None); series[(instituição, sigla)] = {'titulo', 'variantes', 'n'}.
"""
ajuste = _ajustes()
chaves = defaultdict(Counter) # (inst, chave) → Counter(valor original)
for _, inst, v in fontes:
if v and not LIXO.search(v) and not NOTACAO.match(v) and (inst, v) not in ajuste:
chaves[(inst, limpa(v))][v] += 1
series: dict[tuple[str, str], dict] = {}
sigla_de: dict[tuple[str, str], str] = {}
usadas: dict[str, set[str]] = defaultdict(set)
fortes = defaultdict(list) # inst → [chave]
for (inst, chave), variantes in sorted(chaves.items(), key=lambda kv: -sum(kv[1].values())):
if not chave or sum(variantes.values()) < MINIMO:
continue
titulo = _titulo(variantes)
sg = sigla(titulo, usadas[inst])
sigla_de[(inst, chave)] = sg
series[(inst, sg)] = {'titulo': titulo, 'variantes': variantes, 'n': 0}
fortes[inst].append(chave)
for (inst, v), titulo in ajuste.items():
k = (inst, 'ajuste:' + titulo)
if k not in sigla_de:
sg = sigla(titulo, usadas[inst])
sigla_de[k] = sg
series[(inst, sg)] = {'titulo': titulo, 'variantes': Counter(), 'n': 0}
por_handle = {}
for handle, inst, v in fontes:
sg = None
if v and (inst, v) in ajuste:
sg = sigla_de[(inst, 'ajuste:' + ajuste[(inst, v)])]
series[(inst, sg)]['variantes'][v] += 1
elif v and not LIXO.search(v):
chave = limpa(v)
sg = sigla_de.get((inst, chave))
if sg is None:
# o valor raro contém o nome de uma série forte da mesma instituição?
for forte in sorted(fortes[inst], key=len, reverse=True):
if len(forte) > 5 and re.search(rf'\b{re.escape(forte)}\b', chave):
sg = sigla_de[(inst, forte)]
series[(inst, sg)]['variantes'][v] += 1
break
if sg is None:
sg = 'AV'
series.setdefault((inst, 'AV'), {'titulo': 'Avulsos', 'variantes': Counter(), 'n': 0})
series[(inst, sg)]['n'] += 1
# o valor original fica como notação na origem quando não é o próprio nome da série
notacao = v if v and (sg == 'AV' or limpa(v) not in {limpa(x) for x in [series[(inst, sg)]['titulo']]}) else None
por_handle[handle] = (sg, notacao)
return por_handle, series
+85
View File
@@ -0,0 +1,85 @@
"""Validador dos elementos obrigatórios da NOBRADE, por nível.
Obrigatórios: 1.1, 1.2, 1.3 (data de produção), 1.4, 1.5, 2.1; 4.1 só nos
níveis 0 e 1. Além deles, lista avisos que não reprovam mas pedem revisão.
Escreve descricao/pendencias.md e devolve o número de pendências.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from .config import SAIDA
def verifica(u: dict) -> tuple[list[str], list[str]]:
erros, avisos = [], []
n = u['nivel']
if not u.get('1.1'):
erros.append('1.1 código de referência ausente')
if not (u.get('1.2') or {}).get('titulo', '').strip():
erros.append('1.2 título ausente')
prod = (u.get('1.3') or {}).get('producao') or {}
if not prod.get('inicio') and not prod.get('fim'):
erros.append('1.3 data de produção ausente')
if not (u.get('1.4') or {}).get('nome'):
erros.append('1.4 nível ausente')
if not (u.get('1.5') or {}).get('texto'):
erros.append('1.5 dimensão e suporte ausente')
if not u.get('2.1'):
erros.append('2.1 produtor ausente')
if n <= 1 and not u.get('4.1'):
erros.append('4.1 condições de acesso ausente (obrigatório nos níveis 0 e 1)')
if n == 4:
if prod.get('qualidade') == 'atribuída pelo arquivista' or (prod.get('notas') or [''])[0].startswith('data atribuída'):
avisos.append('1.3 data atribuída pelo arquivista — conferir')
if prod.get('motivo') == 'ilegível':
avisos.append('1.3 data da origem ilegível')
if all(p.get('id') is None for p in u.get('2.1') or []):
avisos.append('2.1 autoria não identificada')
if u['id'].split('.')[-2] == 'AV':
avisos.append('nível 3 em Avulsos — série de origem não identificada')
if any('presumida' in x for x in u.get('6.2') or []):
avisos.append('1.5 espécie presumida')
if any('corrompidos' in x for x in u.get('6.2') or []):
avisos.append('valores corrompidos na origem')
if u.get('6.1'):
avisos.append('6.1 preservação indisponível na origem')
if (u.get('4.2') or {}).get('categoria') == 'consultar o detentor':
avisos.append('4.2 condição de reprodução não informada na origem')
if not (u.get('3.1') or '').strip():
avisos.append('3.1 sem âmbito e conteúdo')
return erros, avisos
def valida(unidades) -> int:
erros, avisos = defaultdict(list), defaultdict(list)
por_nivel = Counter()
for u in unidades:
por_nivel[u['nivel']] += 1
e, a = verifica(u)
for x in e:
erros[x].append(u['id'])
for x in a:
avisos[x].append(u['id'])
total = sum(len(v) for v in erros.values())
linhas = ['# Pendências da descrição NOBRADE', '',
'Gerado por `python -m nobrade`. Obrigatórios reprovam; avisos pedem revisão.', '',
'| Nível | Unidades |', '|---|--:|']
nomes = {0: 'acervo', 1: 'coleção', 2: 'seção', 3: 'série', 4: 'dossiê', 5: 'item'}
linhas += [f'| {n} {nomes[n]} | {c:,} |'.replace(',', '.') for n, c in sorted(por_nivel.items())]
linhas += ['', f'## Obrigatórios faltando: {total}', '']
if not erros:
linhas.append('Nenhum. Todas as unidades têm os elementos obrigatórios do seu nível.')
for k, ids in sorted(erros.items(), key=lambda kv: -len(kv[1])):
linhas += [f'### {k} — {len(ids)}', '', *[f'- `{i}`' for i in ids[:50]]]
if len(ids) > 50:
linhas.append(f'- … e mais {len(ids) - 50}')
linhas.append('')
linhas += ['', '## Avisos', '', '| Aviso | Dossiês |', '|---|--:|']
linhas += [f'| {k} | {len(v):,} |'.replace(',', '.') for k, v in sorted(avisos.items(), key=lambda kv: -len(kv[1]))]
linhas.append('')
(SAIDA / 'pendencias.md').write_text('\n'.join(linhas), encoding='utf-8')
return total
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Subconjunto do xlink.xsd da LoC (http://www.loc.gov/standards/xlink/xlink.xsd),
que responde 403 a clientes fora do navegador: só o que o cpf.xsd usa. -->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://www.w3.org/1999/xlink"
xmlns:xlink="http://www.w3.org/1999/xlink"
attributeFormDefault="qualified">
<xs:attribute name="href" type="xs:anyURI"/>
<xs:attribute name="role" type="xs:string"/>
<xs:attribute name="arcrole" type="xs:string"/>
<xs:attribute name="title" type="xs:string"/>
<xs:attribute name="show">
<xs:simpleType><xs:restriction base="xs:string">
<xs:enumeration value="new"/><xs:enumeration value="replace"/><xs:enumeration value="embed"/>
<xs:enumeration value="other"/><xs:enumeration value="none"/>
</xs:restriction></xs:simpleType>
</xs:attribute>
<xs:attribute name="actuate">
<xs:simpleType><xs:restriction base="xs:string">
<xs:enumeration value="onLoad"/><xs:enumeration value="onRequest"/>
<xs:enumeration value="other"/><xs:enumeration value="none"/>
</xs:restriction></xs:simpleType>
</xs:attribute>
<xs:attributeGroup name="simpleLink">
<xs:attribute name="type" type="xs:string" fixed="simple" form="qualified"/>
<xs:attribute ref="xlink:href" use="optional"/>
<xs:attribute ref="xlink:role" use="optional"/>
<xs:attribute ref="xlink:arcrole" use="optional"/>
<xs:attribute ref="xlink:title" use="optional"/>
<xs:attribute ref="xlink:show" use="optional"/>
<xs:attribute ref="xlink:actuate" use="optional"/>
</xs:attributeGroup>
</xs:schema>
+287
View File
@@ -0,0 +1,287 @@
<?xml version='1.0'?>
<?xml-stylesheet href="../2008/09/xsd.xsl" type="text/xsl"?>
<xs:schema targetNamespace="http://www.w3.org/XML/1998/namespace"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns ="http://www.w3.org/1999/xhtml"
xml:lang="en">
<xs:annotation>
<xs:documentation>
<div>
<h1>About the XML namespace</h1>
<div class="bodytext">
<p>
This schema document describes the XML namespace, in a form
suitable for import by other schema documents.
</p>
<p>
See <a href="http://www.w3.org/XML/1998/namespace.html">
http://www.w3.org/XML/1998/namespace.html</a> and
<a href="http://www.w3.org/TR/REC-xml">
http://www.w3.org/TR/REC-xml</a> for information
about this namespace.
</p>
<p>
Note that local names in this namespace are intended to be
defined only by the World Wide Web Consortium or its subgroups.
The names currently defined in this namespace are listed below.
They should not be used with conflicting semantics by any Working
Group, specification, or document instance.
</p>
<p>
See further below in this document for more information about <a
href="#usage">how to refer to this schema document from your own
XSD schema documents</a> and about <a href="#nsversioning">the
namespace-versioning policy governing this schema document</a>.
</p>
</div>
</div>
</xs:documentation>
</xs:annotation>
<xs:attribute name="lang">
<xs:annotation>
<xs:documentation>
<div>
<h3>lang (as an attribute name)</h3>
<p>
denotes an attribute whose value
is a language code for the natural language of the content of
any element; its value is inherited. This name is reserved
by virtue of its definition in the XML specification.</p>
</div>
<div>
<h4>Notes</h4>
<p>
Attempting to install the relevant ISO 2- and 3-letter
codes as the enumerated possible values is probably never
going to be a realistic possibility.
</p>
<p>
See BCP 47 at <a href="http://www.rfc-editor.org/rfc/bcp/bcp47.txt">
http://www.rfc-editor.org/rfc/bcp/bcp47.txt</a>
and the IANA language subtag registry at
<a href="http://www.iana.org/assignments/language-subtag-registry">
http://www.iana.org/assignments/language-subtag-registry</a>
for further information.
</p>
<p>
The union allows for the 'un-declaration' of xml:lang with
the empty string.
</p>
</div>
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:union memberTypes="xs:language">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value=""/>
</xs:restriction>
</xs:simpleType>
</xs:union>
</xs:simpleType>
</xs:attribute>
<xs:attribute name="space">
<xs:annotation>
<xs:documentation>
<div>
<h3>space (as an attribute name)</h3>
<p>
denotes an attribute whose
value is a keyword indicating what whitespace processing
discipline is intended for the content of the element; its
value is inherited. This name is reserved by virtue of its
definition in the XML specification.</p>
</div>
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:NCName">
<xs:enumeration value="default"/>
<xs:enumeration value="preserve"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
<xs:attribute name="base" type="xs:anyURI"> <xs:annotation>
<xs:documentation>
<div>
<h3>base (as an attribute name)</h3>
<p>
denotes an attribute whose value
provides a URI to be used as the base for interpreting any
relative URIs in the scope of the element on which it
appears; its value is inherited. This name is reserved
by virtue of its definition in the XML Base specification.</p>
<p>
See <a
href="http://www.w3.org/TR/xmlbase/">http://www.w3.org/TR/xmlbase/</a>
for information about this attribute.
</p>
</div>
</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="id" type="xs:ID">
<xs:annotation>
<xs:documentation>
<div>
<h3>id (as an attribute name)</h3>
<p>
denotes an attribute whose value
should be interpreted as if declared to be of type ID.
This name is reserved by virtue of its definition in the
xml:id specification.</p>
<p>
See <a
href="http://www.w3.org/TR/xml-id/">http://www.w3.org/TR/xml-id/</a>
for information about this attribute.
</p>
</div>
</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attributeGroup name="specialAttrs">
<xs:attribute ref="xml:base"/>
<xs:attribute ref="xml:lang"/>
<xs:attribute ref="xml:space"/>
<xs:attribute ref="xml:id"/>
</xs:attributeGroup>
<xs:annotation>
<xs:documentation>
<div>
<h3>Father (in any context at all)</h3>
<div class="bodytext">
<p>
denotes Jon Bosak, the chair of
the original XML Working Group. This name is reserved by
the following decision of the W3C XML Plenary and
XML Coordination groups:
</p>
<blockquote>
<p>
In appreciation for his vision, leadership and
dedication the W3C XML Plenary on this 10th day of
February, 2000, reserves for Jon Bosak in perpetuity
the XML name "xml:Father".
</p>
</blockquote>
</div>
</div>
</xs:documentation>
</xs:annotation>
<xs:annotation>
<xs:documentation>
<div xml:id="usage" id="usage">
<h2><a name="usage">About this schema document</a></h2>
<div class="bodytext">
<p>
This schema defines attributes and an attribute group suitable
for use by schemas wishing to allow <code>xml:base</code>,
<code>xml:lang</code>, <code>xml:space</code> or
<code>xml:id</code> attributes on elements they define.
</p>
<p>
To enable this, such a schema must import this schema for
the XML namespace, e.g. as follows:
</p>
<pre>
&lt;schema . . .>
. . .
&lt;import namespace="http://www.w3.org/XML/1998/namespace"
schemaLocation="http://www.w3.org/2001/xml.xsd"/>
</pre>
<p>
or
</p>
<pre>
&lt;import namespace="http://www.w3.org/XML/1998/namespace"
schemaLocation="http://www.w3.org/2009/01/xml.xsd"/>
</pre>
<p>
Subsequently, qualified reference to any of the attributes or the
group defined below will have the desired effect, e.g.
</p>
<pre>
&lt;type . . .>
. . .
&lt;attributeGroup ref="xml:specialAttrs"/>
</pre>
<p>
will define a type which will schema-validate an instance element
with any of those attributes.
</p>
</div>
</div>
</xs:documentation>
</xs:annotation>
<xs:annotation>
<xs:documentation>
<div id="nsversioning" xml:id="nsversioning">
<h2><a name="nsversioning">Versioning policy for this schema document</a></h2>
<div class="bodytext">
<p>
In keeping with the XML Schema WG's standard versioning
policy, this schema document will persist at
<a href="http://www.w3.org/2009/01/xml.xsd">
http://www.w3.org/2009/01/xml.xsd</a>.
</p>
<p>
At the date of issue it can also be found at
<a href="http://www.w3.org/2001/xml.xsd">
http://www.w3.org/2001/xml.xsd</a>.
</p>
<p>
The schema document at that URI may however change in the future,
in order to remain compatible with the latest version of XML
Schema itself, or with the XML namespace itself. In other words,
if the XML Schema or XML namespaces change, the version of this
document at <a href="http://www.w3.org/2001/xml.xsd">
http://www.w3.org/2001/xml.xsd
</a>
will change accordingly; the version at
<a href="http://www.w3.org/2009/01/xml.xsd">
http://www.w3.org/2009/01/xml.xsd
</a>
will not change.
</p>
<p>
Previous dated (and unchanging) versions of this schema
document are at:
</p>
<ul>
<li><a href="http://www.w3.org/2009/01/xml.xsd">
http://www.w3.org/2009/01/xml.xsd</a></li>
<li><a href="http://www.w3.org/2007/08/xml.xsd">
http://www.w3.org/2007/08/xml.xsd</a></li>
<li><a href="http://www.w3.org/2004/10/xml.xsd">
http://www.w3.org/2004/10/xml.xsd</a></li>
<li><a href="http://www.w3.org/2001/03/xml.xsd">
http://www.w3.org/2001/03/xml.xsd</a></li>
</ul>
</div>
</div>
</xs:documentation>
</xs:annotation>
</xs:schema>