305 lines
11 KiB
Python
305 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# coding=utf-8
|
|
"""
|
|
Chaine de traduction de l'extension, sans dependance a gettext.
|
|
|
|
python i18n.py # extract + update + compile
|
|
python i18n.py extract # po/<domaine>.pot depuis le .inx et les .py
|
|
python i18n.py update # reporte le modele dans po/<langue>.po
|
|
python i18n.py compile # po/<langue>.po -> locale/<langue>/LC_MESSAGES/*.mo
|
|
|
|
Le domaine est lu dans l'attribut translationdomain du .inx ; les sources sont
|
|
le .inx et tous les .py du dossier (hors i18n.py et tests).
|
|
|
|
Les textes source (.inx et appels _() des .py) sont en anglais : c'est ce
|
|
qu'Inkscape affiche pour une langue sans catalogue. Le catalogue anglais est
|
|
rempli automatiquement avec les textes source ; seul fr.po se traduit a la main.
|
|
|
|
Les chaines retenues dans le .inx suivent les regles d'Inkscape (inx.its) :
|
|
<name>, attributs gui-text et gui-description, contenu de <option>, <item> et
|
|
<label>, sauf translatable="no". Les espaces sont normalises, sauf sous
|
|
xml:space="preserve".
|
|
"""
|
|
|
|
import ast
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
LANGUAGES = ["en", "fr"]
|
|
SOURCE_LANGUAGE = "en"
|
|
PO_DIR = os.path.join(HERE, "po")
|
|
LOCALE_DIR = os.path.join(HERE, "locale")
|
|
|
|
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
|
|
INX_NS = "{http://www.inkscape.org/namespace/inkscape/extension}"
|
|
|
|
|
|
def _find_inx():
|
|
found = sorted(name for name in os.listdir(HERE) if name.endswith(".inx"))
|
|
if len(found) != 1:
|
|
sys.exit("i18n.py attend un seul fichier .inx dans {} (trouve : {})".format(
|
|
HERE, found))
|
|
return found[0]
|
|
|
|
|
|
INX = _find_inx()
|
|
_INX_ROOT = ET.parse(os.path.join(HERE, INX)).getroot()
|
|
DOMAIN = _INX_ROOT.get("translationdomain")
|
|
if not DOMAIN:
|
|
sys.exit("Attribut translationdomain absent de " + INX)
|
|
EXTENSION_NAME = (_INX_ROOT.findtext(INX_NS + "name") or DOMAIN).strip()
|
|
SOURCES = [INX] + sorted(
|
|
name for name in os.listdir(HERE)
|
|
if name.endswith(".py") and name != "i18n.py" and not name.startswith("test"))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Extraction
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _local(tag):
|
|
return tag.rsplit("}", 1)[-1]
|
|
|
|
|
|
def _normalize(text, preserve):
|
|
return text if preserve else " ".join(text.split())
|
|
|
|
|
|
def extract_inx(path):
|
|
"""Chaines traduisibles d'un fichier .inx, dans l'ordre du document."""
|
|
messages = []
|
|
|
|
def walk(elem, preserve):
|
|
preserve = elem.get(XML_SPACE, "preserve" if preserve else "default") == "preserve"
|
|
if elem.get("translatable") != "no":
|
|
for attr in ("gui-text", "gui-description"):
|
|
if elem.get(attr):
|
|
messages.append(_normalize(elem.get(attr), preserve))
|
|
if _local(elem.tag) in ("name", "option", "item", "label") and elem.text:
|
|
messages.append(_normalize(elem.text, preserve))
|
|
for child in elem:
|
|
walk(child, preserve)
|
|
|
|
walk(ET.parse(path).getroot(), False)
|
|
return [m for m in messages if m]
|
|
|
|
|
|
def extract_py(path):
|
|
"""Arguments litteraux des appels _("...") d'un script Python."""
|
|
with open(path, encoding="utf-8") as handle:
|
|
tree = ast.parse(handle.read(), path)
|
|
calls = [node for node in ast.walk(tree)
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
|
and node.func.id == "_" and len(node.args) == 1
|
|
and isinstance(node.args[0], ast.Constant)
|
|
and isinstance(node.args[0].value, str)]
|
|
calls.sort(key=lambda node: (node.lineno, node.col_offset))
|
|
return [node.args[0].value for node in calls]
|
|
|
|
|
|
def extract():
|
|
"""Liste ordonnee et sans doublon de (msgid, [fichiers sources])."""
|
|
found = {}
|
|
for name in SOURCES:
|
|
path = os.path.join(HERE, name)
|
|
strings = extract_inx(path) if name.endswith(".inx") else extract_py(path)
|
|
for msgid in strings:
|
|
found.setdefault(msgid, [])
|
|
if name not in found[msgid]:
|
|
found[msgid].append(name)
|
|
return list(found.items())
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Lecture / ecriture des fichiers .po
|
|
# --------------------------------------------------------------------------
|
|
|
|
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
|
|
|
|
|
|
def _unquote(line):
|
|
body = line.strip()[1:-1]
|
|
return re.sub(r'\\(.)', lambda m: _ESCAPES.get(m.group(1), m.group(1)), body)
|
|
|
|
|
|
def _quote(text):
|
|
text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
|
|
lines = text.split("\n")
|
|
parts = [line + "\\n" for line in lines[:-1]]
|
|
if lines[-1]:
|
|
parts.append(lines[-1])
|
|
if len(parts) <= 1:
|
|
return '"{}"'.format(parts[0] if parts else "")
|
|
return '""\n' + "\n".join('"{}"'.format(part) for part in parts)
|
|
|
|
|
|
def read_po(path):
|
|
"""Dictionnaire msgid -> (msgstr, fuzzy). L'en-tete a pour cle ""."""
|
|
entries = {}
|
|
if not os.path.exists(path):
|
|
return entries
|
|
msgid = msgstr = None
|
|
field = None
|
|
fuzzy = False
|
|
|
|
def flush():
|
|
if msgid is not None:
|
|
entries[msgid] = (msgstr or "", fuzzy)
|
|
|
|
with open(path, encoding="utf-8") as handle:
|
|
for raw in handle:
|
|
line = raw.strip()
|
|
if line.startswith("#,") and "fuzzy" in line:
|
|
flush()
|
|
msgid = msgstr = field = None
|
|
fuzzy = True
|
|
elif line.startswith("msgid "):
|
|
if field == "msgstr":
|
|
flush()
|
|
fuzzy = False
|
|
msgid, msgstr, field = _unquote(line[6:]), None, "msgid"
|
|
elif line.startswith("msgstr "):
|
|
msgstr, field = _unquote(line[7:]), "msgstr"
|
|
elif line.startswith('"') and field == "msgid":
|
|
msgid += _unquote(line)
|
|
elif line.startswith('"') and field == "msgstr":
|
|
msgstr += _unquote(line)
|
|
elif not line and field == "msgstr":
|
|
flush()
|
|
msgid = msgstr = field = None
|
|
fuzzy = False
|
|
flush()
|
|
return entries
|
|
|
|
|
|
def _header(language):
|
|
fields = [
|
|
("Project-Id-Version", DOMAIN),
|
|
("Language", language or ""),
|
|
("MIME-Version", "1.0"),
|
|
("Content-Type", "text/plain; charset=UTF-8"),
|
|
("Content-Transfer-Encoding", "8bit"),
|
|
("Plural-Forms", {"fr": "nplurals=2; plural=(n > 1);",
|
|
"en": "nplurals=2; plural=(n != 1);"}.get(language, "")),
|
|
]
|
|
return "".join("{}: {}\n".format(key, value) for key, value in fields if value)
|
|
|
|
|
|
def write_po(path, language, messages, translations):
|
|
title = ("Modele de traduction" if language is None
|
|
else "Traduction ({})".format(language))
|
|
out = ["# {} de l'extension Inkscape « {} ».".format(title, EXTENSION_NAME),
|
|
"# Genere par i18n.py ; ne modifier que les msgstr.",
|
|
"msgid \"\"",
|
|
"msgstr " + _quote(_header(language)),
|
|
""]
|
|
for msgid, refs in messages:
|
|
msgstr, fuzzy = translations.get(msgid, ("", False))
|
|
out.append("#: " + " ".join(refs))
|
|
if fuzzy:
|
|
out.append("#, fuzzy")
|
|
out.append("msgid " + _quote(msgid))
|
|
out.append("msgstr " + _quote(msgstr))
|
|
out.append("")
|
|
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
|
handle.write("\n".join(out))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Compilation .mo (format GNU gettext)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def write_mo(path, catalog):
|
|
"""Ecrit un catalogue {msgid: msgstr} au format .mo, cles triees."""
|
|
keys = sorted(catalog)
|
|
ids = [key.encode("utf-8") for key in keys]
|
|
strs = [catalog[key].encode("utf-8") for key in keys]
|
|
count = len(keys)
|
|
ids_start = 7 * 4 + 16 * count
|
|
strs_start = ids_start + sum(len(b) + 1 for b in ids)
|
|
|
|
table_ids, table_strs = [], []
|
|
offset = ids_start
|
|
for data in ids:
|
|
table_ids += [len(data), offset]
|
|
offset += len(data) + 1
|
|
offset = strs_start
|
|
for data in strs:
|
|
table_strs += [len(data), offset]
|
|
offset += len(data) + 1
|
|
|
|
output = struct.pack("<7I", 0x950412DE, 0, count, 7 * 4, 7 * 4 + 8 * count, 0, 0)
|
|
output += struct.pack("<{}I".format(2 * count), *table_ids)
|
|
output += struct.pack("<{}I".format(2 * count), *table_strs)
|
|
output += b"".join(data + b"\0" for data in ids)
|
|
output += b"".join(data + b"\0" for data in strs)
|
|
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "wb") as handle:
|
|
handle.write(output)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Commandes
|
|
# --------------------------------------------------------------------------
|
|
|
|
def po_path(language):
|
|
return os.path.join(PO_DIR, "{}.po".format(language))
|
|
|
|
|
|
def mo_path(language):
|
|
return os.path.join(LOCALE_DIR, language, "LC_MESSAGES", DOMAIN + ".mo")
|
|
|
|
|
|
def cmd_extract():
|
|
os.makedirs(PO_DIR, exist_ok=True)
|
|
messages = extract()
|
|
write_po(os.path.join(PO_DIR, DOMAIN + ".pot"), None, messages, {})
|
|
print("{} chaines -> po/{}.pot".format(len(messages), DOMAIN))
|
|
|
|
|
|
def cmd_update():
|
|
messages = extract()
|
|
for language in LANGUAGES:
|
|
translations = read_po(po_path(language))
|
|
if language == SOURCE_LANGUAGE:
|
|
translations = {msgid: (msgid, False) for msgid, _refs in messages}
|
|
write_po(po_path(language), language, messages, translations)
|
|
missing = [m for m, _r in messages if not translations.get(m, ("", False))[0]]
|
|
print("po/{}.po : {} a traduire".format(language, len(missing)))
|
|
for msgid in missing:
|
|
print(" " + msgid.splitlines()[0])
|
|
|
|
|
|
def cmd_compile():
|
|
for language in LANGUAGES:
|
|
entries = read_po(po_path(language))
|
|
catalog = {msgid: msgstr for msgid, (msgstr, fuzzy) in entries.items()
|
|
if msgid and msgstr and not fuzzy}
|
|
catalog[""] = _header(language)
|
|
write_mo(mo_path(language), catalog)
|
|
print("{} -> {} ({} chaines)".format(
|
|
os.path.relpath(po_path(language), HERE),
|
|
os.path.relpath(mo_path(language), HERE), len(catalog) - 1))
|
|
|
|
|
|
def main(argv):
|
|
commands = {"extract": [cmd_extract], "update": [cmd_update],
|
|
"compile": [cmd_compile],
|
|
"all": [cmd_extract, cmd_update, cmd_compile]}
|
|
name = argv[0] if argv else "all"
|
|
if name not in commands:
|
|
print(__doc__)
|
|
return 1
|
|
for command in commands[name]:
|
|
command()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|