mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-16 10:02:38 +00:00
141 lines
6.3 KiB
Python
141 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Pack the pinned public-domain WEB John source into independent DEFLATE blocks."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import unicodedata
|
|
import zlib
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SOURCE = Path(__file__).with_name("john-web.json")
|
|
HEADER = ROOT / "src/helpers/bible/JohnData.generated.h"
|
|
BLOCK_SIZE = 2048
|
|
ASCII_TYPOGRAPHY = str.maketrans({
|
|
"\u00a0": " ", "\u2013": "-", "\u2014": "-",
|
|
"\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"',
|
|
})
|
|
CHAPTER_VERSES = (51, 25, 36, 54, 47, 71, 53, 59, 41, 42, 57,
|
|
50, 38, 31, 27, 33, 26, 40, 42, 31, 25)
|
|
|
|
|
|
def references():
|
|
return [f"{c}:{v}" for c, count in enumerate(CHAPTER_VERSES, 1)
|
|
for v in range(1, count + 1)]
|
|
|
|
|
|
def checked_text(value, limit):
|
|
if not isinstance(value, str) or not value or value != value.strip():
|
|
raise ValueError("expected nonempty trimmed text")
|
|
if any(unicodedata.category(c).startswith("C") for c in value):
|
|
raise ValueError("control/format characters are not allowed in terminal text")
|
|
encoded = value.encode("utf-8")
|
|
if len(encoded) > limit:
|
|
raise ValueError(f"text exceeds {limit} UTF-8 bytes")
|
|
return encoded
|
|
|
|
|
|
def ascii_verse(value, limit):
|
|
# Keep the pinned source exact. Only the generated firmware copy changes
|
|
# typography; never transliterate letters or silently discard characters.
|
|
checked_text(value, limit * 3)
|
|
try:
|
|
encoded = value.translate(ASCII_TYPOGRAPHY).encode("ascii")
|
|
except UnicodeEncodeError as error:
|
|
raise ValueError("unsupported non-ASCII character in John source") from error
|
|
if len(encoded) > limit:
|
|
raise ValueError(f"text exceeds {limit} ASCII bytes")
|
|
return encoded
|
|
|
|
|
|
def pack(document, block_size=BLOCK_SIZE):
|
|
if block_size not in (1024, 2048, 4096):
|
|
raise ValueError("block size must be 1024, 2048, or 4096")
|
|
checked_text(document["translation"], 24)
|
|
checked_text(document["attribution"], 256)
|
|
verses = document["verses"]
|
|
if not isinstance(verses, dict) or set(verses) != set(references()):
|
|
raise ValueError("source must explicitly include all 879 John references")
|
|
blocks, starts, current = [], [0], bytearray()
|
|
for index, reference in enumerate(references()):
|
|
verse = verses[reference]
|
|
encoded = (ascii_verse(verse, block_size - 1) if verse is not None else b"") + b"\0"
|
|
if len(current) + len(encoded) > block_size:
|
|
blocks.append(bytes(current))
|
|
current.clear()
|
|
starts.append(index)
|
|
current.extend(encoded)
|
|
if current:
|
|
blocks.append(bytes(current))
|
|
if not blocks:
|
|
raise ValueError("source contains no verse text")
|
|
packed, descriptors = bytearray(), []
|
|
for first_verse, block in zip(starts, blocks):
|
|
compressor = zlib.compressobj(level=9, wbits=-15)
|
|
compressed = compressor.compress(block) + compressor.flush()
|
|
descriptors.append((len(packed), len(compressed), len(block), first_verse))
|
|
packed.extend(compressed)
|
|
return bytes(packed), descriptors
|
|
|
|
|
|
def render(document):
|
|
packed, blocks = pack(document)
|
|
digest = hashlib.sha256(json.dumps(document, ensure_ascii=False, sort_keys=True,
|
|
separators=(",", ":")).encode()).hexdigest()
|
|
lines = ["// Generated by tools/bible/pack_john.py; do not edit.",
|
|
"// World English Bible (engwebp), John. Public domain; see tools/bible/README.md.",
|
|
"// Firmware typography: ASCII quotes, apostrophes, dashes and spaces; wording unchanged.",
|
|
f"// Canonical source SHA-256: {digest}",
|
|
"#pragma once", '#include "JohnLookup.h"',
|
|
"namespace mesh { namespace bible { namespace generated {",
|
|
f'static_assert(kBlockSize == {BLOCK_SIZE}, "Regenerate John data after changing block size");',
|
|
'static_assert(sizeof(BlockIndex) == 12, "John flash-size accounting expects a 12-byte index");',
|
|
"static const uint8_t johnData[] = {"]
|
|
for start in range(0, len(packed), 20):
|
|
lines.append(" " + ",".join(f"0x{v:02x}" for v in packed[start:start + 20]) + ",")
|
|
lines += ["};", "static const BlockIndex johnBlocks[] = {"]
|
|
lines += [" {%d, %d, %d, %d}," % block for block in blocks]
|
|
lines += ["};", "static const Corpus johnCorpus = {",
|
|
f" {json.dumps(document['translation'], ensure_ascii=False)},",
|
|
f" {json.dumps(document['attribution'], ensure_ascii=False)},",
|
|
" johnData, sizeof(johnData), johnBlocks,",
|
|
" sizeof(johnBlocks) / sizeof(johnBlocks[0]), kVerseCount", "};", "}}}", ""]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--source", type=Path, default=SOURCE)
|
|
parser.add_argument("--output", type=Path, default=HEADER)
|
|
parser.add_argument("--check", action="store_true")
|
|
parser.add_argument("--compare", action="store_true", help="compare 1/2/4 KiB blocks without writing")
|
|
args = parser.parse_args()
|
|
document = json.loads(args.source.read_text(encoding="utf-8"))
|
|
if args.compare:
|
|
for size in (1024, 2048, 4096):
|
|
packed, blocks = pack(document, size)
|
|
tables = len(blocks) * 12
|
|
plain = sum(block[2] for block in blocks)
|
|
print(f"{size} byte blocks: {len(blocks)} blocks, {plain} plain, "
|
|
f"{len(packed)} compressed, {tables} index, "
|
|
f"{len(packed) + tables} total, "
|
|
f"{100 * (1 - (len(packed) + tables) / plain):.2f}% saved")
|
|
return
|
|
header = render(document)
|
|
if args.check:
|
|
if args.output.read_text(encoding="utf-8") != header:
|
|
raise SystemExit("John header differs; regenerate with tools/bible/pack_john.py")
|
|
else:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(header, encoding="utf-8", newline="\n")
|
|
packed, blocks = pack(document)
|
|
plain = sum(block[2] for block in blocks)
|
|
tables = len(blocks) * 12
|
|
print(f"John: {len(references())} verses, {len(blocks)} blocks; "
|
|
f"{plain} plain bytes -> {len(packed)} DEFLATE + {tables} index bytes "
|
|
f"({100 * (1 - (len(packed) + tables) / plain):.1f}% smaller incl. indexes)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|