Files

262 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Перевод XHTML от pdf2html.py через сторонний book_translator (репозиторий не
модифицируется — используется его штатный CLI и формат файлов).
Схема: наш XHTML -> extracted/chapter_NNN.json + metadata.json ->
03_translate_parallel.py --all -> translations/chapter_NNN_translated.json ->
наш XHTML с сохранённой разметкой.
Курсив/полужирный переживают внешний переводчик в виде маркеров ⟦i⟧…⟦/i⟧:
модель их обычно не трогает. Каждый абзац проверяется на баланс маркеров; при
поломке абзац сохраняется переведённым, но без внутренней разметки.
"""
import argparse
import hashlib
import html
import json
import re
import subprocess
import sys
from pathlib import Path
BLOCK = re.compile(r"^<(p|h1|h2)([^>]*)>(.*)</\1>$")
INLINE = ("i", "b", "code")
TAG = re.compile(r"</?(%s)>" % "|".join(INLINE))
MARK = re.compile(r"⟦(/?)(%s)⟧" % "|".join(INLINE))
# картинки и пустые блоки не переводим
SKIP = re.compile(r"<img\b")
# Листинги кода не переводятся вовсе: BLOCK их не узнаёт, поэтому строки <pre>
# доходят до результата нетронутыми. Здесь <pre> вырезается только из проверок
# языка — иначе английский код утянул бы долю кириллицы ниже порога приёмки.
PRE = re.compile(r"<pre\b.*?</pre>", re.S)
def detect_language(text):
"""Грубая эвристика: доля кириллицы против латиницы."""
cyr = sum(1 for c in text if "Ѐ" <= c <= "ӿ")
lat = sum(1 for c in text if c.isascii() and c.isalpha())
total = cyr + lat
if total < 200:
return "unknown", 0.0
share = cyr / total
return ("ru" if share > 0.5 else "en"), share
def to_marks(s):
"""Инлайновые теги -> маркеры, HTML-сущности -> обычные символы."""
return html.unescape(TAG.sub(lambda m: "⟦%s⟧" % m.group(0)[1:-1], s))
def from_marks(s):
"""Маркеры -> теги. Возвращает (html, ok): ok=False если разметка разъехалась."""
escaped = html.escape(s, quote=False)
depth = dict.fromkeys(INLINE, 0)
ok = True
for slash, tag in MARK.findall(escaped):
depth[tag] += -1 if slash else 1
if depth[tag] < 0:
ok = False
if any(v != 0 for v in depth.values()):
ok = False
if not ok:
return MARK.sub("", escaped), False
return MARK.sub(lambda m: "<%s%s>" % (m.group(1), m.group(2)), escaped), True
def parse_blocks(path):
"""[(строка_исходника, tag, attrs, inner)] — только переводимые блоки."""
out = []
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines()):
m = BLOCK.match(line.strip())
if m and not SKIP.search(m.group(3)) and m.group(3).strip():
out.append((i, m.group(1), m.group(2), m.group(3)))
return out
def split_chapters(blocks):
"""Разбивка по h1 — их переводчик держит контекст в пределах главы."""
chapters, cur = [], []
for b in blocks:
if b[1] == "h1" and cur:
chapters.append(cur)
cur = []
cur.append(b)
if cur:
chapters.append(cur)
return chapters
def write_input(chapters, extracted):
extracted.mkdir(parents=True, exist_ok=True)
for stale in extracted.glob("chapter_*.json"):
stale.unlink() # главы прошлого прогона иначе уедут в перевод
index = []
for n, ch in enumerate(chapters):
paragraphs = [to_marks(b[3]) for b in ch]
title = re.sub("<[^>]+>", "", ch[0][3])[:120] if ch[0][1] == "h1" else "Часть %d" % n
words = sum(len(p.split()) for p in paragraphs)
data = {
"number": n,
"title": title,
"paragraphs": paragraphs,
"paragraph_count": len(paragraphs),
"word_count": words,
"char_count": sum(len(p) for p in paragraphs),
"source_format": "xhtml",
"extraction_method": "pdf2html-bridge",
}
(extracted / ("chapter_%03d.json" % n)).write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
index.append({"number": n, "title": title,
"paragraph_count": len(paragraphs), "word_count": words})
(extracted / "metadata.json").write_text(json.dumps(
{"total_chapters": len(chapters),
"total_words": sum(c["word_count"] for c in index),
"chapters": index, "source_format": "xhtml"},
ensure_ascii=False, indent=2), encoding="utf-8")
return index
def claim_workdir(workdir, source, blocks):
"""Рабочий каталог принадлежит одной книге. Имена chapter_NNN.json у всех
книг одинаковы, а внешний репозиторий пропускает главы, помеченные
готовыми, — без этой привязки вторая книга молча соберётся из перевода
первой."""
digest = hashlib.sha256(
"\n".join(b[3] for b in blocks).encode("utf-8")).hexdigest()[:16]
claim = workdir / "bridge_source.json"
now = {"source": source.name, "blocks": len(blocks), "sha256": digest}
if claim.exists():
was = json.loads(claim.read_text(encoding="utf-8"))
if was.get("sha256") != digest:
sys.exit("рабочий каталог занят другой книгой (%s, блоков %s). "
"Возьми чистый --workdir." % (was.get("source"), was.get("blocks")))
else:
claim.write_text(json.dumps(now, ensure_ascii=False, indent=2),
encoding="utf-8")
def run_translator(repo, workdir, extracted, workers):
script = repo / "03_translate_parallel.py"
if not script.exists():
sys.exit("не найден %s — укажи --repo на клон book_translator" % script)
cmd = [sys.executable, str(script), "--all",
"--workers", str(workers), "--extracted-dir", str(extracted)]
print("запуск:", " ".join(cmd), "(cwd=%s)" % workdir)
r = subprocess.run(cmd, cwd=workdir)
if r.returncode != 0:
sys.exit("book_translator завершился с кодом %d" % r.returncode)
# Доля от длины оригинала, ниже которой перевод считается мусором. Совпадение
# числа абзацев ничего не гарантирует: модель иногда возвращает на длинный абзац
# огрызок вида «, v», и прежние ворота такое пропускали.
MIN_LEN_SHARE = 0.25
def rebuild(src, chapters, workdir, out):
lines = src.read_text(encoding="utf-8").splitlines()
broken = missing = translated = junk = untouched = 0
for n, ch in enumerate(chapters):
f = workdir / "translations" / ("chapter_%03d_translated.json" % n)
if not f.exists():
missing += len(ch)
continue
paragraphs = json.loads(f.read_text(encoding="utf-8"))["paragraphs"]
if len(paragraphs) != len(ch):
print("глава %d: %d абзацев вместо %d, оставлен оригинал"
% (n, len(paragraphs), len(ch)))
missing += len(ch)
continue
for (idx, tag, attrs, original), text in zip(ch, paragraphs):
plain_src = re.sub("<[^>]+>", "", original)
if len(plain_src) > 120 and len(text) < max(20, len(plain_src) * MIN_LEN_SHARE):
junk += 1
lines[idx] = "<%s%s>%s</%s>" % (tag, attrs, original, tag)
continue # оставляем оригинал: английский абзац лучше огрызка
# Абзац, вернувшийся на языке оригинала. Доля кириллицы по всему
# документу такое не ловит: полтора процента в ней тонут, а на
# странице это заметный кусок английского текста.
if len(plain_src) > 150 and detect_language(re.sub("<[^>]+>", "", text))[0] \
== detect_language(plain_src)[0] != "unknown":
untouched += 1
body, ok = from_marks(text)
broken += not ok
translated += 1
lines[idx] = "<%s%s>%s</%s>" % (tag, attrs, body, tag)
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
print("переведено блоков: %d, без перевода: %d, разметка потеряна в %d, "
"огрызков заменено оригиналом: %d, осталось на языке оригинала: %d"
% (translated, missing, broken, junk, untouched))
return missing == 0 and junk * 200 <= translated
def verify_output(out, target):
"""Совпадение числа абзацев ещё не значит, что перевод состоялся: внешний
скрипт при ошибке API молча подставляет оригинал. Проверяем язык результата.
"""
text = re.sub("<[^>]+>", "", PRE.sub(" ", out.read_text(encoding="utf-8")))
lang, share = detect_language(text)
stub = text.count("[UNTRANSLATED]")
print("результат: %s (кириллица %.0f%%), заглушек [UNTRANSLATED]: %d"
% (lang, share * 100, stub))
if stub:
print("ВНИМАНИЕ: внешний переводчик вернул заглушки — проверь его логи и ключ API")
return False
if lang != target:
print("ВНИМАНИЕ: результат не на языке %s — перевода фактически не было" % target)
return False
return True
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("source", type=Path, help="XHTML от pdf2html.py")
ap.add_argument("output", type=Path, help="куда писать переведённый XHTML")
ap.add_argument("--repo", type=Path, required=True,
help="клон vetermanve/book_translator (не модифицируется)")
ap.add_argument("--workdir", type=Path, required=True,
help="рабочий каталог: extracted/ и translations/")
ap.add_argument("--workers", type=int, default=12)
ap.add_argument("--target", default="ru", help="целевой язык (по умолчанию ru)")
ap.add_argument("--force", action="store_true",
help="переводить, даже если книга уже на целевом языке")
ap.add_argument("--check-only", action="store_true",
help="только проверить язык и объём, ничего не переводить")
args = ap.parse_args()
blocks = parse_blocks(args.source)
if not blocks:
sys.exit("в %s нет блоков <p>/<h1>/<h2> — это не вывод pdf2html.py" % args.source)
plain = " ".join(re.sub("<[^>]+>", "", b[3]) for b in blocks)
lang, share = detect_language(plain)
chapters = split_chapters(blocks)
pre_n = len(PRE.findall(args.source.read_text(encoding="utf-8")))
print("блоков: %d, глав: %d, знаков: %d, листингов <pre> (не переводятся): %d"
% (len(blocks), len(chapters), len(plain), pre_n))
print("язык источника: %s (кириллица %.0f%%)" % (lang, share * 100))
if lang == args.target and not args.force:
print("книга уже на языке %s — перевод не требуется (--force чтобы всё равно)"
% args.target)
return
if lang == "unknown":
print("язык определить не удалось — продолжаю")
if args.check_only:
return
args.workdir.mkdir(parents=True, exist_ok=True)
claim_workdir(args.workdir, args.source, blocks)
extracted = (args.workdir / "extracted").resolve()
write_input(chapters, extracted)
run_translator(args.repo.resolve(), args.workdir.resolve(), extracted, args.workers)
ok = rebuild(args.source, chapters, args.workdir, args.output)
if not verify_output(args.output, args.target):
sys.exit("перевод не состоялся — упаковывать этот файл нельзя")
if not ok:
print("часть блоков осталась на языке оригинала — см. счётчик выше")
if __name__ == "__main__":
main()