code wiki / _hdl_build / nx_mobi_book.nx
nx_mobi_book.nx source
↩ module page · 305 lines · 16854 B
1// nx_mobi_book.nx -- SOVEREIGN MOBI/AZW (Kindle) -> Nishi format. Operator goal: read your OWN purchased books on
2// your OWN device, freed from the lock-in container. Parses the PDB database + PalmDOC-decompresses the text +
3// reads EXTH metadata, then reuses the SAME pipeline as the EPUB reader (nx_html_to_text -> book.json + chap.txt)
4// so the zero-JS reader renders it unchanged.
5// LEGAL LINE: this DECODES the FORMAT (interoperability). It DETECTS the MOBI encryptionType flag and DECLINES
6// DRM-encrypted files (exit 2) -- it does NOT circumvent DRM. DRM-FREE purchases/side-loads/library books decode.
7// Rung 1: whole book -> one chapter (readable); HUFF/CDIC compression + KF8/AZW3 + pagebreak chapter-splitting are
8// next rungs. Usage: nx_mobi_book <mobi-path> <slug>. expect_exit: 0 license_tier: ORIGINAL
9import "nx_syscalls.nx"
10import "nx_itoa_lib.nx" // shared MSB-first emitter (zero-alloc)
11import "nx_palmdoc.nx"
12import "nx_huffcdic.nx"
13import "nx_html_to_text.nx"
14import "nx_mobi_cover.nx"
15import "nx_chapter_split.nx"
16import "nx_charset.nx" // MOJIBAKE FIX: nx_charset_repair_utf8 -- stray Windows-1252 bytes -> clean UTF-8
17const K_MAGIC_1252: i64 = 1252
18const K_MAGIC_17480: i64 = 17480
19const K_MAGIC_262144: i64 = 262144
20const K_MAGIC_1024: i64 = 1024
21const K_MAGIC_8192: i64 = 8192
22
23func mb_slen(s: *u8) -> i64 { var n: i64=0; while s[n]!=(0 as u8){n=n+1} return n }
24func mb_w(fd: i64, s: *u8) -> i64 { sys_write(fd, s, mb_slen(s)); return 0 }
25// MIGRATED to the shared emitter (debt 1785563586). The old body mmapped a scratch buffer
26// per call and never freed it. At PAGE granularity that is 4096B leaked PER CALL -- the
27// defect that took 28.5GB of a 36GB host in nx_ts_lumadiff (2MB input, ~3.66M calls).
28// nxi_* is MSB-first, allocates NOTHING, and emits identical bytes including the sign.
29func mb_n(fd: i64, v: i64) -> i64 { nxi_fd(fd, v); return 0 }
30func mb_p(s: *u8) -> i64 { mb_w(1, s); return 0 }
31func mb_pn(v: i64) -> i64 { mb_n(1, v); return 0 }
32func ensure_dir(path: *u8) -> i64 { __syscall(258, 0-100, path, 0x1ed, 0, 0, 0); return 0 }
33
34func be16(b: *u8, o: i64) -> i64 { return ((b[o] as i64) << 8) | (b[o+1] as i64) }
35func be32(b: *u8, o: i64) -> i64 { return ((b[o] as i64) << 24) | ((b[o+1] as i64) << 16) | ((b[o+2] as i64) << 8) | (b[o+3] as i64) }
36
37func find_sub(hay: *u8, hl: i64, needle: *u8) -> i64 {
38 let nl: i64 = mb_slen(needle); if nl == 0 { return 0-1 }
39 var i: i64 = 0
40 while i + nl <= hl { var k: i64=0; var hit: i64=1; while k<nl { if hay[i+k]!=needle[k]{hit=0;k=nl}else{k=k+1} } if hit==1 {return i} i=i+1 }
41 return 0-1
42}
43// extract the <h1> text (inner tags stripped) from html[0..hl) into out; ret len (0 if no <h1>). Used for chapter titles.
44func extract_h1(html: *u8, hl: i64, out: *u8, cap: i64) -> i64 {
45 out[0] = 0 as u8
46 let h1: i64 = find_sub(html, hl, "<h1" as *u8)
47 if h1 < 0 { return 0 }
48 var gt: i64 = 0-1; var p: i64 = h1
49 while p < hl { if html[p] == (0x3e as u8) { gt = p; p = hl } else { p = p + 1 } }
50 if gt < 0 { return 0 }
51 let cs: i64 = gt + 1
52 let he: i64 = find_sub((html as i64 + cs) as *u8, hl - cs, "</h1" as *u8)
53 var ce: i64 = hl
54 if he >= 0 { ce = cs + he }
55 var o: i64 = 0; var i: i64 = cs; var intag: i64 = 0
56 while i < ce {
57 let c: i64 = html[i] as i64
58 if c == 0x3c { intag = 1 } else { if c == 0x3e { intag = 0 } else { if intag == 0 { if o < cap-1 { out[o]=html[i] as u8; o=o+1 } } } }
59 i = i + 1
60 }
61 out[o] = 0 as u8
62 return o
63}
64func er_cat(dst: *u8, off: i64, s: *u8) -> i64 { var i: i64=0; while s[i]!=(0 as u8){dst[off+i]=s[i];i=i+1} return off+i }
65func er_catn(dst: *u8, off: i64, v: i64) -> i64 { let t: *u8=sys_mmap(28); var m: i64=v; var k: i64=0; if m==0{t[0]=48 as u8;k=1}; while m>0{t[k]=(48+(m%10)) as u8;m=m/10;k=k+1}; var i: i64=0; while i<k{dst[off+i]=t[k-1-i];i=i+1} return off+k }
66// JSON string emit. MOJIBAKE FIX: repair each metadata string (title/author/publisher/description...) to clean
67// UTF-8 first, so a stray Windows-1252 byte renders correctly instead of U+FFFD. Idempotent on valid UTF-8.
68func er_wjson(fd: i64, s: *u8) -> i64 {
69 var sl: i64 = 0; while s[sl] != (0 as u8) { sl = sl + 1 }
70 let rcap: i64 = sl * 3 + 16
71 let rep: *u8 = sys_mmap(rcap)
72 let rn: i64 = nx_charset_repair_utf8(s, sl, rep, rcap)
73 var i: i64 = 0
74 while i < rn {
75 let c: i64 = rep[i] as i64
76 if c == 0x22 { sys_write(fd, "\\\"" as *u8, 2) }
77 else { if c == 0x5c { sys_write(fd, "\\\\" as *u8, 2) }
78 else { if c < 0x20 { sys_write(fd, " " as *u8, 1) }
79 else { sys_write(fd, (rep as i64 + i) as *u8, 1) } } }
80 i = i + 1
81 }
82 sys_munmap(rep, rcap)
83 return 0
84}
85// numeric character-reference decode (&#DDDD; / &#xHHHH; -> UTF-8), same proven shape as nx_epub_book.
86func utf8_put(out: *u8, o: i64, cp: i64) -> i64 {
87 if cp < 0x80 { out[o] = cp as u8; return o + 1 }
88 if cp < 0x800 { out[o] = (0xC0 | (cp >> 6)) as u8; out[o+1] = (0x80 | (cp & 0x3F)) as u8; return o + 2 }
89 if cp < 0x10000 { out[o] = (0xE0 | (cp >> 12)) as u8; out[o+1] = (0x80 | ((cp >> 6) & 0x3F)) as u8; out[o+2] = (0x80 | (cp & 0x3F)) as u8; return o + 3 }
90 out[o] = (0xF0 | (cp >> 18)) as u8; out[o+1] = (0x80 | ((cp >> 12) & 0x3F)) as u8; out[o+2] = (0x80 | ((cp >> 6) & 0x3F)) as u8; out[o+3] = (0x80 | (cp & 0x3F)) as u8; return o + 4
91}
92func decode_numeric_refs(txt: *u8, n: i64, out: *u8, outcap: i64) -> i64 {
93 var i: i64 = 0
94 var o: i64 = 0
95 while i < n {
96 let c: i64 = txt[i] as i64
97 var done: i64 = 0
98 if c == 0x26 { if i + 1 < n { if (txt[i+1] as i64) == 0x23 {
99 var j: i64 = i + 2
100 var hex: i64 = 0
101 if j < n { let xc: i64 = txt[j] as i64; if xc == 0x78 { hex = 1; j = j + 1 } else { if xc == 0x58 { hex = 1; j = j + 1 } } }
102 var cp: i64 = 0
103 var ndig: i64 = 0
104 var scan: i64 = 1
105 while scan == 1 {
106 if j >= n { scan = 0 } else {
107 let dc: i64 = txt[j] as i64
108 var dv: i64 = 0-1
109 if hex == 1 { if dc >= 48 { if dc <= 57 { dv = dc - 48 } } if dc >= 97 { if dc <= 102 { dv = dc - 87 } } if dc >= 65 { if dc <= 70 { dv = dc - 55 } } }
110 else { if dc >= 48 { if dc <= 57 { dv = dc - 48 } } }
111 if dv < 0 { scan = 0 } else { if hex == 1 { cp = cp * 16 + dv } else { cp = cp * 10 + dv } ndig = ndig + 1; j = j + 1 }
112 }
113 }
114 if ndig > 0 { if j < n { if (txt[j] as i64) == 0x3b { if cp > 0 { if cp <= 0x10FFFF { if o + 4 < outcap { o = utf8_put(out, o, cp) } i = j + 1; done = 1 } } } } }
115 } } }
116 if done == 0 { if o < outcap - 1 { out[o] = txt[i]; o = o + 1 } i = i + 1 }
117 }
118 out[o] = 0 as u8
119 return o
120}
121// copy EXTH record value [p+8, p+rlen) of record at p into out (NUL-term).
122func exth_copy(z: *u8, p: i64, rlen: i64, out: *u8, cap: i64) -> i64 {
123 var o: i64 = 0
124 var s: i64 = p + 8
125 let e: i64 = p + rlen
126 while s < e { if o < cap-1 { out[o] = z[s]; o = o + 1 } s = s + 1 }
127 out[o] = 0 as u8
128 return o
129}
130
131const MCAP: i64 = 16777216
132
133func do_mobi(path: *u8, slug: *u8) -> i64 {
134 let lb: *i64 = sys_mmap(16) as *i64
135 let z: *u8 = sys_read_file(path, lb)
136 if (z as i64) == 0 { mb_p("MOBI-FAIL read " as *u8); mb_p(path); mb_p("\n" as *u8); return 0-1 }
137 let zl: i64 = lb[0]
138 if zl < 80 { mb_p("MOBI-FAIL too-small\n" as *u8); return 0-1 }
139
140 let numRec: i64 = be16(z, 76)
141 if numRec < 2 { mb_p("MOBI-FAIL no-text-records\n" as *u8); return 0-1 }
142 let rec0off: i64 = be32(z, 78)
143 if rec0off + 16 > zl { mb_p("MOBI-FAIL bad-rec0\n" as *u8); return 0-1 }
144
145 let comp: i64 = be16(z, rec0off + 0)
146 let textLen: i64 = be32(z, rec0off + 4)
147 let recCount: i64 = be16(z, rec0off + 8)
148 let enc: i64 = be16(z, rec0off + 12)
149
150 // ---- LEGAL LINE: DRM is detected and DECLINED, never circumvented ----
151 if enc != 0 {
152 mb_p("MOBI-DRM-DECLINED encryptionType=" as *u8); mb_pn(enc)
153 mb_p(" (the sovereign reader decodes DRM-FREE MOBI/AZW; it does NOT circumvent DRM. Use a DRM-free copy.)\n" as *u8)
154 sys_exit(2); return 2
155 }
156 var is_huff: i64 = 0
157 if comp == K_MAGIC_17480 { is_huff = 1 } else { if comp != 1 { if comp != 2 {
158 mb_p("MOBI-UNSUPPORTED compression=" as *u8); mb_pn(comp); mb_p(" (supported: none/PalmDOC/HUFF-CDIC)\n" as *u8)
159 sys_exit(3); return 3
160 } } }
161
162 // ---- HUFF/CDIC: load the Huffman tables (HUFF record) + dictionary (CDIC records) before decode ----
163 let d1cl: *i64 = sys_mmap(256*8) as *i64
164 let d1tm: *i64 = sys_mmap(256*8) as *i64
165 let d1mx: *i64 = sys_mmap(256*8) as *i64
166 let hmin: *i64 = sys_mmap(64*8) as *i64
167 let hmax: *i64 = sys_mmap(64*8) as *i64
168 let dptr: *i64 = sys_mmap(K_MAGIC_262144*8) as *i64
169 let dlen: *i64 = sys_mmap(K_MAGIC_262144*8) as *i64
170 let dflag: *i64 = sys_mmap(K_MAGIC_262144*8) as *i64
171 let dcnt: *i64 = sys_mmap(8) as *i64; dcnt[0]=0
172 if is_huff == 1 {
173 let huffidx: i64 = be32(z, rec0off + 112) // Huffman Record Offset: the spec's 0x70 is RECORD-0-relative (incl the 16B PalmDOC hdr), verified on a real kindlegen file
174 let huffcnt: i64 = be32(z, rec0off + 116) // Huffman Record Count (record-0 0x74)
175 let hs: i64 = be32(z, 78 + huffidx*8)
176 var he: i64 = zl
177 if huffidx + 1 < numRec { he = be32(z, 78 + (huffidx+1)*8) }
178 if huff_load((z as i64 + hs) as *u8, he - hs, d1cl, d1tm, d1mx, hmin, hmax) != 0 {
179 mb_p("MOBI-HUFF-FAIL bad-HUFF-record\n" as *u8); sys_exit(3); return 3
180 }
181 var ci: i64 = 1
182 while ci < huffcnt {
183 let cidx: i64 = huffidx + ci
184 let cs: i64 = be32(z, 78 + cidx*8)
185 var ce: i64 = zl
186 if cidx + 1 < numRec { ce = be32(z, 78 + (cidx+1)*8) }
187 cdic_load((z as i64 + cs) as *u8, ce - cs, dptr, dlen, dflag, dcnt)
188 ci = ci + 1
189 }
190 }
191
192 // ---- decompress/concat text records 1..recCount ----
193 let tbuf: *u8 = sys_mmap(MCAP)
194 var to: i64 = 0
195 var ri: i64 = 1
196 while ri <= recCount {
197 if ri >= numRec { ri = recCount + 1 } else {
198 let rs: i64 = be32(z, 78 + ri*8)
199 var re: i64 = zl
200 if ri + 1 < numRec { re = be32(z, 78 + (ri+1)*8) }
201 let rlen: i64 = re - rs
202 if rlen > 0 { if rs + rlen <= zl {
203 if comp == 2 { to = to + palmdoc_decompress((z as i64 + rs) as *u8, rlen, (tbuf as i64 + to) as *u8, MCAP - to) }
204 else { if is_huff == 1 { to = to + hc_unpack((z as i64 + rs) as *u8, rlen, (tbuf as i64 + to) as *u8, MCAP - to, d1cl, d1tm, d1mx, hmin, hmax, dptr, dlen, dflag, dcnt[0]) }
205 else { var k: i64=0; while k < rlen { if to < MCAP { tbuf[to]=z[rs+k]; to=to+1 } k=k+1 } } }
206 } }
207 ri = ri + 1
208 }
209 }
210 var htmllen: i64 = to
211 if textLen > 0 { if textLen < htmllen { htmllen = textLen } }
212
213 // ---- metadata: EXTH (author=100, title=503), fallback title = PDB database name ----
214 let title: *u8 = sys_mmap(K_MAGIC_1024); title[0] = 0 as u8
215 let author: *u8 = sys_mmap(K_MAGIC_1024); author[0] = 0 as u8
216 // richer EXTH metadata ("all the book info"): publisher 101 / isbn 104 / pubdate 106 -- captured from the file itself
217 let publisher: *u8 = sys_mmap(K_MAGIC_1024); publisher[0] = 0 as u8
218 let isbn: *u8 = sys_mmap(256); isbn[0] = 0 as u8
219 let pubdate: *u8 = sys_mmap(256); pubdate[0] = 0 as u8
220 let descr_raw: *u8 = sys_mmap(K_MAGIC_8192); descr_raw[0] = 0 as u8 // EXTH 103 description (HTML; html_to_text'd before book.json)
221 // EMBEDDED-COVER pointers (spec mobileread-mobi-spec.ref): EXTH 201 coveroffset / 202 thumboffset are ADDED to
222 // the MOBI header's "First Image index" to find the cover PDB record. Captured here during the EXTH scan.
223 var coveroff: i64 = 0; var coverfound: i64 = 0; var thumboff: i64 = 0; var thumbfound: i64 = 0
224 let rec1off: i64 = be32(z, 78 + 8)
225 var r0ext: i64 = rec1off - rec0off
226 if r0ext <= 0 { r0ext = zl - rec0off }
227 let exrel: i64 = find_sub((z as i64 + rec0off) as *u8, r0ext, "EXTH" as *u8)
228 if exrel >= 0 {
229 let exth: i64 = rec0off + exrel
230 let cnt: i64 = be32(z, exth + 8)
231 var p: i64 = exth + 12
232 var ci: i64 = 0
233 while ci < cnt {
234 if p + 8 > zl { ci = cnt } else {
235 let rtype: i64 = be32(z, p)
236 let rlen: i64 = be32(z, p + 4)
237 if rlen < 8 { ci = cnt } else {
238 if rtype == 100 { exth_copy(z, p, rlen, author, K_MAGIC_1024) }
239 if rtype == 503 { exth_copy(z, p, rlen, title, K_MAGIC_1024) }
240 if rtype == 201 { if rlen >= 12 { coveroff = be32(z, p + 8); coverfound = 1 } } // EXTH 201 coveroffset
241 if rtype == 202 { if rlen >= 12 { thumboff = be32(z, p + 8); thumbfound = 1 } } // EXTH 202 thumboffset
242 if rtype == 101 { exth_copy(z, p, rlen, publisher, K_MAGIC_1024) } // EXTH 101 publisher
243 if rtype == 104 { exth_copy(z, p, rlen, isbn, 256) } // EXTH 104 isbn
244 if rtype == 106 { exth_copy(z, p, rlen, pubdate, 256) } // EXTH 106 publishing date
245 if rtype == 103 { exth_copy(z, p, rlen, descr_raw, K_MAGIC_8192) } // EXTH 103 description (HTML)
246 p = p + rlen
247 ci = ci + 1
248 }
249 }
250 }
251 }
252 if title[0] == (0 as u8) { var k: i64=0; while k < 31 { let c: i64 = z[k] as i64; if c==0 { k=31 } else { title[k]=z[k] as u8; k=k+1 } } title[k]=0 as u8 }
253
254 // ---- description (EXTH 103) is HTML -> strip tags + decode numeric refs so book.json carries clean prose ----
255 let descr_clean: *u8 = sys_mmap(K_MAGIC_8192); descr_clean[0] = 0 as u8
256 if descr_raw[0] != (0 as u8) {
257 let dtmp: *u8 = sys_mmap(K_MAGIC_8192)
258 let dtn0: i64 = nx_html_to_text(descr_raw, mb_slen(descr_raw), dtmp, K_MAGIC_8192)
259 decode_numeric_refs(dtmp, dtn0, descr_clean, K_MAGIC_8192)
260 }
261
262 // ---- emit reader/<slug>/ dir + cover (the chapter split + chap<i>.txt files are done by nx_chapter_split, below) ----
263 ensure_dir("knowledge/staging/media/reader" as *u8)
264 let dir: *u8 = sys_mmap(512)
265 var dl: i64 = er_cat(dir, 0, "knowledge/staging/media/reader/" as *u8)
266 dl = er_cat(dir, dl, slug); dir[dl] = 0 as u8
267 ensure_dir(dir)
268 let covername: *u8 = sys_mmap(64); covername[0] = 0 as u8 // EMBEDDED cover (shared w/ nx_kf8_book via nx_mobi_cover)
269 mobi_extract_cover(z, zl, numRec, rec0off, coveroff, coverfound, thumboff, thumbfound, dir, covername)
270
271 // ---- book.json (metadata + chapters[] + toc[]) ----
272 let jp: *u8 = sys_mmap(K_MAGIC_1024)
273 var jl: i64 = er_cat(jp, 0, dir as *u8); jl = er_cat(jp, jl, "/book.json" as *u8); jp[jl] = 0 as u8
274 let jfd: i64 = sys_openat_wr(jp, 0x1a4)
275 if jfd < 0 { mb_p("MOBI-FAIL open-json\n" as *u8); return 0-1 }
276 mb_w(jfd, "{\"title\":\"" as *u8); er_wjson(jfd, title)
277 mb_w(jfd, "\",\"author\":\"" as *u8); er_wjson(jfd, author)
278 mb_w(jfd, "\",\"publisher\":\"" as *u8); er_wjson(jfd, publisher)
279 mb_w(jfd, "\",\"pubdate\":\"" as *u8); er_wjson(jfd, pubdate)
280 mb_w(jfd, "\",\"isbn\":\"" as *u8); er_wjson(jfd, isbn)
281 mb_w(jfd, "\",\"description\":\"" as *u8); er_wjson(jfd, descr_clean)
282 mb_w(jfd, "\",\"format\":\"mobi\",\"rfix\":1,\"slug\":\"" as *u8); er_wjson(jfd, slug) // rfix=reader-fix schema -> server re-extracts older caches
283 mb_w(jfd, "\"," as *u8)
284 let totalp: *i64 = sys_mmap(8) as *i64; totalp[0]=0
285 let nseg: i64 = chapter_split(tbuf, htmllen, dir, title, jfd, totalp, "mobi\x00" as *u8) // split -> chap<i>.txt + chapters[]/toc[]
286 let total: i64 = totalp[0]
287 mb_w(jfd, ",\"assets\":[],\"nassets\":0,\"cover\":\"" as *u8); er_wjson(jfd, covername)
288 mb_w(jfd, "\"}" as *u8)
289 sys_close(jfd)
290
291 mb_p("MOBI-BOOK title=\"" as *u8); mb_w(1, title); mb_p("\" author=\"" as *u8); mb_w(1, author)
292 mb_p("\" comp=" as *u8); mb_pn(comp); mb_p(" enc=" as *u8); mb_pn(enc); mb_p(" text_records=" as *u8); mb_pn(recCount)
293 mb_p(" html_bytes=" as *u8); mb_pn(htmllen); mb_p(" chapters=" as *u8); mb_pn(nseg); mb_p(" chars=" as *u8); mb_pn(total); mb_p(" -> " as *u8); mb_p(dir); mb_p("\n" as *u8)
294 if total < 20 { return 0-1 }
295 return 0
296}
297
298func main(argc: i64, argv: *i64) -> i64 {
299 var path: *u8 = "knowledge/fixtures/nishi_mobi_fixture.mobi" as *u8
300 var slug: *u8 = "nishi_mobi_fixture" as *u8
301 if argc >= 3 { path = argv[1] as *u8; slug = argv[2] as *u8 }
302 let r: i64 = do_mobi(path, slug)
303 if r == 0 { mb_p("MOBI-BOOK-OK\n" as *u8); sys_exit(0); return 0 }
304 mb_p("MOBI-BOOK-FAIL\n" as *u8); sys_exit(1); return 1
305}