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
672 lines
27 KiB
Python
672 lines
27 KiB
Python
# 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()
|