Files
UmbraTrans/tools/translation/render.py
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text

Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
2026-10-25 11:04:53 -07:00

672 lines
27 KiB
Python
Raw 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.

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Render the translated book as AsciiDoc and as an OpenDocument text file.
The AsciiDoc file is the canonical text output. The ODT is produced from a
Flat ODF (.fodt) document so that styles, page breaks and the table of
contents are under our control, then converted with LibreOffice. The TOC is
updated through UNO, because a plain ``--convert-to`` leaves an empty index.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from xml.sax.saxutils import escape
from llm import DEFAULT_ANALYST, DEFAULT_CRITIC, DEFAULT_MODEL
FONT_SERIF = "Noto Serif CJK SC"
FONT_SANS = "Noto Sans CJK SC"
def _esc(text: str) -> str:
return escape(text, {'"': """})
LATIN_RUN = re.compile(r"[A-Za-z][A-Za-z0-9'’.\-·]*(?:\s+[A-Za-z][A-Za-z0-9'’.\-·]*)*")
def _rich_esc(text: str) -> str:
"""Escape text and mark Latin runs as English.
A Chinese paragraph tells LibreOffice to use Chinese line-breaking for the
whole run, which can split an English name in the middle of a word
("Deal Hu / nter"). A character style with fo:language="en" keeps the
Latin run on Western line-breaking rules without changing its font.
"""
out: list[str] = []
last = 0
for m in LATIN_RUN.finditer(text):
out.append(_esc(text[last : m.start()]))
out.append(f'<text:span text:style-name="Latin">{_esc(m.group(0))}</text:span>')
last = m.end()
out.append(_esc(text[last:]))
return "".join(out)
def load_run(run_dir: Path, chapter_id: str) -> dict | None:
path = run_dir / chapter_id / "translation.json"
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def chapter_drafts(run_dir: Path | None, chapter_id: str) -> dict[str, str]:
if not run_dir:
return {}
data = load_run(run_dir, chapter_id)
return dict(data.get("paragraphs", {})) if data else {}
def _ordered_chapters(book: dict, run_dir: Path | None, include_missing: bool = False) -> list[dict]:
chapters = book["chapters"]
out = []
for c in chapters:
if c["matter"].startswith("front") and c["matter"] != "front_audio":
out.append(c)
out += [c for c in chapters if c["matter"] == "front_audio"]
out += [c for c in chapters if c["matter"] == "body"]
out += [c for c in chapters if c["matter"] == "back"]
if include_missing:
return out
return [c for c in out if chapter_drafts(run_dir, c["id"])]
def _cn_num(n: int) -> str:
digits = "零一二三四五六七八九"
if n <= 0:
return str(n)
if n <= 10:
return "十" if n == 10 else digits[n]
if n < 20:
return "十" + digits[n - 10]
if n < 100:
return digits[n // 10] + "十" + (digits[n % 10] if n % 10 else "")
return str(n)
def _body_chapter_number(book: dict, chapter: dict) -> int:
body = [c for c in book["chapters"] if c["matter"] == "body"]
body.sort(key=lambda c: c["index"])
return body.index(chapter) + 1
def _paragraph_style(paragraph: dict, matter: str) -> str:
if matter == "front_copyright":
return "Copyright"
if matter == "front_note":
return "Quote" if _is_author_note_code(paragraph.get("text", "")) else "FrontText"
if matter.startswith("front"):
return "FrontText"
if paragraph.get("type") == "dialogue":
return "Dialogue"
return "BodyText"
def _is_author_note_code(text: str) -> bool:
keys = (
"One of those codes was",
"All women captives",
"Those who rape",
)
return any(text.startswith(k) for k in keys)
# --------------------------------------------------------------------- AsciiDoc
def build_asciidoc(book: dict, run_dir: Path, *, sample: bool = False, date: str = "") -> str:
chapters = _ordered_chapters(book, run_dir)
lines: list[str] = []
lines.append("= Deal Hunter: 交易猎人")
lines.append("B. D. Murphy")
lines.append(":doctype: book")
lines.append(":lang: zh-CN")
lines.append(":toc: left")
lines.append(":toclevels: 2")
lines.append(f":revdate: {date or time.strftime('%Y-%m-%d')}")
if sample:
lines.append(":revremark: 审阅样本")
lines.append(":icons: font")
lines.append("")
lines.append("// 译者:Umbra Tools")
if sample:
lines.append("")
lines.append("NOTE: 本文件是简体中文翻译的审阅样本。已译部分见目录;未列出的章节将在全文运行中补齐。")
lines.append("")
seen_audio = False
for c in chapters:
drafts = chapter_drafts(run_dir, c["id"])
if not drafts:
continue
if c["matter"] == "front_audio":
if seen_audio:
continue
seen_audio = True
lines.append(f"// source: {c['id']} (audio front matter)")
lines.append("== 音频版序言")
lines.append("")
lines.append("以下是《Deal Hunter》有声版的开场白。")
lines.append("")
for ac in [x for x in book["chapters"] if x["matter"] == "front_audio"]:
adrafts = chapter_drafts(run_dir, ac["id"])
if not adrafts:
continue
lines.append(f"=== {ac['title_zh']}")
lines.append("")
for p in ac["paragraphs"]:
zh = adrafts.get(p["id"])
if not zh:
continue
if p.get("type") == "scene_break":
lines.append("'''")
lines.append("")
continue
lines.append(zh)
lines.append("")
continue
if c["matter"] == "body":
n = _body_chapter_number(book, c)
heading = f"第{_cn_num(n)}章 {c['title_zh']}"
else:
heading = c["title_zh"]
lines.append(f"// source: {c['id']} (database row {c['row']}), {len(c['paragraphs'])} paragraphs")
lines.append(f"== {heading}")
lines.append("")
for p in c["paragraphs"]:
zh = drafts.get(p["id"])
if not zh:
continue
if p.get("type") == "scene_break":
lines.append("'''")
lines.append("")
continue
style = _paragraph_style(p, c["matter"])
if style in {"Quote", "Dialogue"}:
lines.append(f"[.{style.lower()}]" if style == "Dialogue" else "[quote]")
if style == "Quote":
lines.append("____")
lines.append(zh)
lines.append("____")
else:
lines.append(zh)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
# --------------------------------------------------------------------- Flat ODF
STYLES = f"""
<office:font-face-decls>
<style:font-face style:name="{FONT_SERIF}" svg:font-family="'{FONT_SERIF}'" style:font-family-generic="roman" style:font-pitch="variable"/>
<style:font-face style:name="{FONT_SANS}" svg:font-family="'{FONT_SANS}'" style:font-family-generic="swiss" style:font-pitch="variable"/>
</office:font-face-decls>
<office:styles>
<style:default-style style:family="paragraph">
<style:paragraph-properties fo:orphans="2" fo:widows="2" fo:hyphenation-ladder-count="no-limit"/>
<style:text-properties style:font-name="{FONT_SERIF}" fo:font-size="11pt" fo:language="zh" fo:country="CN"/>
</style:default-style>
<style:style style:name="Title" style:family="paragraph" style:parent-style-name="Standard" style:class="text" style:master-page-name="FirstPage">
<style:paragraph-properties fo:text-align="center" fo:margin-top="2.1in" fo:margin-bottom="0.15in"/>
<style:text-properties fo:font-size="30pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Subtitle" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="center" fo:margin-bottom="0.5in"/>
<style:text-properties fo:font-size="18pt" fo:color="#444444"/>
</style:style>
<style:style style:name="Author" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="center" fo:margin-top="0.4in" fo:margin-bottom="0.15in"/>
<style:text-properties fo:font-size="14pt"/>
</style:style>
<style:style style:name="Small" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="center" fo:margin-bottom="0.08in"/>
<style:text-properties fo:font-size="9.5pt" fo:color="#666666"/>
</style:style>
<style:style style:name="PageBreak" style:family="paragraph" style:parent-style-name="Standard" style:master-page-name="Standard">
<style:paragraph-properties fo:break-before="page"/>
</style:style>
<style:style style:name="ChapterHeading" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:break-before="page" fo:margin-top="0.25in" fo:margin-bottom="0.3in" fo:keep-with-next="always"/>
<style:text-properties fo:font-size="20pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="SectionHeading" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-top="0.25in" fo:margin-bottom="0.12in" fo:keep-with-next="always"/>
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="BodyText" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="justify" fo:text-indent="0.31in" fo:margin-bottom="0.06in" fo:line-height="150%"/>
<style:text-properties fo:font-size="11pt"/>
</style:style>
<style:style style:name="Dialogue" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="justify" fo:text-indent="0.31in" fo:margin-bottom="0.06in" fo:line-height="150%"/>
<style:text-properties fo:font-size="11pt"/>
</style:style>
<style:style style:name="Latin" style:family="text">
<style:text-properties fo:language="en" fo:country="US"/>
</style:style>
<style:style style:name="FrontText" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="left" fo:margin-bottom="0.10in" fo:line-height="140%"/>
<style:text-properties fo:font-size="10.5pt"/>
</style:style>
<style:style style:name="Copyright" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="left" fo:margin-bottom="0.10in" fo:line-height="135%"/>
<style:text-properties fo:font-size="9.5pt" fo:color="#333333"/>
</style:style>
<style:style style:name="Quote" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:margin-left="0.5in" fo:margin-right="0.4in" fo:margin-bottom="0.12in" fo:line-height="145%" fo:background-color="#f4f4f4"/>
<style:text-properties fo:font-size="10.5pt"/>
</style:style>
<style:style style:name="SceneBreak" style:family="paragraph" style:parent-style-name="Standard" style:class="text">
<style:paragraph-properties fo:text-align="center" fo:margin-top="0.12in" fo:margin-bottom="0.12in"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="TOCTitle" style:family="paragraph" style:parent-style-name="Standard" style:class="index">
<style:paragraph-properties fo:text-align="center" fo:margin-bottom="0.3in"/>
<style:text-properties fo:font-size="18pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Contents1" style:family="paragraph" style:parent-style-name="Standard" style:class="index">
<style:paragraph-properties fo:margin-left="0in" fo:margin-bottom="0.08in"/>
<style:text-properties fo:font-size="11pt"/>
</style:style>
<style:style style:name="Header" style:family="paragraph" style:parent-style-name="Standard" style:class="extra">
<style:paragraph-properties fo:text-align="center"/>
<style:text-properties fo:font-size="8.5pt" fo:color="#777777"/>
</style:style>
<style:style style:name="Footer" style:family="paragraph" style:parent-style-name="Standard" style:class="extra">
<style:paragraph-properties fo:text-align="center"/>
<style:text-properties fo:font-size="8.5pt" fo:color="#777777"/>
</style:style>
<text:outline-style style:name="Outline">
<text:outline-level-style text:level="1" style:num-format=""/>
<text:outline-level-style text:level="2" style:num-format=""/>
</text:outline-style>
</office:styles>
"""
def _odt_paragraph(style: str, text: str, outline: int | None = None) -> str:
if outline:
return f'<text:h text:style-name="{style}" text:outline-level="{outline}">{_rich_esc(text)}</text:h>'
return f'<text:p text:style-name="{style}">{_rich_esc(text)}</text:p>'
def build_fodt(book: dict, run_dir: Path, *, sample: bool = False, date: str = "") -> str:
chapters = _ordered_chapters(book, run_dir)
body: list[str] = []
# Title page
body.append(_odt_paragraph("Title", "Deal Hunter"))
body.append(_odt_paragraph("Subtitle", "交易猎人"))
body.append(_odt_paragraph("Author", "B·D·墨菲 著"))
body.append(_odt_paragraph("Small", "Umbra Tools 译"))
if sample:
body.append(_odt_paragraph("Small", "简体中文译本 · 审阅样本"))
body.append(_odt_paragraph("Small", date or time.strftime("%Y-%m-%d")))
body.append('<text:p text:style-name="PageBreak"/>')
# Table of contents
body.append(
'<text:table-of-content text:name="TableOfContents" text:style-name="Contents1" text:protected="false">'
'<text:table-of-content-source text:outline-level="1">'
'<text:index-title-template text:style-name="TOCTitle">目录</text:index-title-template>'
'<text:table-of-content-entry-template text:outline-level="1" text:style-name="Contents1">'
"<text:index-entry-text/>"
'<text:index-entry-tab-stop style:type="right" style:leader-char="."/>'
"<text:index-entry-page-number/>"
"</text:table-of-content-entry-template>"
"</text:table-of-content-source>"
"</text:table-of-content>"
)
if sample:
body.append(_odt_paragraph("ChapterHeading", "审阅样本说明", 1))
body.append(
_odt_paragraph(
"FrontText",
"本文件是《Deal Hunter》简体中文翻译的审阅样本。已译部分见目录;未列出的章节将在全文运行中补齐。",
)
)
body.append(
_odt_paragraph(
"FrontText",
"翻译流程:段落重建 → 意图分析 → 初译 → 双语审校。术语见 glossary.json,审校意见见 review.html。",
)
)
seen_audio = False
for c in chapters:
drafts = chapter_drafts(run_dir, c["id"])
if not drafts:
continue
if c["matter"] == "front_audio":
if seen_audio:
continue
seen_audio = True
body.append(_odt_paragraph("ChapterHeading", "音频版序言", 1))
body.append(_odt_paragraph("FrontText", "以下是《Deal Hunter》有声版的开场白。"))
for ac in [x for x in book["chapters"] if x["matter"] == "front_audio"]:
adrafts = chapter_drafts(run_dir, ac["id"])
if not adrafts:
continue
body.append(_odt_paragraph("SectionHeading", ac["title_zh"], 2))
for p in ac["paragraphs"]:
zh = adrafts.get(p["id"])
if not zh:
continue
if p.get("type") == "scene_break":
body.append(_odt_paragraph("SceneBreak", "* * *"))
else:
body.append(_odt_paragraph("FrontText", zh))
continue
if c["matter"] == "body":
n = _body_chapter_number(book, c)
heading = f"第{_cn_num(n)}章 {c['title_zh']}"
else:
heading = c["title_zh"]
body.append(_odt_paragraph("ChapterHeading", heading, 1))
for p in c["paragraphs"]:
zh = drafts.get(p["id"])
if not zh:
continue
if p.get("type") == "scene_break":
body.append(_odt_paragraph("SceneBreak", "* * *"))
continue
body.append(_odt_paragraph(_paragraph_style(p, c["matter"]), zh))
if sample:
body.append(_odt_paragraph("ChapterHeading", "样本结束", 1))
body.append(
_odt_paragraph(
"FrontText",
"以上为审阅样本。全文运行将覆盖其余章节;审校问题清单会随各章翻译记录一并提供。",
)
)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"
xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
{STYLES}
<office:automatic-styles>
<style:page-layout style:name="pm1">
<style:page-layout-properties fo:page-width="8.27in" fo:page-height="11.69in"
fo:margin-top="0.9in" fo:margin-bottom="0.9in" fo:margin-left="1.0in" fo:margin-right="1.0in"
style:print-orientation="portrait" style:first-page-number="1"/>
<style:header-style><style:header-footer-properties fo:min-height="0.3in" fo:margin-bottom="0.2in"/></style:header-style>
<style:footer-style><style:header-footer-properties fo:min-height="0.3in" fo:margin-top="0.2in"/></style:footer-style>
</style:page-layout>
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1" style:next-style-name="Standard">
<style:header><text:p text:style-name="Header">Deal Hunter · 交易猎人</text:p></style:header>
<style:footer><text:p text:style-name="Footer"><text:page-number text:select-page="current">1</text:page-number></text:p></style:footer>
</style:master-page>
<style:master-page style:name="FirstPage" style:page-layout-name="pm1"/>
</office:master-styles>
<office:body>
<office:text>
<text:sequence-decls>
<text:sequence-decl text:display-outline-level="0" text:name="Illustration"/>
<text:sequence-decl text:display-outline-level="0" text:name="Table"/>
<text:sequence-decl text:display-outline-level="0" text:name="Text"/>
<text:sequence-decl text:display-outline-level="0" text:name="Drawing"/>
</text:sequence-decls>
{chr(10).join(body)}
</office:text>
</office:body>
</office:document>
"""
# --------------------------------------------------------------------- LibreOffice
def _soffice_binary() -> str:
for name in ("soffice", "libreoffice"):
path = shutil.which(name)
if path:
return path
raise SystemExit("LibreOffice not found; cannot produce ODT/PDF")
def convert_with_uno(fodt: Path, odt: Path, pdf: Path | None, port: int = 2002) -> None:
"""Convert FODT -> ODT (and PDF) with an updated TOC.
``--convert-to`` leaves a text:table-of-content empty. Opening the document
through UNO lets us call update() on the index before storing.
"""
fodt = fodt.resolve()
odt = odt.resolve()
if pdf:
pdf = pdf.resolve()
soffice = _soffice_binary()
profile = Path(tempfile.mkdtemp(prefix="lo-profile-"))
proc = subprocess.Popen(
[
soffice,
"--headless",
"--invisible",
"--norestore",
"--nofirststartwizard",
f"-env:UserInstallation={profile.as_uri()}",
f"--accept=socket,host=127.0.0.1,port={port};urp;",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
import uno # type: ignore
from com.sun.star.beans import PropertyValue # type: ignore
local = uno.getComponentContext()
resolver = local.ServiceManager.createInstanceWithContext(
"com.sun.star.bridge.UnoUrlResolver", local
)
ctx = None
for _ in range(80):
try:
ctx = resolver.resolve(
f"uno:socket,host=127.0.0.1,port={port};urp;StarOffice.ComponentContext"
)
break
except Exception:
time.sleep(0.25)
if ctx is None:
raise RuntimeError("could not connect to LibreOffice")
desktop = ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
doc = desktop.loadComponentFromURL(uno.systemPathToFileUrl(str(fodt)), "_blank", 0, ())
indexes = doc.getDocumentIndexes()
for i in range(indexes.getCount()):
indexes.getByIndex(i).update()
def prop(name: str, value: object) -> object:
p = PropertyValue()
p.Name = name
p.Value = value
return p
doc.storeToURL(uno.systemPathToFileUrl(str(odt)), (prop("FilterName", "writer8"),))
if pdf:
doc.storeToURL(uno.systemPathToFileUrl(str(pdf)), (prop("FilterName", "writer_pdf_Export"),))
doc.close(True)
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
shutil.rmtree(profile, ignore_errors=True)
def render(
*,
book_path: Path,
run_dir: Path,
out_dir: Path,
sample: bool = False,
date: str = "",
pdf: bool = True,
) -> dict[str, Path]:
book = json.loads(book_path.read_text(encoding="utf-8"))
out_dir.mkdir(parents=True, exist_ok=True)
stem = "deal-hunter-zh-sample" if sample else "deal-hunter-zh"
asciidoc = out_dir / f"{stem}.asciidoc"
fodt = out_dir / f"{stem}.fodt"
odt = out_dir / f"{stem}.odt"
pdf_path = out_dir / f"{stem}.pdf" if pdf else None
asciidoc.write_text(build_asciidoc(book, run_dir, sample=sample, date=date), encoding="utf-8")
fodt.write_text(build_fodt(book, run_dir, sample=sample, date=date), encoding="utf-8")
convert_with_uno(fodt, odt, pdf_path)
out = {"asciidoc": asciidoc, "fodt": fodt, "odt": odt}
if pdf_path:
out["pdf"] = pdf_path
return out
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for block in iter(lambda: f.read(1024 * 1024), b""):
h.update(block)
return h.hexdigest()
def _repo_commit(start: Path) -> str | None:
for p in [start, *start.parents]:
if (p / ".git").exists():
try:
return subprocess.check_output(
["git", "-C", str(p), "rev-parse", "HEAD"],
text=True,
stderr=subprocess.DEVNULL,
).strip()
except (OSError, subprocess.CalledProcessError):
return None
return None
def write_manifest(
*,
book_path: Path,
run_dir: Path,
out_dir: Path,
sample: bool,
files: dict[str, Path],
) -> Path:
"""Write the reproducibility manifest next to the rendered files.
It contains hashes, counts and model names, never the book text.
"""
book = json.loads(book_path.read_text(encoding="utf-8"))
chapters: list[dict] = []
total = translated = issues = scene_breaks = 0
for c in book["chapters"]:
data = load_run(run_dir, c["id"])
expected = len([p for p in c["paragraphs"] if p.get("type") != "scene_break"])
scene_breaks += len([p for p in c["paragraphs"] if p.get("type") == "scene_break"])
n = len(data.get("paragraphs", {})) if data else 0
total += expected
translated += n
if not data:
continue
chapter_issues = len(data.get("issues", []) or [])
issues += chapter_issues
chapters.append(
{
"id": c["id"],
"index": c["index"],
"matter": c["matter"],
"title": c["title"],
"title_zh": c["title_zh"],
"paragraphs_expected": expected,
"paragraphs_translated": n,
"critic_issues": chapter_issues,
}
)
glossary = run_dir.parent / "glossary" / "glossary.json"
manifest = {
"title": book.get("title"),
"title_zh": book.get("title_zh"),
"author": book.get("author"),
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"sample": sample,
"source": {
"bundle": book.get("bundle"),
"bundle_sha256": book.get("bundle_sha256"),
"book_json_sha256": _sha256(book_path),
},
"glossary_sha256": _sha256(glossary) if glossary.exists() else None,
"prompts_sha256": _sha256(Path(__file__).with_name("prompts.py")),
"git_commit": _repo_commit(book_path),
"models": {
"drafter": DEFAULT_MODEL,
"analyst": DEFAULT_ANALYST,
"critic": DEFAULT_CRITIC,
},
"counts": {
"chapters": len(chapters),
"paragraphs_expected": total,
"paragraphs_translated": translated,
"scene_breaks": scene_breaks,
"critic_issues": issues,
},
"chapters": chapters,
"outputs": {k: str(v) for k, v in files.items()},
}
path = out_dir / "run-manifest.json"
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def main() -> None:
ap = argparse.ArgumentParser(description="Render Deal Hunter Chinese AsciiDoc + ODT.")
ap.add_argument("--book", type=Path, required=True)
ap.add_argument("--run-dir", type=Path, required=True)
ap.add_argument("--out-dir", type=Path, required=True)
ap.add_argument("--sample", action="store_true")
ap.add_argument("--date", default="")
ap.add_argument("--no-pdf", action="store_true")
args = ap.parse_args()
out = render(
book_path=args.book,
run_dir=args.run_dir,
out_dir=args.out_dir,
sample=args.sample,
date=args.date,
pdf=not args.no_pdf,
)
out["manifest"] = write_manifest(
book_path=args.book,
run_dir=args.run_dir,
out_dir=args.out_dir,
sample=args.sample,
files=out,
)
for k, v in out.items():
print(f"{k:9} {v}")
if __name__ == "__main__":
main()