Something went wrong. Try again.
A Zig library and CLI for reading Mobipocket (.mobi, .prc, .azw) e-books.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Command line front end for the mobi reading library.
const std = @import("std");const Io = std.Io;const mobi = @import("mobi");const MOBI = mobi.MOBI;const drm = mobi.drm;
const usage = \\usage: mobi <command> <file.mobi> [options] \\ \\commands: \\ info <file> print metadata and a record summary \\ text <file> write the book markup to stdout as UTF-8 \\ images <file> <dir> extract embedded images into <dir> \\ \\options for encrypted books: \\ --serial <serial> device serial number, 16 or 40 characters \\ --pid <pid> device PID, 10 characters \\ \\Books with no device binding are unlocked automatically. Removing DRM may \\not be lawful where you are; that is your call to make, not this tool's. \\;
pub fn main(init: std.process.Init) !void { run(init) catch |err| switch (err) { // Downstream closed the pipe (`mobi text book.mobi | head`); that is a // normal way for the command to end, not a failure. error.WriteFailed => {}, else => return err, };}
fn run(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io; const arena = init.arena.allocator();
var stdout_buffer: [64 * 1024]u8 = undefined; var stdout_file_writer: Io.File.Writer = .init(.stdout(), io, &stdout_buffer); const out = &stdout_file_writer.interface;
const args = try init.minimal.args.toSlice(arena); if (args.len < 3) fail(usage, .{});
const command = args[1]; const path = args[2];
var serial: ?[]const u8 = null; var pid: ?[]const u8 = null; var positional: ?[]const u8 = null;
var i: usize = 3; while (i < args.len) : (i += 1) { const arg = args[i]; if (std.mem.eql(u8, arg, "--serial") or std.mem.eql(u8, arg, "--pid")) { i += 1; if (i == args.len) fail("{s} needs a value\n", .{arg}); if (arg[2] == 's') serial = args[i] else pid = args[i]; } else if (std.mem.startsWith(u8, arg, "--")) { fail("unknown option '{s}'\n\n" ++ usage, .{arg}); } else { positional = arg; } }
const bytes = Io.Dir.cwd().readFileAlloc(io, path, gpa, .unlimited) catch |err| { fail("cannot read {s}: {t}\n", .{ path, err }); }; defer gpa.free(bytes);
var book: MOBI = MOBI.parse(gpa, bytes) catch |err| { fail("cannot parse {s}: {t}\n", .{ path, err }); }; defer book.deinit(gpa);
// Try to unlock up front, but let each command decide how much it cares. var locked: ?anyerror = null; if (book.isEncrypted()) unlock(&book, serial, pid) catch |err| { locked = err; };
if (std.mem.eql(u8, command, "info")) { try printInfo(gpa, out, &book, locked); } else if (std.mem.eql(u8, command, "text")) { if (locked) |err| fail("cannot unlock {s}: {t}\n", .{ path, err }); const text = try book.readTextUtf8(gpa); defer gpa.free(text); try out.writeAll(text); } else if (std.mem.eql(u8, command, "images")) { const dir = positional orelse fail("images needs an output directory\n\n" ++ usage, .{}); try extractImages(io, out, &book, dir); } else { fail("unknown command '{s}'\n\n" ++ usage, .{command}); }
try out.flush();}
fn unlock(book: *MOBI, serial: ?[]const u8, pid_text: ?[]const u8) !void { if (serial) |s| return book.unlockWithSerial(s); if (pid_text) |p| { if (p.len != drm.pid_size) return error.InvalidPid; return book.unlockWithPid(p[0..drm.pid_size].*); } return book.unlock();}
/// Report a usage or input problem on stderr and exit, without the stack trace/// that returning an error from `main` would print.fn fail(comptime fmt: []const u8, args: anytype) noreturn { std.debug.print(fmt, args); std.process.exit(2);}
fn printInfo(gpa: std.mem.Allocator, out: *Io.Writer, book: *const MOBI, locked: ?anyerror) !void { try out.print("title: {s}\n", .{book.title()}); if (book.author()) |v| try out.print("author: {s}\n", .{v}); if (book.publisher()) |v| try out.print("publisher: {s}\n", .{v}); if (book.exth) |exth| { if (exth.get(.publishing_date)) |v| try out.print("published: {s}\n", .{v}); if (exth.get(.isbn)) |v| try out.print("isbn: {s}\n", .{v}); for (exth.getAll(.subject)) |v| try out.print("subject: {s}\n", .{v}); if (exth.get(.rights)) |v| try out.print("rights: {s}\n", .{v}); } try out.print("encoding: {t}\n", .{book.textEncoding()}); try out.print("compression: {t}\n", .{book.palmdoc_header.compression}); try out.print("version: {d}\n", .{book.fileVersion()});
try out.print("encryption: {t}", .{book.palmdoc_header.encryption}); if (book.isEncrypted()) { if (locked) |err| { try out.print(" (locked: {t})", .{err}); } else { try out.writeAll(" (unlocked)"); } } try out.writeAll("\n"); if (book.drm_validity) |v| { if (v.expiresAt()) |minutes| { try out.print("expires: {d} (minutes since epoch)\n", .{minutes}); } }
try out.print("records: {d} total, {d} text\n", .{ book.recordCount(), book.textRecordCount() }); try out.print("text length: {d} bytes\n", .{book.palmdoc_header.text_length}); if (book.kf8Boundary()) |b| try out.print("kf8 at: record {d}\n", .{b});
var images: usize = 0; var it = book.images(); while (it.next()) |_| images += 1; try out.print("images: {d}\n", .{images});
// Decompressing is the only way to know the text really reads back. if (book.readTextUtf8(gpa)) |text| { defer gpa.free(text); try out.print("decoded: {d} bytes of UTF-8\n", .{text.len}); } else |err| { try out.print("decoded: failed ({t})\n", .{err}); }}
fn extractImages(io: Io, out: *Io.Writer, book: *const MOBI, dir_path: []const u8) !void { var dir = try Io.Dir.cwd().createDirPathOpen(io, dir_path, .{}); defer dir.close(io);
var name_buf: [64]u8 = undefined; var count: usize = 0; var it = book.images(); while (it.next()) |img| { const name = try std.fmt.bufPrint(&name_buf, "{d:0>5}.{s}", .{ img.index, img.format.extension() }); try dir.writeFile(io, .{ .sub_path = name, .data = img.data }); try out.print("{s}/{s} {d} bytes\n", .{ dir_path, name, img.data.len }); count += 1; } try out.print("extracted {d} images\n", .{count});}