Adds the reverse conversion (sab -> sax) and makes the tools usable without remembering paths on the command line. - sax.py: decoder ported from OfflineApp.decodeAppBinary + encodeAppXml. Rebuilds the tree from the firstChild/nextSibling chain and writes XML the way XWriter does. Reproduces java.lang.Float.toString, and rounds manifest defaults to f4 before comparing them - otherwise props equal to their default (0.1) get written where sedonac omits them. - sab2sax.py: CLI dispatching on the input extension, like sedonac. sab.py still runs and hands off to it. - config.py: sedona.home lives in system.properties. If it is missing or has no manifests/, a folder chooser opens and the answer is written back. File choosers for the input, save dialogs for the output; the default output name carries a timestamp so a run never overwrites the previous one. - verify.py: checks both directions plus a sab -> sax -> sab round trip, and points at testprogram/ where the test files actually live. All three checks pass byte-identical against the checked-in sedonac output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
11 KiB
Python
348 lines
11 KiB
Python
"""The other direction: .sab -> .sax, ported from sedona.offline.OfflineApp
|
|
(decodeAppBinary + encodeAppXml) and OfflineComponent/OfflineLink.
|
|
|
|
Output is meant to match `sedonac.exe <file.sab>` byte for byte, down to the
|
|
`<!-- /path -->` comments and Java's float formatting.
|
|
|
|
Note what a .sab simply does not contain: only *config* props are stored, so
|
|
runtime prop values cannot come back. sedonac has the same hole -- both write
|
|
the manifest default for them, and a value equal to its default is not written
|
|
at all.
|
|
"""
|
|
import os, struct, base64, math, datetime
|
|
import sab, config
|
|
|
|
|
|
class Reader:
|
|
def __init__(self, data):
|
|
self.b, self.p = data, 0
|
|
|
|
def take(self, n):
|
|
if self.p + n > len(self.b):
|
|
raise Exception("truncated sab at offset %d" % self.p)
|
|
v = self.b[self.p:self.p + n]
|
|
self.p += n
|
|
return v
|
|
|
|
def u1(self): return self.take(1)[0]
|
|
def u2(self): return struct.unpack(">H", self.take(2))[0]
|
|
def i4(self): return struct.unpack(">i", self.take(4))[0]
|
|
def i8(self): return struct.unpack(">q", self.take(8))[0]
|
|
def f4(self): return struct.unpack(">f", self.take(4))[0]
|
|
def f8(self): return struct.unpack(">d", self.take(8))[0]
|
|
|
|
def str_(self):
|
|
end = self.b.index(0, self.p)
|
|
s = self.b[self.p:end].decode("ascii")
|
|
self.p = end + 1
|
|
return s
|
|
|
|
|
|
# ---------- values ----------
|
|
def decode_value(r, vtype, flags):
|
|
if "s" in flags: # asStr Buf: u2 len (incl NUL)
|
|
n = r.u2()
|
|
return r.take(n).split(b"\0")[0].decode("ascii")
|
|
if vtype == "bool":
|
|
v = r.u1()
|
|
return None if v == 2 else (v == 1)
|
|
if vtype == "byte": return r.u1()
|
|
if vtype == "short": return r.u2()
|
|
if vtype == "int": return r.i4()
|
|
if vtype == "long": return r.i8()
|
|
if vtype == "float": return r.f4()
|
|
if vtype == "double": return r.f8()
|
|
if vtype in ("sys::Buf", "buf"):
|
|
return bytes(r.take(r.u2()))
|
|
raise Exception("unhandled slot type " + str(vtype))
|
|
|
|
|
|
def default_value(vtype, flags, raw):
|
|
"""The manifest `default`, or Value.defaultForType -> zero. Same rules as sab.py."""
|
|
if "s" in flags:
|
|
return raw or ""
|
|
if vtype == "bool":
|
|
return None if raw == "null" else raw in ("true", "1")
|
|
if vtype in ("byte", "short", "int", "long"):
|
|
return sab._int(raw)
|
|
if vtype == "float":
|
|
# the default is a 32-bit Float too, so round before comparing:
|
|
# 0.1 as a double is not the 0.1 that came out of the file
|
|
return to_f32(sab._flt(raw))
|
|
if vtype == "double":
|
|
return sab._flt(raw)
|
|
if vtype in ("sys::Buf", "buf"):
|
|
return base64.b64decode(raw) if raw else b""
|
|
raise Exception("unhandled slot type " + str(vtype))
|
|
|
|
|
|
def same(a, b):
|
|
if isinstance(a, float) and isinstance(b, float):
|
|
return (math.isnan(a) and math.isnan(b)) or a == b
|
|
return type(a) is type(b) and a == b
|
|
|
|
|
|
# ---------- java number formatting ----------
|
|
F32_MAX = 3.4028234663852886e38 # largest finite float
|
|
F32_INF_EDGE = 3.4028235677973366e38 # midpoint to 2**128: below rounds to F32_MAX
|
|
|
|
|
|
def to_f32(v):
|
|
"""Round a double to the nearest float. struct raises past F32_MAX instead
|
|
of rounding, so handle that edge ourselves."""
|
|
try:
|
|
return struct.unpack(">f", struct.pack(">f", v))[0]
|
|
except OverflowError:
|
|
if abs(v) < F32_INF_EDGE:
|
|
return math.copysign(F32_MAX, v)
|
|
return math.copysign(math.inf, v)
|
|
|
|
|
|
def _shortest(a, single):
|
|
"""Fewest digits that still round-trip -- what Java's Float/Double.toString picks."""
|
|
for prec in range(0, 17):
|
|
s = "%.*e" % (prec, a)
|
|
v = float(s)
|
|
if single:
|
|
v = to_f32(v)
|
|
if v == a:
|
|
break
|
|
mant, exp = s.split("e")
|
|
digits = mant.replace(".", "").rstrip("0") or "0"
|
|
return digits, int(exp)
|
|
|
|
|
|
def java_float(v, single=True):
|
|
"""Java Float.toString / Double.toString: always a decimal point, E-form
|
|
outside [1e-3, 1e7)."""
|
|
if math.isnan(v):
|
|
return "NaN"
|
|
if math.isinf(v):
|
|
return "-Infinity" if v < 0 else "Infinity"
|
|
neg = math.copysign(1.0, v) < 0
|
|
a = abs(v)
|
|
if a == 0.0:
|
|
return "-0.0" if neg else "0.0"
|
|
|
|
digits, exp = _shortest(a, single)
|
|
if -3 <= exp < 7:
|
|
if exp >= 0:
|
|
head = digits[:exp + 1].ljust(exp + 1, "0")
|
|
tail = digits[exp + 1:] or "0"
|
|
else:
|
|
head, tail = "0", "0" * (-exp - 1) + digits
|
|
out = head + "." + tail
|
|
else:
|
|
out = digits[0] + "." + (digits[1:] or "0") + "E" + str(exp)
|
|
return "-" + out if neg else out
|
|
|
|
|
|
def encode_string(val, vtype, flags):
|
|
"""Value.encodeString()."""
|
|
if "s" in flags:
|
|
return _literal(val)
|
|
if vtype == "bool":
|
|
return "null" if val is None else ("true" if val else "false")
|
|
if vtype == "float":
|
|
return "null" if math.isnan(val) else java_float(val, True)
|
|
if vtype == "double":
|
|
return "null" if math.isnan(val) else java_float(val, False)
|
|
if vtype in ("sys::Buf", "buf"):
|
|
return base64.b64encode(val).decode("ascii")
|
|
return str(val)
|
|
|
|
|
|
def _literal(s):
|
|
"""TextUtil.toLiteral."""
|
|
out = []
|
|
for c in s:
|
|
out.append({"\0": "\\0", "\n": "\\n", "\r": "\\r", "\t": "\\t",
|
|
"\\": "\\\\", '"': '\\"', "$": "\\$"}.get(c, c))
|
|
return "".join(out)
|
|
|
|
|
|
def _safe(s):
|
|
"""XWriter.safe: escape markup, quotes, and anything outside printable ASCII."""
|
|
out = []
|
|
for c in s:
|
|
o = ord(c)
|
|
if o < 0x20 or o > 0x7e or c in "'\"":
|
|
out.append("&#x%x;" % o)
|
|
elif c == "<":
|
|
out.append("<")
|
|
elif c == ">":
|
|
out.append(">")
|
|
elif c == "&":
|
|
out.append("&")
|
|
else:
|
|
out.append(c)
|
|
return "".join(out)
|
|
|
|
|
|
# ---------- binary decode ----------
|
|
def read_app(sab_path, home):
|
|
"""-> (kits, comps by id, links). Each comp: id/type/name/parent/kids/props."""
|
|
r = Reader(open(sab_path, "rb").read())
|
|
if r.i4() != 0x73617070:
|
|
raise Exception("%s is not a .sab (bad magic)" % sab_path)
|
|
ver = r.i4()
|
|
if ver != 0x0003:
|
|
raise Exception("unsupported sab version 0x%x" % ver)
|
|
|
|
parts = []
|
|
for _ in range(r.u1()):
|
|
name = r.str_()
|
|
parts.append((name, "%08x" % (r.i4() & 0xFFFFFFFF)))
|
|
kits, types_by_q = sab.load_kits(parts, home)
|
|
r.u2() # maxId, recomputed on the way out
|
|
|
|
qname_of = {}
|
|
for ki, (kname, _, tlist) in enumerate(kits):
|
|
for t in tlist:
|
|
qname_of[(ki, int(t.get("id")))] = "%s::%s" % (kname, t.get("name"))
|
|
|
|
cache = {}
|
|
by_id, order = {}, []
|
|
while True:
|
|
cid = r.u2()
|
|
if cid == 0xFFFF:
|
|
break
|
|
ki, ti = r.u1(), r.u1()
|
|
if (ki, ti) not in qname_of:
|
|
raise Exception("comp %d: no type %d in kit %d" % (cid, ti, ki))
|
|
qname = qname_of[(ki, ti)]
|
|
c = {"id": cid, "type": qname, "name": r.str_(), "kids": [], "props": []}
|
|
c["parentId"], c["childId"], c["siblingId"] = r.u2(), r.u2(), r.u2()
|
|
for name, vtype, flags, dflt in sab.resolve_slots(qname, types_by_q, cache):
|
|
if "a" in flags or "c" not in flags:
|
|
continue # config props only, in slot-id order
|
|
c["props"].append((name, vtype, flags, dflt, decode_value(r, vtype, flags)))
|
|
if r.u1() != ord(";"):
|
|
raise Exception("corrupted component %d %s" % (cid, c["name"]))
|
|
if cid in by_id:
|
|
raise Exception("duplicate id: %d" % cid)
|
|
by_id[cid] = c
|
|
order.append(cid)
|
|
|
|
# rebuild the tree from the child/sibling id chain
|
|
for c in by_id.values():
|
|
c["parent"] = by_id[c["parentId"]] if c["parentId"] != 0xFFFF else None
|
|
if c["childId"] != 0xFFFF:
|
|
kid = by_id[c["childId"]]
|
|
while True:
|
|
c["kids"].append(kid)
|
|
if kid["siblingId"] == 0xFFFF:
|
|
break
|
|
kid = by_id[kid["siblingId"]]
|
|
|
|
links = []
|
|
while True:
|
|
fc = r.u2()
|
|
if fc == 0xFFFF:
|
|
break
|
|
fs, tc, ts = r.u1(), r.u2(), r.u1()
|
|
for cid in (fc, tc):
|
|
if cid not in by_id:
|
|
raise Exception("link references unknown comp id %d" % cid)
|
|
links.append((by_id[fc], fs, by_id[tc], ts))
|
|
if r.u1() != ord("."):
|
|
raise Exception("invalid app end marker")
|
|
|
|
return kits, by_id, order, links, types_by_q, cache
|
|
|
|
|
|
# ---------- xml encode ----------
|
|
def path_of(c):
|
|
parts = []
|
|
while c["parent"] is not None:
|
|
parts.append(c["name"])
|
|
c = c["parent"]
|
|
return "/" + "/".join(reversed(parts)) if parts else "/"
|
|
|
|
|
|
def write_xml(out, kits, app, links, types_by_q, cache):
|
|
w = out.append
|
|
w("<?xml version='1.0'?>\n")
|
|
w("<sedonaApp>\n")
|
|
w("<schema>\n")
|
|
for name, cks, _ in kits:
|
|
w(' <kit name="%s" checksum="%08x" />\n' % (_safe(name), cks & 0xFFFFFFFF))
|
|
w("</schema>\n")
|
|
|
|
w("<app>\n")
|
|
write_props(w, app, 2)
|
|
for kid in app["kids"]:
|
|
write_comp(w, kid, 2)
|
|
w("</app>\n")
|
|
|
|
w("<links>\n")
|
|
for fc, fs, tc, ts in links:
|
|
w(' <link from="%s.%s" to="%s.%s"/>\n' % (
|
|
_safe(path_of(fc)), _safe(slot_name(fc, fs, types_by_q, cache)),
|
|
_safe(path_of(tc)), _safe(slot_name(tc, ts, types_by_q, cache))))
|
|
w("</links>\n")
|
|
w("</sedonaApp>\n")
|
|
|
|
|
|
def slot_name(comp, slot_id, types_by_q, cache):
|
|
slots = sab.resolve_slots(comp["type"], types_by_q, cache)
|
|
if slot_id >= len(slots):
|
|
raise Exception("%s has no slot id %d" % (comp["type"], slot_id))
|
|
return slots[slot_id][0]
|
|
|
|
|
|
def write_props(w, c, indent):
|
|
"""Only props whose value differs from the slot default get written."""
|
|
n = 0
|
|
for name, vtype, flags, dflt, val in c["props"]:
|
|
if same(val, default_value(vtype, flags, dflt)):
|
|
continue
|
|
w('%s<prop name="%s" val="%s"/>\n' % (
|
|
" " * indent, _safe(name), _safe(encode_string(val, vtype, flags))))
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def write_comp(w, c, indent):
|
|
pad = " " * indent
|
|
w("%s<!-- %s -->\n" % (pad, path_of(c)))
|
|
w('%s<comp name="%s" id="%d" type="%s"' % (pad, _safe(c["name"]), c["id"], _safe(c["type"])))
|
|
|
|
body = []
|
|
n = write_props(body.append, c, indent + 2)
|
|
for kid in c["kids"]:
|
|
write_comp(body.append, kid, indent + 2)
|
|
if not n and not c["kids"]:
|
|
w("/>\n")
|
|
else:
|
|
w(">\n")
|
|
w("".join(body))
|
|
w("%s</comp>\n" % pad)
|
|
|
|
|
|
# ---------- main ----------
|
|
def default_sax_name(sab_path):
|
|
"""`app.sab` -> `app-sab2sax-20260728-140609.sax`."""
|
|
stem = os.path.splitext(os.path.basename(sab_path))[0]
|
|
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
return "%s-sab2sax-%s.sax" % (stem, stamp)
|
|
|
|
|
|
def decode(sab_path, home=None, sax_path=None):
|
|
if home is None:
|
|
home = config.get_sedona_home()
|
|
if sax_path is None:
|
|
sax_path = os.path.join(os.path.dirname(os.path.abspath(sab_path)),
|
|
default_sax_name(sab_path))
|
|
|
|
kits, by_id, order, links, types_by_q, cache = read_app(sab_path, home)
|
|
if 0 not in by_id:
|
|
raise Exception("no app component (id 0) in " + sab_path)
|
|
|
|
out = []
|
|
write_xml(out, kits, by_id[0], links, types_by_q, cache)
|
|
text = "".join(out)
|
|
with open(sax_path, "w", encoding="ascii", newline="\n") as f:
|
|
f.write(text)
|
|
return len(text)
|