"""The other direction: .sab -> .sax, ported from sedona.offline.OfflineApp (decodeAppBinary + encodeAppXml) and OfflineComponent/OfflineLink. Output is meant to match `sedonac.exe ` byte for byte, down to the `` 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("\n") w("\n") w("\n") for name, cks, _ in kits: w(' \n' % (_safe(name), cks & 0xFFFFFFFF)) w("\n") w("\n") write_props(w, app, 2) for kid in app["kids"]: write_comp(w, kid, 2) w("\n") w("\n") for fc, fs, tc, ts in links: w(' \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("\n") w("\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\n' % ( " " * indent, _safe(name), _safe(encode_string(val, vtype, flags)))) n += 1 return n def write_comp(w, c, indent): pad = " " * indent w("%s\n" % (pad, path_of(c))) w('%s\n") else: w(">\n") w("".join(body)) w("%s\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)