Files
Lu Fan(SonnyCalcr) 9b9326f153 feat(scripts): 出厂词库不再带内置快捷短语 (#1057)
本地构建不再读取 msime 的 mix/quick_phrases.txt,quick_parases 只建空表,
K 模式、设置页新增的用户快捷短语和安装回放仍写在这张表里。check_outputs
要求该表存在且为空;--compare-release 不再比对这张表。
2026-10-18 13:33:39 +08:00

313 lines
16 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.
"""构建流程:按 Rust 构建器的阶段顺序生成四个产物,写清单与校验和,整套检查通过后再替换 MetasequoiaImeDict/out/。"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import shutil
import sqlite3
import time
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from . import english_db, inputs, japanese_model, others_db, pinyin_db, symbols_db, textutil
ROOT = Path(__file__).resolve().parents[2]
DICT_ROOT = ROOT / "MetasequoiaImeDict"
OUT = DICT_ROOT / "out"
BACKUP = DICT_ROOT / "out.bak"
CACHE = DICT_ROOT / "cache" / "inputs"
NOTICE = DICT_ROOT / "source" / "mozc_dictionary_oss" / "README.txt"
MARKER = "local-dictionary.json"
REPOSITORY = "metasequoiaime/msime-dictionary"
# 随产物放在 out/ 里的声明:Mozc 要求分发模型时附上 README 与 LICENSE,SCOWL 要求每份拷贝附上它的版权声明。
NOTICE_FILES = ("mozc_dictionary_oss_README.txt", "mozc_LICENSE.txt", "scowl_Copyright.txt")
_spec = importlib.util.spec_from_file_location("dictionary_product", ROOT / "scripts" / "dictionary_product.py")
_product = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_product)
class Stages:
"""逐阶段打印耗时与结果,和 Rust 构建器的 [build]/[done] 输出对应。"""
def __call__(self, name: str, action):
print(f"[build] {name}", flush=True)
started = time.perf_counter()
summary = action()
print(f"[done] {name} in {time.perf_counter() - started:.1f}s: {summary}", flush=True)
return summary
def build_msime(source: inputs.Inputs, path: Path, stage: Stages) -> None:
connection = sqlite3.connect(path)
try:
stage("quanpin", lambda: f"{pinyin_db.build_quanpin(connection, source.source('sources/pinyin/single-chars.txt'), [source.source('sources/pinyin/rime-ice.txt'), source.source('sources/pinyin/rime-ice-supplement.txt')])} rows")
def words(relative: str):
entries = pinyin_db.parse_word_list(textutil.read(source.source(relative)), relative)
inserted, promoted, unchanged = pinyin_db.apply_words(connection, entries)
return f"{len(entries)} entries: {inserted} inserted, {promoted} promoted, {unchanged} already at or above their weight"
stage("places-supplement", lambda: words("sources/pinyin/places.txt"))
stage("custom-words", lambda: words("custom/words.txt"))
def corrections():
name = "pinyin-reading-corrections.txt"
entries = pinyin_db.parse_reading_corrections(textutil.read(source.resource(name)), name)
return f"{len(entries)} entries: {pinyin_db.apply_reading_corrections(connection, entries, name)} rows removed"
stage("reading-corrections", corrections)
def wubi():
imported, skipped, outside = pinyin_db.build_wubi86(
connection, [source.source("sources/wubi/wubi86-jidian.txt"), source.source("sources/wubi/wubi86-supplement.txt")])
return f"{imported} rows imported, {skipped} skipped, {outside} outside the basic CJK set left out"
stage("wubi", wubi)
def quick_phrases():
pinyin_db.build_quick_phrases(connection)
return "empty table, no built-in phrases"
stage("quick-phrases", quick_phrases)
stage("japanese-lexicon", lambda: f"{pinyin_db.build_japanese_lexicon(connection, source.jp_sela)} entries")
textutil.integrity_check(connection)
with connection:
textutil.analyze(connection, optimize=True)
finally:
connection.close()
textutil.freeze(path)
def build_english(source: inputs.Inputs, path: Path, msime: Path, stage: Stages) -> bytes:
"""返回 SCOWL 的 Copyright 原文,它同时写进库里的 source_notices 和 out/ 下的 scowl_Copyright.txt。"""
package = english_db.read_scowl_package(source.scowl)
scowl_name = "sources/english/scowl-words.txt"
scowl_words = english_db.parse_word_list(textutil.read(source.source(scowl_name)), scowl_name)
connection = sqlite3.connect(path)
try:
def words():
base = english_db.parse_base_dict_words(textutil.read(source.source("sources/english/rime-ice-en.txt")),
"rime-ice-en.txt")
supplement = english_db.parse_base_dict_words(
textutil.read(source.source("sources/english/rime-ice-en-supplement.txt")), "rime-ice-en-supplement.txt")
supplement_added = english_db.merge_words(base, supplement)
scowl_added = english_db.merge_words(base, {word: list(displays) for word, displays in scowl_words.items()})
counts = english_db.parse_google_counts(textutil.read(source.source("sources/english/google-word-counts.txt")))
custom = english_db.parse_custom_english(textutil.read(source.source("custom/english.txt")), "custom/english.txt")
total, base_rows, added, replaced = english_db.build_english_words(
connection, base, counts, english_db.letter_forms(package), custom)
english_db.write_notices(connection, [(english_db.NOTICE_SOURCE, package.copyright.decode("utf-8"))])
return (f"{total} words in {base_rows} rows ({supplement_added} from rime-ice supplement, {scowl_added} from SCOWL), "
f"{len(custom)} custom rows: {added} added, {replaced} replacing a base row")
stage("english", words)
def glosses():
# 只由 SCOWL 带来的词不进中译英(scowl-words.txt 本来就没有 rime-ice 与自定义词)。
with closing(sqlite3.connect(f"file:{msime}?mode=ro", uri=True)) as pinyin:
en_zh, zh_en = english_db.derive_glosses(source.ecdict, connection, pinyin, set(scowl_words))
english_db.write_glosses(connection, en_zh, zh_en)
return f"{len(en_zh)} English-to-Chinese, {len(zh_en)} Chinese-to-English"
stage("english-glosses", glosses)
def cn_en():
name = "sources/cn-en/machine-translations.txt"
entries = english_db.parse_cn_en_translations(textutil.read(source.source(name)), name)
replaced, added = english_db.apply_cn_en_translations(connection, entries)
return f"{len(entries)} Chinese-to-English: {replaced} replacing an ECDICT gloss, {added} added"
# 排在 ECDICT 释义之后、custom/translations.txt 之前:覆盖前者,再被后者覆盖。
stage("cn-en-translations", cn_en)
def translations():
entries = english_db.parse_custom_translations(textutil.read(source.source("custom/translations.txt")),
"custom/translations.txt")
english_db.apply_custom_translations(connection, entries)
return f"{len(entries)} overrides"
stage("custom-translations", translations)
finally:
connection.close()
textutil.freeze(path)
return package.copyright
def build_others(source: inputs.Inputs, path: Path, stage: Stages) -> None:
pinyin = others_db.Pinyin(textutil.read(source.resource("pinyin-overrides.txt")), "pinyin-overrides.txt")
connection = sqlite3.connect(path)
try:
def emoji():
catalog = others_db.read_emoji_catalog(textutil.read(source.resource("emoji/emoji_catalog.txt")), "emoji_catalog.txt")
zh = others_db.load_keyword_map(textutil.read(source.resource("emoji/emoji.txt")), "emoji.txt")
en = others_db.load_keyword_map(textutil.read(source.resource("emoji/emoji_en.txt")), "emoji_en.txt")
rows, keys = others_db.emoji_rows(pinyin, catalog, zh, en)
return f"{len(rows)} emoji, {others_db.build_emoji(connection, rows, keys)} search keys"
stage("emoji", emoji)
def kaomoji():
mapping = others_db.load_keyword_map(textutil.read(source.resource("kaomoji/kaomoji.txt")), "kaomoji.txt")
rows, entries = others_db.build_kaomoji(connection, pinyin, mapping)
return f"{entries} kaomoji, {rows} keyword rows"
stage("kaomoji", kaomoji)
def symbols():
groups = symbols_db.parse_wanxiang(textutil.read(source.source(symbols_db.WANXIANG)))
codex = symbols_db.parse_codex(textutil.read(source.source(symbols_db.CODEX)))
excluded = symbols_db.parse_unrenderable(textutil.read(source.source(symbols_db.UNRENDERABLE)))
readings = symbols_db.parse_readings(textutil.read(source.source(symbols_db.READINGS)))
catalog = symbols_db.catalog_rows(pinyin, groups, codex, excluded)
codes = symbols_db.code_rows(groups, readings, codex, excluded)
symbols_db.build(connection, catalog, codes)
return (f"{len(catalog)} panel symbols, {len(codes)} code rows from {len(groups)} groups, "
f"{len(readings)} pinyin readings and {len(codex)} codex names, "
f"{len(excluded)} unrenderable left out")
stage("symbols", symbols)
finally:
connection.close()
textutil.freeze(path)
def build_japanese(source: inputs.Inputs, path: Path, stage: Stages) -> None:
def model():
data, tokens, added, filtered = japanese_model.build(source.dictionary)
path.write_bytes(data)
return f"{tokens} tokens ({added} added from the aux dictionary and manual word lists, {filtered} base lines filtered)"
stage("japanese-model", model)
def build_all(source: inputs.Inputs, staging: Path) -> None:
stage = Stages()
build_msime(source, staging / "msime.db", stage)
copyright_text = build_english(source, staging / "english.db", staging / "msime.db", stage)
build_others(source, staging / "others.db", stage)
build_japanese(source, staging / "dict_japanese.dat", stage)
shutil.copyfile(source.source(japanese_model.NOTICE), staging / "mozc_dictionary_oss_README.txt")
shutil.copyfile(source.source(japanese_model.LICENSE), staging / "mozc_LICENSE.txt")
(staging / "scowl_Copyright.txt").write_bytes(copyright_text)
def check_outputs(directory: Path) -> None:
"""与 Prepare-PackageFiles.ps1 和引擎读取方式对应的最低检查。"""
# 连接要显式关掉,否则 Windows 上临时目录删不掉。
with closing(sqlite3.connect(f"file:{directory / 'msime.db'}?mode=ro", uri=True)) as msime:
tables = {row[0] for row in msime.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for table in ("wubi86", "japanese_lexicon", "tbl_1_a", "tbl_2_n", "tbl_others_s"):
if table not in tables or not msime.execute(f'SELECT 1 FROM "{table}" LIMIT 1').fetchone():
raise ValueError(f"msime.db 缺少表或表为空:{table}")
# 快捷短语表必须在(用户快捷短语写在这里),但出厂必须是空的。
if "quick_parases" not in tables:
raise ValueError("msime.db 缺少表:quick_parases")
if msime.execute('SELECT 1 FROM "quick_parases" LIMIT 1').fetchone():
raise ValueError("msime.db 的 quick_parases 不应带内置快捷短语")
with closing(sqlite3.connect(f"file:{directory / 'english.db'}?mode=ro", uri=True)) as english:
columns = list(english.execute("PRAGMA table_info(english_words)"))
if "weight" not in {c[1] for c in columns} or [c[1] for c in columns if c[5] > 0] != ["word", "display"]:
raise ValueError("english.db 的 english_words schema 与 Windows 端不符")
with (directory / "dict_japanese.dat").open("rb") as model:
if model.read(8) != japanese_model.MAGIC:
raise ValueError("dict_japanese.dat 头部不是 MSJPDT1")
def write_manifests(directory: Path, commit: str, dirty: bool, msime_commit: str) -> None:
files = {name: {"sha256": inputs.sha256(directory / name), "size": (directory / name).stat().st_size}
for name in sorted(_product.DESKTOP_FILES)}
lock = json.loads(inputs.LOCK.read_text(encoding="utf-8"))
# 与产品锁里旧词库的 dictionary-manifest.json 同一格式,dictionary_product.verify_product 能校验。
manifest = {
"manifest_version": 1,
"profile": "desktop",
"format_version": 1,
"engine_compatibility": {"dictionary_format": 1, "japanese_model_magic": "MSJPDT1"},
"source": {"repository": REPOSITORY, "path": ".", "commit": commit, "dirty": dirty},
"sqlite_journal_mode": "delete",
"format_contract_commit": msime_commit,
"custom_dictionary_commit": commit,
"custom_dictionary_repository": REPOSITORY,
"custom_dictionary_path": "custom",
"references": {
"msime": {"repository": f"https://github.com/{lock['msime']['repository']}.git", "commit": msime_commit},
**{name: {"url": entry["url"], "sha256": entry["sha256"]} for name, entry in lock["external"].items()},
},
"features": ["pinyin", "wubi", "quick_phrases", "english", "emoji", "kaomoji", "symbols", "japanese"],
"files": files,
}
(directory / _product.MANIFEST_NAME).write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
_product.verify_product(directory, "desktop")
outputs = sorted(set(_product.DESKTOP_FILES) | set(NOTICE_FILES) | {_product.MANIFEST_NAME})
(directory / "SHA256SUMS.txt").write_text(
"".join(f"{inputs.sha256(directory / name)} {name}\n" for name in outputs), encoding="utf-8")
marker = {
"built_from": "source",
"repository": REPOSITORY,
"custom_dictionary_commit": commit,
"dirty": dirty,
"msime_commit": msime_commit,
"builder": builder_digest(),
"built_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"files": {name: inputs.sha256(directory / name) for name in outputs + ["SHA256SUMS.txt"]},
}
(directory / MARKER).write_text(json.dumps(marker, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def builder_digest() -> str:
"""构建器自身(本目录的 .py 与 inputs.lock.json,加上入口脚本)的摘要:构建规则变了,同一份源数据也要重建。"""
digest = hashlib.sha256()
here = Path(__file__).resolve().parent
for path in sorted([*here.glob("*.py"), here / "inputs.lock.json", ROOT / "scripts" / "build-dictionary.py"]):
digest.update(path.name.encode("utf-8") + b"\0" + path.read_bytes().replace(b"\r\n", b"\n") + b"\0")
return digest.hexdigest()
def stale_reason(commit: str, dirty: bool) -> str | None:
"""out/ 不是由这份 msime-dictionary 提交和当前构建器构建的就返回原因;工作区有未提交改动时总是重建。"""
try:
marker = verify_out()
except ValueError as error:
return str(error)
if dirty or not commit:
return "msime-dictionary 有未提交的改动"
if marker.get("dirty"):
return "上次构建时 msime-dictionary 有未提交的改动"
if marker.get("custom_dictionary_commit") != commit:
return f"msime-dictionary 已从 {marker.get('custom_dictionary_commit', '')[:12]} 变为 {commit[:12]}"
if marker.get("builder") != builder_digest():
return "构建脚本有改动"
return None
def verify_out() -> dict:
marker_path = OUT / MARKER
if not marker_path.is_file():
raise ValueError(f"{OUT} 不是 build-dictionary.py 构建的词库(没有 {MARKER})")
marker = json.loads(marker_path.read_text(encoding="utf-8"))
for name, digest in marker["files"].items():
path = OUT / name
if not path.is_file() or inputs.sha256(path) != digest:
raise ValueError(f"{name} 与 {MARKER} 记录的摘要不符,重新运行 build-dictionary.py")
if not NOTICE.is_file():
raise ValueError(f"缺少 {NOTICE},重新运行 build-dictionary.py")
return marker
def install(staging: Path) -> None:
if OUT.exists():
if not (OUT / MARKER).is_file() and not BACKUP.exists():
# 产品锁的旧词库只备份一次,之后再构建不会把备份覆盖掉。
print(f"备份旧词库到 {BACKUP}")
shutil.copytree(OUT, BACKUP)
shutil.rmtree(OUT)
shutil.copytree(staging, OUT)
NOTICE.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(OUT / "mozc_dictionary_oss_README.txt", NOTICE)