diff --git a/.gitignore b/.gitignore index 8e90778..9e03f12 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,12 @@ tuku/picbrowser tuku/*.mpk tuku/build/ tuku/test/test_library +tuku/test/test_reader_core +tuku/test/test_settings_reader_persist +tuku/test/test_settings_lock_host +tuku/test/test_pin +tuku/test/test_zip +tuku/test/test_cbz # ---- 杂项 ---- *.orig diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh index 602ceec..48752ac 100755 --- a/tuku/build_tuku.sh +++ b/tuku/build_tuku.sh @@ -4,15 +4,18 @@ # SDK 路径用绝对路径(默认 $HOME/Proj/MeowOS-SDK/sdk),可用 MEOW_SDK_DIR 覆盖; # 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/os.meow.picbrowser。 # 交叉编译器/sysroot 同 SDK:MEOW_CC / MEOW_SYSROOT 可覆盖。 +# -I"$SYSROOT/include":zip 容器层(logic/zip/zip.c)直接 include , +# 头文件随 sysroot 的 libz.a 一起提供(SDK 自身只用最小 zlib ABI 自声明)。 set -e cd "$(dirname "$0")" CC="${MEOW_CC:-$HOME/.tools/zig-mipsel-cc}" SYSROOT="${MEOW_SYSROOT:-$HOME/.tools/mipsel-linux-musleabi-sysroot}" SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" -"$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ +"$CC" -Os -static -Wall -Wextra -I"$SDK" -I"$SYSROOT/include" -I. \ app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ - logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c logic/pin.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c logic/pin.c \ "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser ls -la picbrowser file picbrowser diff --git a/tuku/logic/library.c b/tuku/logic/library.c index b640401..4db9545 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -12,6 +12,14 @@ * - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page); * - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」, * 供阅读器末页前进/首页后退跨册;mr_library_dir_has_images 是共享的「是不是一本书」判据。 + * + * M11 追加(档案册,仍然不改目录扫描的主体语义): + * - 目标目录的**直接子文件**里 .cbz/.zip 也成行(行=文件名+档内页数,has_images=1); + * - 档案册探测由调用方注入(mr_lib_archive_fn),logic 层不依赖 zip 容器; + * 不注入时所有 *_ex 入口与 M7 行为逐字一致; + * - 册间衔接的兄弟表从「含图目录」扩成「含图目录 ∪ 含图片页的档案册」,同列自然序。 + * 小函数边界(add_dir_entry / add_archive_entry / entry_subtitle / entry_badge / + * sibling_is_book)刻意留给 M13 的书库重写复用。 */ #include "library.h" #include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ @@ -66,6 +74,15 @@ int mr_path_is_image(const char *name) strcasecmp(dot, "jpeg") == 0 || strcasecmp(dot, "bmp") == 0; } +int mr_path_is_archive(const char *name) +{ + if (!name) return 0; + const char *dot = strrchr(name, '.'); + if (!dot || dot == name) return 0; + dot++; + return strcasecmp(dot, "cbz") == 0 || strcasecmp(dot, "zip") == 0; +} + int mr_natural_cmp(const char *a, const char *b) { const unsigned char *pa = (const unsigned char *)a; @@ -105,12 +122,22 @@ int mr_natural_cmp(const char *a, const char *b) typedef struct scan_info { int pages; /* 直接图片数 */ int subdirs; /* 含图子目录数 */ - int nav; /* 1=本目录最终含图(可直接看或可导航到图) */ + int archives; /* 直接档案册数(.cbz/.zip 且探测到图片页) */ + int nav; /* 1=本目录最终含书(可直接看或可导航到书) */ char cover[MR_PATH_MAX]; /* 缩略图源 */ char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */ } scan_info; -static void scan_dir(const char *path, int depth, int max_depth, scan_info *si) +/* 档案册探测(UI 注入;NULL=纯目录书库,行为与 M7 一致)。 */ +static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) +{ + if (!probe || !path || !path[0]) return 0; + int n = probe(path, ud); + return n > 0 ? n : 0; +} + +static void scan_dir(const char *path, int depth, int max_depth, scan_info *si, + mr_lib_archive_fn probe, void *ud) { memset(si, 0, sizeof *si); DIR *d = opendir(path); @@ -132,21 +159,25 @@ static void scan_dir(const char *path, int depth, int max_depth, scan_info *si) if (S_ISDIR(st.st_mode)) { if (depth >= max_depth) continue; scan_info child; - scan_dir(full, depth + 1, max_depth, &child); + scan_dir(full, depth + 1, max_depth, &child, probe, ud); if (!child.nav) continue; si->subdirs++; if (!si->cover[0] && child.cover[0]) copy_str(si->cover, (int)sizeof si->cover, child.cover); - } else if (S_ISREG(st.st_mode) && mr_path_is_image(name)) { - si->pages++; - if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { - copy_str(si->first_name, (int)sizeof si->first_name, name); - copy_str(si->cover, (int)sizeof si->cover, full); + } else if (S_ISREG(st.st_mode)) { + if (mr_path_is_image(name)) { + si->pages++; + if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { + copy_str(si->first_name, (int)sizeof si->first_name, name); + copy_str(si->cover, (int)sizeof si->cover, full); + } + } else if (mr_path_is_archive(name)) { + if (archive_pages_at(probe, ud, full) > 0) si->archives++; } } } closedir(d); - si->nav = (si->pages > 0 || si->subdirs > 0); + si->nav = (si->pages > 0 || si->subdirs > 0 || si->archives > 0); } void mr_library_free(mr_library *lib) @@ -179,7 +210,69 @@ static int entry_cmp(const void *pa, const void *pb) return mr_natural_cmp(a->name, b->name); } -int mr_library_scan(mr_library *lib, const char *dir, int max_depth) +/* 副标题(M11 扩展):直接含图 → pages;只有档案册 → books;否则 folders。 */ +static void entry_subtitle(mr_library_entry *e, int archive_books) +{ + if (e->has_images) + snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, + e->pages == 1 ? "" : "s"); + else if (archive_books > 0) + snprintf(e->subtitle, sizeof e->subtitle, "%d book%s", archive_books, + archive_books == 1 ? "" : "s"); + else + snprintf(e->subtitle, sizeof e->subtitle, "%d folder%s", e->subdirs, + e->subdirs == 1 ? "" : "s"); +} + +/* 续读角标(M7):有 last page 记录的册,在副标题后缀「· 续读 Pn」。 + * 档案册以档案路径为册标识,同一套逻辑复用。 + * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ +static void entry_badge(mr_library_entry *e) +{ + if (!e->has_images) return; + char badge[24]; + if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; + int used = (int)strlen(e->subtitle); + if (used < (int)sizeof e->subtitle - 2) + snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); +} + +/* 文件夹册(含可导航中间目录)一行。 */ +static int add_dir_entry(mr_library *lib, const char *name, const char *full, + const scan_info *si) +{ + mr_library_entry e; + memset(&e, 0, sizeof e); + if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; + if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; + copy_str(e.cover, (int)sizeof e.cover, si->cover); + e.pages = si->pages; + e.subdirs = si->subdirs; + e.has_images = (si->pages > 0); + entry_subtitle(&e, si->archives); + entry_badge(&e); + return lib_add(lib, &e); +} + +/* 档案册(.cbz/.zip)一行:文件即一本书,点入直接阅读。 + * cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */ +static int add_archive_entry(mr_library *lib, const char *name, const char *full, + int pages) +{ + mr_library_entry e; + memset(&e, 0, sizeof e); + if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; + if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; + e.pages = pages; + e.subdirs = 0; + e.has_images = 1; + snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", pages, pages == 1 ? "" : "s"); + entry_badge(&e); + return lib_add(lib, &e); +} + +int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, + mr_lib_archive_fn archive_pages, void *ud) { if (!lib) return -1; mr_library_free(lib); @@ -201,38 +294,22 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) struct stat st; if (lstat(full, &st) != 0) continue; - if (S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; + if (S_ISLNK(st.st_mode)) continue; - scan_info si; - scan_dir(full, 1, max_depth, &si); - if (!si.nav) continue; - - mr_library_entry e; - memset(&e, 0, sizeof e); - if (copy_str(e.name, (int)sizeof e.name, name) != 0) continue; - if (copy_str(e.path, (int)sizeof e.path, full) != 0) continue; - copy_str(e.cover, (int)sizeof e.cover, si.cover); - e.pages = si.pages; - e.subdirs = si.subdirs; - e.has_images = (si.pages > 0); - if (e.has_images) - snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", e.pages, - e.pages == 1 ? "" : "s"); - else - snprintf(e.subtitle, sizeof e.subtitle, "%d folder%s", e.subdirs, - e.subdirs == 1 ? "" : "s"); - /* 续读角标(M7):有 last page 记录的册,在副标题后缀「· 续读 Pn」。 - * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ - if (e.has_images) { - char badge[24]; - if (mr_cont_badge(e.path, badge, (int)sizeof badge)) { - int used = (int)strlen(e.subtitle); - if (used < (int)sizeof e.subtitle - 2) - snprintf(e.subtitle + used, sizeof e.subtitle - (size_t)used, - " · %s", badge); - } + int rc = 0; + if (S_ISDIR(st.st_mode)) { + scan_info si; + scan_dir(full, 1, max_depth, &si, archive_pages, ud); + if (!si.nav) continue; + rc = add_dir_entry(lib, name, full, &si); + } else if (S_ISREG(st.st_mode) && mr_path_is_archive(name)) { + int pages = archive_pages_at(archive_pages, ud, full); + if (pages <= 0) continue; /* 坏档/无图片页:不当书 */ + rc = add_archive_entry(lib, name, full, pages); + } else { + continue; } - if (lib_add(lib, &e) != 0) { + if (rc != 0) { closedir(d); mr_library_free(lib); return -1; @@ -244,6 +321,11 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) return lib->count; } +int mr_library_scan(mr_library *lib, const char *dir, int max_depth) +{ + return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL); +} + int mr_path_parent(const char *path, char *out, int outsz) { if (!path || !out || outsz <= 0) return -1; @@ -295,7 +377,23 @@ static int name_ptr_cmp(const void *pa, const void *pb) return mr_natural_cmp(a, b); } -int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) +/* 兄弟册判据(M11):目录=直接含图;普通文件=档案册且有图片页。 + * 两者同列,交给 name_ptr_cmp 自然序混排。 */ +static int sibling_is_book(const char *full, const struct stat *st, + mr_lib_archive_fn probe, void *ud) +{ + if (S_ISDIR(st->st_mode)) return mr_library_dir_has_images(full); + if (S_ISREG(st->st_mode)) { + const char *slash = strrchr(full, '/'); + const char *name = slash ? slash + 1 : full; + if (!mr_path_is_archive(name)) return 0; + return archive_pages_at(probe, ud, full) > 0; + } + return 0; +} + +int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, + mr_lib_archive_fn archive_pages, void *ud) { if (!out || outsz <= 0) return -1; out[0] = '\0'; @@ -329,8 +427,8 @@ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, parent, name) != 0) continue; struct stat st; - if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; - if (!mr_library_dir_has_images(full)) continue; /* 只算「一本书」,中间目录跳过 */ + if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode)) continue; + if (!sibling_is_book(full, &st, archive_pages, ud)) continue; if (n >= cap) { int nc = cap ? cap * 2 : 16; @@ -360,3 +458,8 @@ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) free(names); return rc; } + +int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) +{ + return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL); +} diff --git a/tuku/logic/library.h b/tuku/logic/library.h index e21268c..ba57c82 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -4,10 +4,12 @@ * (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化, * 本层只读它来拼副标题,宿主测试用配置存根顶替。) * 书库语义: - * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」; - * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含图的中间目录(可继续导航); - * - 行的 pages=该目录直接图片数;subdirs=其下含图子目录数(中间目录用于副标题); - * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover; + * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」;M11 起另列直接子文件里的 + * .cbz/.zip(档案册即一本书,与文件夹册同列自然序混排); + * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含书的中间目录(可继续导航); + * - 行的 pages=该目录直接图片数(档案册=档内图片页数);subdirs=其下含图子目录数; + * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover + * (档案册留空,UI 解压档内首图); * - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。 * * 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录 @@ -25,13 +27,13 @@ extern "C" { #define MR_LIB_MAX_DEPTH 8 typedef struct mr_library_entry { - char name[MR_NAME_MAX]; /* 目录名(行主标题) */ + char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */ char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */ - char cover[MR_PATH_MAX]; /* 缩略图源图(空=none) */ - char subtitle[48]; /* 预先格式化好的 "N pages" / "N folders" */ - int pages; /* 本目录直接图片数 */ + char cover[MR_PATH_MAX]; /* 缩略图源图(空=none;档案册由 UI 解压首图) */ + char subtitle[48]; /* 预先格式化好的 "N pages" / "N books" / "N folders" */ + int pages; /* 本目录直接图片数(档案册=档内图片页数) */ int subdirs; /* 本目录下含图子目录数 */ - int has_images; /* 1=本目录直接含图(点入进 reader) */ + int has_images; /* 1=可直接阅读(直接含图 或 档案册) */ } mr_library_entry; typedef struct mr_library { @@ -43,6 +45,10 @@ typedef struct mr_library { /* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ int mr_path_is_image(const char *name); +/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。 + * 注意这只是路径判据;能不能当书读还要看档案里有没有图片页(探测回调)。 */ +int mr_path_is_archive(const char *name); + /* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前; * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ int mr_natural_cmp(const char *a, const char *b); @@ -52,6 +58,23 @@ int mr_natural_cmp(const char *a, const char *b); * max_depth<=0 或超过 MR_LIB_MAX_DEPTH 时按上限处理。 */ int mr_library_scan(mr_library *lib, const char *dir, int max_depth); +/* ============================================================ */ +/* 档案册(M11):.cbz/.zip 当一本书,与文件夹册同列 */ +/* ------------------------------------------------------------ + * logic 层不依赖 zip 容器(保持宿主裸测/链接面干净):档案册探测由 UI 注入。 + * archive_pages(path, ud) 对普通文件返回图片页数(>0=是一本书),0=不是/坏档。 + * 传 NULL 时行为与 mr_library_scan 完全一致(纯目录书库),M7 语义不变。 + * + * 档案册行的口径:name=文件名(含扩展名)、path=档案文件路径、pages=档内页数、 + * has_images=1(点入直接阅读)、cover 留空(缩略图由 UI 解压首图生成)、 + * 副标题 "N pages"、续读角标同文件夹册(按档案路径记)。 + * 只含档案册的子目录算「可导航」(nav),副标题写 "N books"。 + * ============================================================ */ +typedef int (*mr_lib_archive_fn)(const char *path, void *ud); + +int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, + mr_lib_archive_fn archive_pages, void *ud); + /* 释放条目数组并清零(可重复调用)。 */ void mr_library_free(mr_library *lib); @@ -63,12 +86,20 @@ int mr_path_parent(const char *path, char *out, int outsz); * 1=有;0=没有/不可读。 */ int mr_library_dir_has_images(const char *dir); -/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 +/* 册间衔接(M7):同父目录下按书库自然序排列的「可直接阅读」兄弟册。 * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 - * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 */ + * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 + * 只算文件夹册(直接含图目录)——M7 口径不变。 */ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); +/* 册间衔接 + 档案册(M11):兄弟表 = 「直接含图目录」∪「含图片页的 .cbz/.zip」, + * 两者同列按名称自然序混排(mission 基线:跨册衔接按同父目录自然序)。 + * 因此末页前进可以从文件夹册进到同目录的档案册,反之亦然。 + * archive_pages 传 NULL 时与 mr_book_adjacent 完全等价。 */ +int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, + mr_lib_archive_fn archive_pages, void *ud); + #ifdef __cplusplus } #endif diff --git a/tuku/logic/reader/archive_source.c b/tuku/logic/reader/archive_source.c new file mode 100644 index 0000000..159e891 --- /dev/null +++ b/tuku/logic/reader/archive_source.c @@ -0,0 +1,395 @@ +/* logic/reader/archive_source.c — 档案册(CBZ/zip 当一本书):页源契约的第二种实现。 + * + * 契约、条目过滤与缓存纪律见 page_source.h / page_cache.h。实现要点: + * - 页表在 init 时一次建好(mz_open 只读 central directory;只看名字,不解压); + * - open(i) 才解压单条目:mz_read_alloc → page_cache 落盘 → 注入的解码回调按路径解码。 + * 因此翻页/条漫两套消费代码与目录册完全一致(meta 走原始像素尺寸,open 走解码); + * - meta(i) 按需解压该页到内存只读图片头(不落缓存),结果缓存在 meta_w/meta_h; + * - 单个 mr_zip_book 的 mz_read* 由自带的互斥量串行化(解码线程与主线程 meta 会碰面)。 + * + * 线程:count/page_name 只读页表,可任意线程;open 可由解码线程调用;meta 按 M6 + * 用法在主线程调用(条漫排版/当前页几何)。两个 mr_zip_book 实例互不影响, + * 解压缓存(page_cache)是进程级共享的。 + */ +#include "logic/reader/page_source.h" +#include "logic/reader/page_cache.h" +#include "logic/zip/zip.h" + +#include +#include +#include +#include +#include + +/* ============================================================ */ +/* 条目过滤:图片后缀 + 路径安全 + 非隐藏 + 非目录 */ +/* ============================================================ */ +static int has_control(const char *name, unsigned len) +{ + for (unsigned i = 0; i < len; i++) { + unsigned char c = (unsigned char)name[i]; + if (c < 0x20 || c == 0x7F) return 1; + } + return 0; +} + +/* 逐段检查:拒绝 ".." 段与隐藏段("._resource" 之类不参与成册)。 */ +static int components_safe(const char *name) +{ + const char *p = name; + while (*p) { + const char *end = strchr(p, '/'); + size_t n = end ? (size_t)(end - p) : strlen(p); + if (n == 2 && p[0] == '.' && p[1] == '.') return 0; + if (n > 0 && p[0] == '.') return 0; + if (!end) break; + p = end + 1; + } + return 1; +} + +int mr_zip_entry_is_page(const char *name, unsigned name_len) +{ + if (!name || name_len == 0 || name_len >= MR_ZIP_MAX_ENTRY_NAME) return 0; + if (strlen(name) != name_len) return 0; /* 内嵌 NUL:拒绝 */ + if (name[0] == '/' || name[0] == '\\') return 0; /* 绝对路径 */ + if (name_len >= 2 && name[1] == ':') return 0; /* Windows 盘符 */ + if (strchr(name, '\\')) return 0; /* 反斜杠分隔符:一律拒绝 */ + if (has_control(name, name_len)) return 0; + if (name[name_len - 1] == '/') return 0; /* 目录条目 */ + if (!components_safe(name)) return 0; + return mr_path_is_image(name); +} + +/* 页表排序:跨子目录按完整条目名自然序(中文按 UTF-8 字节序,数字段按数值)。 */ +typedef struct page_ref { + const char *name; + int eidx; +} page_ref; + +static int page_ref_cmp(const void *pa, const void *pb) +{ + const page_ref *a = (const page_ref *)pa; + const page_ref *b = (const page_ref *)pb; + int c = mr_natural_cmp(a->name, b->name); + if (c != 0) return c; + return a->eidx < b->eidx ? -1 : (a->eidx > b->eidx ? 1 : 0); +} + +/* ============================================================ */ +/* 生命周期 */ +/* ============================================================ */ +static int entry_is_page(const mz_entry *e) +{ + if (!e || mz_entry_is_dir(e)) return 0; + if (e->flags & 0x0001u) return 0; /* 加密条目:读不了,不列 */ + if (!mr_zip_entry_is_page(e->name, e->name_len)) return 0; + if (e->uncomp_size == 0) return 0; + if (e->uncomp_size > (uint64_t)MR_ZIP_PAGE_MAX_BYTES) return 0; /* zip 炸弹 */ + return 1; +} + +void mr_zip_book_free(mr_zip_book *zb) +{ + if (!zb) return; + if (zb->arch) { + mz_close((mz_archive *)zb->arch); + zb->arch = NULL; + } + if (zb->lock) { + pthread_mutex_destroy((pthread_mutex_t *)zb->lock); + free(zb->lock); + zb->lock = NULL; + } + free(zb->order); + zb->order = NULL; + free(zb->meta_w); + zb->meta_w = NULL; + free(zb->meta_h); + zb->meta_h = NULL; + zb->count = 0; +} + +int mr_zip_book_init(mr_zip_book *zb, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud) +{ + if (!zb) return -1; + memset(zb, 0, sizeof *zb); + if (!path || !path[0]) return -1; + if (strlen(path) >= sizeof zb->path) return -1; + if (maxdim <= 0) maxdim = 720; + + mz_archive *z = NULL; + if (mz_open(path, &z) != MZ_OK || !z) return -1; + + pthread_mutex_t *lk = (pthread_mutex_t *)malloc(sizeof *lk); + if (!lk) { + mz_close(z); + return -1; + } + pthread_mutex_init(lk, NULL); + + snprintf(zb->path, sizeof zb->path, "%s", path); + zb->arch = z; + zb->lock = lk; + zb->maxdim = maxdim; + zb->load = load; + zb->unload = unload; + zb->ud = ud; + + struct stat st; + if (stat(path, &st) == 0) { + zb->mtime = (long long)st.st_mtime; + zb->size = (long long)st.st_size; + } + + int n = mz_count(z); + page_ref *refs = NULL; + int nref = 0, cap = 0; + for (int i = 0; i < n && nref < MR_BOOK_MAX_PAGES; i++) { + const mz_entry *e = mz_entry_at(z, i); + if (!entry_is_page(e)) continue; + if (nref >= cap) { + int nc = cap ? cap * 2 : 32; + page_ref *nr = (page_ref *)realloc(refs, (size_t)nc * sizeof *nr); + if (!nr) { + free(refs); + mr_zip_book_free(zb); + return -1; + } + refs = nr; + cap = nc; + } + refs[nref].name = e->name; + refs[nref].eidx = i; + nref++; + } + if (nref > 1) qsort(refs, (size_t)nref, sizeof *refs, page_ref_cmp); + if (nref > 0) { + zb->order = (int *)malloc((size_t)nref * sizeof *zb->order); + if (!zb->order) { + free(refs); + mr_zip_book_free(zb); + return -1; + } + for (int i = 0; i < nref; i++) zb->order[i] = refs[i].eidx; + } + free(refs); + zb->count = nref; + return nref; +} + +/* ============================================================ */ +/* 页 → 缓存文件 */ +/* ============================================================ */ +static const mz_entry *page_entry(mr_zip_book *zb, int index) +{ + if (!zb || !zb->arch || !zb->order) return NULL; + if (index < 0 || index >= zb->count) return NULL; + return mz_entry_at((mz_archive *)zb->arch, zb->order[index]); +} + +int mr_zip_book_page_path(mr_zip_book *zb, int index, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + + const mz_entry *e = page_entry(zb, index); + if (!e) return -1; + + char path[MR_CACHE_FILE_MAX]; + if (mr_cache_path(zb->path, zb->mtime, zb->size, e->name, index, + path, (int)sizeof path) != 0) + return -1; + if (mr_cache_touch(path)) { + snprintf(out, (size_t)outsz, "%s", path); + return 0; + } + + pthread_mutex_lock((pthread_mutex_t *)zb->lock); + void *buf = NULL; + size_t len = 0; + int rc = mz_read_alloc((mz_archive *)zb->arch, e, &buf, &len); + pthread_mutex_unlock((pthread_mutex_t *)zb->lock); + if (rc != MZ_OK || !buf) { + free(buf); + return -1; + } + int ok = mr_cache_store(path, buf, len); + free(buf); + if (!ok) return -1; + snprintf(out, (size_t)outsz, "%s", path); + return 0; +} + +void mr_zip_book_page_name(const mr_zip_book *zb, int index, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + out[0] = '\0'; + const mz_entry *e = page_entry((mr_zip_book *)zb, index); + if (!e) return; + snprintf(out, (size_t)outsz, "%s", e->name); +} + +/* ============================================================ */ +/* 书库辅助:数页 / 取封面 */ +/* ============================================================ */ +int mr_zip_count_pages(const char *path) +{ + if (!path || !path[0]) return 0; + mz_archive *z = NULL; + if (mz_open(path, &z) != MZ_OK || !z) return 0; + int n = mz_count(z), pages = 0; + for (int i = 0; i < n && pages < MR_BOOK_MAX_PAGES; i++) + if (entry_is_page(mz_entry_at(z, i))) pages++; + mz_close(z); + return pages; +} + +int mr_zip_cover_path(const char *path, char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + if (!path || !path[0]) return 0; + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 0, NULL, NULL, NULL); + int ok = 0; + if (n > 0) ok = (mr_zip_book_page_path(&zb, 0, out, outsz) == 0); + mr_zip_book_free(&zb); + return ok; +} + +/* ============================================================ */ +/* mr_book_ops 适配器(档案册) */ +/* ============================================================ */ +static int zip_ops_count(void *ctx) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + return zb ? zb->count : 0; +} + +/* meta 按需解压头探测;结果缓存,每页只解压一次。主线程调用(M6 用法)。 */ +static int zip_ops_meta(void *ctx, int index, int *w, int *h) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (w) *w = 0; + if (h) *h = 0; + if (!zb || index < 0 || index >= zb->count) return 0; + + if (!zb->meta_w) { + int *mw = (int *)calloc((size_t)zb->count, sizeof *mw); + int *mh = (int *)calloc((size_t)zb->count, sizeof *mh); + if (!mw || !mh) { + free(mw); + free(mh); + return 0; + } + zb->meta_w = mw; + zb->meta_h = mh; + } + if (zb->meta_w[index] > 0 && zb->meta_h[index] > 0) { + if (w) *w = zb->meta_w[index]; + if (h) *h = zb->meta_h[index]; + return 1; + } + + const mz_entry *e = page_entry(zb, index); + if (!e) return 0; + + pthread_mutex_lock((pthread_mutex_t *)zb->lock); + void *buf = NULL; + size_t len = 0; + int rc = mz_read_alloc((mz_archive *)zb->arch, e, &buf, &len); + pthread_mutex_unlock((pthread_mutex_t *)zb->lock); + if (rc != MZ_OK || !buf) { + free(buf); + return 0; + } + int pw = 0, ph = 0; + int known = mr_probe_size_mem(buf, len, &pw, &ph); + free(buf); + if (!known) return 0; + + zb->meta_w[index] = pw; + zb->meta_h[index] = ph; + if (w) *w = pw; + if (h) *h = ph; + return 1; +} + +static meow_img *zip_ops_open(void *ctx, int index) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (!zb || !zb->load) return NULL; + char path[MR_CACHE_FILE_MAX]; + if (mr_zip_book_page_path(zb, index, path, (int)sizeof path) != 0) return NULL; + return zb->load(path, zb->ud, zb->maxdim); +} + +static void zip_ops_close(void *ctx, meow_img *img) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (zb && zb->unload && img) zb->unload(img); +} + +static void zip_ops_destroy(void *ctx) +{ + mr_zip_book *zb = (mr_zip_book *)ctx; + if (zb) mr_zip_book_free(zb); +} + +static const mr_book_ops g_zip_ops = { + zip_ops_count, zip_ops_meta, zip_ops_open, zip_ops_close, zip_ops_destroy, +}; + +void mr_zip_book_bind(mr_book *b, mr_zip_book *zb) +{ + if (!b) return; + b->ops = zb ? &g_zip_ops : NULL; + b->ctx = zb; +} + +/* ============================================================ */ +/* 统一开册 */ +/* ============================================================ */ +int mr_book_open_auto(mr_book *b, mr_book_handle *h, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud) +{ + if (b) { + b->ops = NULL; + b->ctx = NULL; + } + if (!b || !h) return -1; + memset(h, 0, sizeof *h); + if (!path || !path[0]) return -1; + + int n; + /* 目录名也可能以 .cbz 结尾(罕见):只有普通文件才当档案册。 */ + int is_archive = mr_path_is_archive(path); + if (is_archive) { + struct stat st; + if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) is_archive = 0; + } + if (is_archive) { + n = mr_zip_book_init(&h->u.zip, path, maxdim, load, unload, ud); + if (n < 0) return -1; + h->kind = MR_BOOK_KIND_ZIP; + if (n > 0) mr_zip_book_bind(b, &h->u.zip); + } else { + n = mr_dir_book_init(&h->u.dir, path, maxdim, load, unload, ud); + if (n < 0) return -1; + h->kind = MR_BOOK_KIND_DIR; + if (n > 0) mr_dir_book_bind(b, &h->u.dir); + } + return n; +} + +void mr_book_close_auto(mr_book *b, mr_book_handle *h) +{ + if (b) mr_book_destroy(b); + if (!h) return; + if (h->kind == MR_BOOK_KIND_ZIP) mr_zip_book_free(&h->u.zip); + else if (h->kind == MR_BOOK_KIND_DIR) mr_dir_book_free(&h->u.dir); + memset(h, 0, sizeof *h); +} diff --git a/tuku/logic/reader/page_cache.c b/tuku/logic/reader/page_cache.c new file mode 100644 index 0000000..4cf403b --- /dev/null +++ b/tuku/logic/reader/page_cache.c @@ -0,0 +1,366 @@ +/* logic/reader/page_cache.c — 解压页磁盘缓存实现,见 page_cache.h 的纪律说明。 */ +#include "logic/reader/page_cache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct cache_entry { + char path[MR_CACHE_FILE_MAX]; + long long size; + unsigned long long used; /* LRU 计数(越大越新) */ +} cache_entry; + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static char g_dir[MR_CACHE_DIR_MAX]; +static int g_ready; +static cache_entry g_idx[MR_CACHE_MAX_PAGES]; +static int g_n; +static long long g_bytes; +static unsigned long long g_tick; + +/* ============================================================ */ +/* 目录推导 */ +/* ============================================================ */ +int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz) +{ + if (!data_dir || !data_dir[0] || !out || outsz <= 0) return -1; + + size_t n = strlen(data_dir); + while (n > 1 && data_dir[n - 1] == '/') n--; + if (n >= (size_t)outsz) return -1; + memcpy(out, data_dir, n); + out[n] = '\0'; + + /* 末段是 "data" 时换成 "cache"(数据目录 → 同级缓存目录)。 */ + char *slash = strrchr(out, '/'); + if (slash && strcmp(slash + 1, "data") == 0) *slash = '\0'; + + n = strlen(out); + if (n == 0) { + if (outsz < 7) return -1; + memcpy(out, "/cache", 7); + return 0; + } + int w = snprintf(out + n, (size_t)(outsz - (int)n), "/cache"); + if (w <= 0 || w >= outsz - (int)n) { + out[0] = '\0'; + return -1; + } + return 0; +} + +int mr_cache_is_page_name(const char *name) +{ + return name && strncmp(name, MR_CACHE_PREFIX, strlen(MR_CACHE_PREFIX)) == 0; +} + +const char *mr_cache_dir(void) +{ + return g_ready ? g_dir : ""; +} + +int mr_cache_ready(void) +{ + return g_ready; +} + +/* ============================================================ */ +/* 索引(调用方持锁) */ +/* ============================================================ */ +static int idx_find_locked(const char *path) +{ + for (int i = 0; i < g_n; i++) + if (strcmp(g_idx[i].path, path) == 0) return i; + return -1; +} + +static void idx_remove_locked(int i) +{ + if (i < 0 || i >= g_n) return; + g_bytes -= g_idx[i].size; + g_idx[i] = g_idx[g_n - 1]; + g_n--; +} + +static void idx_evict_locked(void) +{ + if (g_n <= 0) return; + int oldest = 0; + for (int i = 1; i < g_n; i++) + if (g_idx[i].used < g_idx[oldest].used) oldest = i; + unlink(g_idx[oldest].path); + idx_remove_locked(oldest); +} + +/* 腾出 len 字节:页数或字节先到上限就淘汰最旧。单条 len 本身超过 + * MR_CACHE_MAX_BYTES 时会清空其它条目、让这一条独占(文档化的一次性溢出, + * 上限由页源单页 64MiB 约束)——大页仍可读,不因预算拒绝落缓存。 */ +static void make_room_locked(long long len) +{ + while (g_n > 0 && (g_n >= MR_CACHE_MAX_PAGES || + g_bytes + len > MR_CACHE_MAX_BYTES)) + idx_evict_locked(); +} + +static void idx_add_locked(const char *path, long long size) +{ + if (g_n >= MR_CACHE_MAX_PAGES) return; + cache_entry *e = &g_idx[g_n++]; + snprintf(e->path, sizeof e->path, "%s", path); + e->size = size; + g_tick++; + e->used = g_tick; + g_bytes += size; +} + +/* 上一进程遗留的解压页:索引在内存里,磁盘上的 mrpage_* 全是孤儿。 */ +static void clean_orphans_locked(void) +{ + DIR *d = opendir(g_dir); + if (!d) return; + size_t plen = strlen(MR_CACHE_PREFIX); + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strncmp(de->d_name, MR_CACHE_PREFIX, plen) != 0) continue; + char full[MR_CACHE_FILE_MAX]; + int w = snprintf(full, sizeof full, "%s/%s", g_dir, de->d_name); + if (w > 0 && w < (int)sizeof full) unlink(full); + } + closedir(d); +} + +/* ============================================================ */ +/* 生命周期 */ +/* ============================================================ */ +/* 逐级建目录(数据目录的父级可能还不存在;只建到缓存目录为止)。 */ +static int mkdir_p(const char *path) +{ + char tmp[MR_CACHE_DIR_MAX]; + size_t n = strlen(path); + if (n == 0 || n >= sizeof tmp) return -1; + memcpy(tmp, path, n + 1); + for (size_t i = 1; i <= n; i++) { + if (tmp[i] != '/' && tmp[i] != '\0') continue; + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0 && errno != EEXIST) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + return 0; +} + +int mr_cache_init(const char *data_dir) +{ + char dir[MR_CACHE_DIR_MAX]; + if (mr_cache_dir_from_data(data_dir, dir, (int)sizeof dir) != 0) return -1; + + pthread_mutex_lock(&g_lock); + if (g_ready && strcmp(g_dir, dir) == 0) { + pthread_mutex_unlock(&g_lock); + return 0; + } + + g_ready = 0; + g_n = 0; + g_bytes = 0; + g_dir[0] = '\0'; + + if (mkdir_p(dir) != 0) { + pthread_mutex_unlock(&g_lock); + return -1; + } + struct stat st; + if (stat(dir, &st) != 0 || !S_ISDIR(st.st_mode)) { + pthread_mutex_unlock(&g_lock); + return -1; + } + snprintf(g_dir, sizeof g_dir, "%s", dir); + clean_orphans_locked(); + g_tick = 0; + g_ready = 1; + pthread_mutex_unlock(&g_lock); + return 0; +} + +void mr_cache_shutdown(void) +{ + pthread_mutex_lock(&g_lock); + g_ready = 0; + g_n = 0; + g_bytes = 0; + g_tick = 0; + g_dir[0] = '\0'; + pthread_mutex_unlock(&g_lock); +} + +/* ============================================================ */ +/* 键 / 命中 / 写入 */ +/* ============================================================ */ +static unsigned long long hash_bytes(unsigned long long h, const void *p, size_t n) +{ + const unsigned char *b = (const unsigned char *)p; + for (size_t i = 0; i < n; i++) { + h ^= (unsigned long long)b[i]; + h *= 1099511628211ULL; + } + return h; +} + +static unsigned long long cache_hash(const char *archive, long long mtime, + long long size, const char *entry, int page) +{ + char num[64]; + unsigned long long h = 1469598103934665603ULL; + h = hash_bytes(h, archive, strlen(archive) + 1); + int n = snprintf(num, sizeof num, "%lld", mtime); + h = hash_bytes(h, num, (size_t)n + 1); + n = snprintf(num, sizeof num, "%lld", size); + h = hash_bytes(h, num, (size_t)n + 1); + h = hash_bytes(h, entry, strlen(entry) + 1); + n = snprintf(num, sizeof num, "%d", page); + h = hash_bytes(h, num, (size_t)n + 1); + return h; +} + +/* 保留图片扩展名:解码入口按 .jpg/.jpeg 区分「解码期缩解」路径。 */ +static void entry_ext(const char *entry, char *out, int outsz) +{ + const char *dot = entry ? strrchr(entry, '.') : NULL; + const char *ext = dot ? dot + 1 : ""; + if ((strcasecmp(ext, "jpg") == 0) || (strcasecmp(ext, "jpeg") == 0) || + (strcasecmp(ext, "png") == 0) || (strcasecmp(ext, "bmp") == 0)) { + int i = 0; + for (; ext[i] && i < outsz - 1; i++) out[i] = (char)tolower((unsigned char)ext[i]); + out[i] = '\0'; + return; + } + snprintf(out, (size_t)outsz, "img"); +} + +int mr_cache_path(const char *archive, long long mtime, long long size, + const char *entry, int page_index, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!archive || !archive[0] || !entry || !entry[0]) return -1; + + char ext[8]; + entry_ext(entry, ext, (int)sizeof ext); + + pthread_mutex_lock(&g_lock); + if (!g_ready) { + pthread_mutex_unlock(&g_lock); + return -1; + } + unsigned long long h = cache_hash(archive, mtime, size, entry, page_index); + int n = snprintf(out, (size_t)outsz, "%s/%s%016llx.%s", g_dir, MR_CACHE_PREFIX, h, ext); + pthread_mutex_unlock(&g_lock); + if (n <= 0 || n >= outsz) { + out[0] = '\0'; + return -1; + } + return 0; +} + +int mr_cache_touch(const char *path) +{ + if (!path || !path[0]) return 0; + + pthread_mutex_lock(&g_lock); + if (!g_ready) { + pthread_mutex_unlock(&g_lock); + return 0; + } + int i = idx_find_locked(path); + /* 命中也要 stat 校验(P2-1 纵深防御):外部清理、掉电残留、或别的 LRU 误删 + * 都会让索引指向不存在的文件;假命中会让页源返回死路径且永不自愈。缺失或 + * 尺寸不符就从索引移除、按未命中处理,让调用方重新解压。 */ + struct stat st; + int exists = stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0; + if (i >= 0) { + if (!exists || (long long)st.st_size != g_idx[i].size) { + idx_remove_locked(i); + pthread_mutex_unlock(&g_lock); + return 0; + } + g_tick++; + g_idx[i].used = g_tick; + pthread_mutex_unlock(&g_lock); + return 1; + } + /* 索引里没有(外部写入/索引满):磁盘上有就算命中,能装进索引就装。 */ + if (exists && g_n < MR_CACHE_MAX_PAGES && strlen(path) < MR_CACHE_FILE_MAX) + idx_add_locked(path, (long long)st.st_size); + pthread_mutex_unlock(&g_lock); + return exists ? 1 : 0; +} + +int mr_cache_store(const char *path, const void *data, size_t len) +{ + if (!path || !path[0] || !data || len == 0) return 0; + if (strlen(path) >= MR_CACHE_FILE_MAX) return 0; + + char tmp[MR_CACHE_FILE_MAX + 8]; + int w = snprintf(tmp, sizeof tmp, "%s.part", path); + if (w <= 0 || w >= (int)sizeof tmp) return 0; + + pthread_mutex_lock(&g_lock); + if (!g_ready) { + pthread_mutex_unlock(&g_lock); + return 0; + } + int i = idx_find_locked(path); + if (i >= 0) idx_remove_locked(i); + make_room_locked((long long)len); + pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞缩略图线程 */ + + FILE *f = fopen(tmp, "wb"); + int ok = f && fwrite(data, 1, len, f) == len; + if (f && fclose(f) != 0) ok = 0; + if (!ok) { + unlink(tmp); + return 0; + } + if (rename(tmp, path) != 0) { + unlink(tmp); + return 0; + } + + pthread_mutex_lock(&g_lock); + if (g_ready) { + i = idx_find_locked(path); + if (i >= 0) idx_remove_locked(i); + make_room_locked((long long)len); /* 写盘期间可能被别的线程填满 */ + idx_add_locked(path, (long long)len); + g_tick++; + } + pthread_mutex_unlock(&g_lock); + return 1; +} + +int mr_cache_count(void) +{ + pthread_mutex_lock(&g_lock); + int n = g_n; + pthread_mutex_unlock(&g_lock); + return n; +} + +long long mr_cache_bytes(void) +{ + pthread_mutex_lock(&g_lock); + long long b = g_bytes; + pthread_mutex_unlock(&g_lock); + return b; +} diff --git a/tuku/logic/reader/page_cache.h b/tuku/logic/reader/page_cache.h new file mode 100644 index 0000000..e0c4bda --- /dev/null +++ b/tuku/logic/reader/page_cache.h @@ -0,0 +1,76 @@ +/* logic/reader/page_cache.h — 档案册解压页的磁盘缓存(纯 C,不依赖 SDK)。 + * + * 为什么需要磁盘缓存:SDK 的图像解码入口只吃文件路径(meow_img_load_file_dim), + * 而 zip 里的页是压缩流。档案页源在 open(i) 时把条目解压成缓存文件,再用同一个 + * 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。 + * + * 目录与上限(纪律): + * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data, + * 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响); + * - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数); + * - **单条目溢出**:某页解压后本身就超过 32MiB 时允许一次性溢出——只保留该条、 + * 其它条目全部淘汰,占用上限受页源单页上限 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束。 + * 这样超大页仍可阅读,不会因为预算拒绝落缓存而变成永久空白页; + * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页) + * (注意:缩略图 LRU 也必须在同一目录里跳过 mrpage_*,否则会互删); + * - 写入走 ".part" + rename,进程崩溃不会留下半个缓存文件。 + * + * 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或 + * 同名条目重复出现时不会命中旧内容。文件名保留图片扩展名(解码入口按扩展名区分 + * JPEG 的「解码期缩解」路径)。 + * + * 线程:内部互斥量;可在解码线程/缩略图线程/主线程并发调用。 + * 纯 C:宿主测试可直接编译(见 tuku/test/test_cbz.c)。 + */ +#ifndef MR_PAGE_CACHE_H +#define MR_PAGE_CACHE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_CACHE_MAX_PAGES 64 +#define MR_CACHE_MAX_BYTES (32LL * 1024 * 1024) +#define MR_CACHE_PREFIX "mrpage_" +#define MR_CACHE_DIR_MAX 200 +#define MR_CACHE_FILE_MAX 256 + +/* 数据目录 → 缓存目录(纯字符串):末尾的 "data" 段替换为 "cache"。 + * 例:/…/MeowData/os.meow.picbrowser/data → /…/MeowData/os.meow.picbrowser/cache。 + * 成功 0;参数非法/缓冲不足 -1。 */ +int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz); + +/* 文件名是否属于解压页缓存(mrpage_ 前缀)。缩略图 LRU 与启动清孤儿共用这一判据, + * 保证两套缓存同目录时互不误删(P2-1)。1=解压页缓存。 */ +int mr_cache_is_page_name(const char *name); + +/* 建立/切换缓存目录并清孤儿。0=可用;-1=不可用(之后 store/touch 均失败)。 + * 同一目录重复调用为无操作(不会删掉正在用的页)。 */ +int mr_cache_init(const char *data_dir); + +/* 释放索引(不删文件);测试用。可重复调用。 */ +void mr_cache_shutdown(void); + +int mr_cache_ready(void); +const char *mr_cache_dir(void); /* 未初始化返回空串 */ + +/* 计算某页缓存文件路径(不碰磁盘)。扩展名取自 entry 名(未知时 .img)。 */ +int mr_cache_path(const char *archive, long long mtime, long long size, + const char *entry, int page_index, char *out, int outsz); + +/* 缓存命中:文件在索引里(或磁盘上存在)则刷新 LRU 并返回 1,否则 0。 */ +int mr_cache_touch(const char *path); + +/* 写入缓存并纳入 LRU;1=成功。len==0 视为失败(空页没有解码价值)。 */ +int mr_cache_store(const char *path, const void *data, size_t len); + +/* 观测(宿主测试断言 LRU 纪律用):当前索引页数 / 字节数。 */ +int mr_cache_count(void); +long long mr_cache_bytes(void); + +#ifdef __cplusplus +} +#endif +#endif /* MR_PAGE_CACHE_H */ diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c index f33297d..abab520 100644 --- a/tuku/logic/reader/page_source.c +++ b/tuku/logic/reader/page_source.c @@ -146,16 +146,56 @@ int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim, /* ============================================================ */ /* 图片头探测(不解码像素) */ -/* ============================================================ */ +/* ------------------------------------------------------------ + * 同一套解析同时服务「文件」(目录册,流式读不占内存)和「内存缓冲」 + * (档案册:page_cache 解压后直接探测,不必再落盘读文件)。 + * ============================================================ */ +typedef struct probe_src { + const unsigned char *mem; /* 非 NULL = 内存源 */ + size_t len, pos; + FILE *f; /* mem == NULL 时用 */ +} probe_src; + +static int ps_read(probe_src *s, void *buf, size_t n) +{ + if (s->mem) { + if (n > s->len - s->pos) return 0; + memcpy(buf, s->mem + s->pos, n); + s->pos += n; + return 1; + } + return fread(buf, 1, n, s->f) == n; +} + +static int ps_getc(probe_src *s) +{ + if (s->mem) { + if (s->pos >= s->len) return EOF; + return s->mem[s->pos++]; + } + return fgetc(s->f); +} + +static int ps_skip(probe_src *s, long n) +{ + if (n < 0) return 0; + if (s->mem) { + if ((size_t)n > s->len - s->pos) return 0; + s->pos += (size_t)n; + return 1; + } + return fseek(s->f, n, SEEK_CUR) == 0; +} + static unsigned probe_be32(const unsigned char *p) { return ((unsigned)p[0] << 24) | ((unsigned)p[1] << 16) | ((unsigned)p[2] << 8) | p[3]; } -static int probe_png(FILE *f, int *w, int *h) +static int probe_png(probe_src *s, int *w, int *h) { unsigned char hdr[24]; - if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + if (!ps_read(s, hdr, sizeof hdr)) return 0; static const unsigned char sig[8] = { 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }; if (memcmp(hdr, sig, 8) != 0 || memcmp(hdr + 12, "IHDR", 4) != 0) return 0; unsigned ww = probe_be32(hdr + 16), hh = probe_be32(hdr + 20); @@ -165,27 +205,27 @@ static int probe_png(FILE *f, int *w, int *h) return 1; } -static int probe_jpeg(FILE *f, int *w, int *h) +static int probe_jpeg(probe_src *s, int *w, int *h) { unsigned char b[2]; - if (fread(b, 1, 2, f) != 2 || b[0] != 0xFF || b[1] != 0xD8) return 0; + if (!ps_read(s, b, 2) || b[0] != 0xFF || b[1] != 0xD8) return 0; for (;;) { - int c = fgetc(f); + int c = ps_getc(s); if (c == EOF) return 0; if (c != 0xFF) continue; - int m = fgetc(f); - while (m == 0xFF) m = fgetc(f); /* 填充字节 */ + int m = ps_getc(s); + while (m == 0xFF) m = ps_getc(s); /* 填充字节 */ if (m == EOF) return 0; if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) continue; /* 无长度段 */ if (m == 0xD9 || m == 0xDA) return 0; /* EOI/SOS 前没等到 SOF */ unsigned char lb[2]; - if (fread(lb, 1, 2, f) != 2) return 0; + if (!ps_read(s, lb, 2)) return 0; int len = ((int)lb[0] << 8) | lb[1]; if (len < 2) return 0; int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC; if (is_sof) { unsigned char so[5]; - if (len < 7 || fread(so, 1, 5, f) != 5) return 0; + if (len < 7 || !ps_read(s, so, 5)) return 0; int hh = ((int)so[1] << 8) | so[2]; int ww = ((int)so[3] << 8) | so[4]; if (ww <= 0 || hh <= 0) return 0; @@ -193,14 +233,14 @@ static int probe_jpeg(FILE *f, int *w, int *h) *h = hh; return 1; } - if (fseek(f, len - 2, SEEK_CUR) != 0) return 0; + if (!ps_skip(s, len - 2)) return 0; } } -static int probe_bmp(FILE *f, int *w, int *h) +static int probe_bmp(probe_src *s, int *w, int *h) { unsigned char hdr[26]; - if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + if (!ps_read(s, hdr, sizeof hdr)) return 0; if (hdr[0] != 'B' || hdr[1] != 'M') return 0; int ww = (int)((unsigned)hdr[18] | ((unsigned)hdr[19] << 8) | ((unsigned)hdr[20] << 16) | ((unsigned)hdr[21] << 24)); @@ -213,6 +253,32 @@ static int probe_bmp(FILE *f, int *w, int *h) return 1; } +static int probe_any(probe_src *s, int *w, int *h) +{ + for (int pass = 0; pass < 3; pass++) { + s->pos = 0; + if (s->f) rewind(s->f); + int ok = (pass == 0) ? probe_png(s, w, h) + : (pass == 1) ? probe_jpeg(s, w, h) + : probe_bmp(s, w, h); + if (ok) return 1; + } + return 0; +} + +int mr_probe_size_mem(const void *buf, size_t len, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (!buf || len < 8 || !w || !h) return 0; + probe_src s = { (const unsigned char *)buf, len, 0, NULL }; + if (!probe_any(&s, w, h) || *w <= 0 || *h <= 0) { + *w = *h = 0; + return 0; + } + return 1; +} + int mr_probe_size(const char *path, int *w, int *h) { if (w) *w = 0; @@ -220,9 +286,8 @@ int mr_probe_size(const char *path, int *w, int *h) if (!path || !path[0] || !w || !h) return 0; FILE *f = fopen(path, "rb"); if (!f) return 0; - int ok = probe_png(f, w, h); - if (!ok) { rewind(f); ok = probe_jpeg(f, w, h); } - if (!ok) { rewind(f); ok = probe_bmp(f, w, h); } + probe_src s = { NULL, 0, 0, f }; + int ok = probe_any(&s, w, h); fclose(f); if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; } return ok; diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h index fe79858..b582278 100644 --- a/tuku/logic/reader/page_source.h +++ b/tuku/logic/reader/page_source.h @@ -18,6 +18,7 @@ #define MR_PAGE_SOURCE_H #include "logic/library.h" /* mr_path_is_image / mr_natural_cmp / MR_PATH_MAX / MR_NAME_MAX */ +#include /* size_t(mr_probe_size_mem) */ #ifdef __cplusplus extern "C" { @@ -84,6 +85,83 @@ void mr_dir_book_bind(mr_book *b, mr_dir_book *db); /* 纯 C 头解析:取图片原始像素尺寸(不解码像素)。 * PNG(IHDR)/ JPEG(SOF0-15)/ BMP(BITMAPINFOHEADER+)。1=已知,0=未知。 */ int mr_probe_size(const char *path, int *w, int *h); +/* 同上,但从内存缓冲探测(档案页解压后不需要落盘再读一遍)。 */ +int mr_probe_size_mem(const void *buf, size_t len, int *w, int *h); + +/* ============================================================ */ +/* 档案册(M11):.cbz / .zip 当一本书 */ +/* ------------------------------------------------------------ + * 目录册之外的第二种 mr_book 实现,契约完全相同。条目名按 zip 原始字节 + * 透传(通常 UTF-8):只收「图片后缀 + 路径安全 + 非隐藏 + 非目录」的条目, + * 跨子目录按 mr_natural_cmp 自然排序。open(i) 先把条目解压进 + * logic/reader/page_cache(磁盘 LRU),再用注入的解码回调按路径解码—— + * 与目录册走同一条 UI 解码/预算路径,翻页与条漫两种模式零改动复用。 + * + * 条目名永远不会被拼成文件系统路径(缓存文件名是哈希),路径安全过滤是 + * 纵深防御:拒绝绝对路径、'..' 段、反斜杠与内嵌 NUL。 + * ============================================================ */ +#define MR_ZIP_MAX_ENTRY_NAME 1024 /* 超长条目名跳过(防病态包) */ +#define MR_ZIP_PAGE_MAX_BYTES (64LL * 1024 * 1024) /* 单页解压上限(防 zip 炸弹) */ + +typedef struct mr_zip_book { + char path[MR_PATH_MAX]; + void *arch; /* mz_archive*(zip 层句柄;不对外暴露) */ + void *lock; /* pthread_mutex_t*:mz_read* 串行化(open/meta 共用) */ + int *order; /* 页 i → zip 条目下标(堆分配) */ + int *meta_w; /* 页原始宽/高(按需解压头探测;NULL=未探测) */ + int *meta_h; + int count; + int maxdim; + long long mtime; /* 档案 mtime/size:解压缓存键的一部分 */ + long long size; + mr_book_load_fn load; /* 可为 NULL(只取封面/页路径时不需要解码) */ + mr_book_unload_fn unload; + void *ud; +} mr_zip_book; + +/* 打开档案并建立页表。返回页数(>=0);参数非法/坏档/空档案不可读返回 -1 + * (zb 已清空、可安全 free)。0 页的合法档案返回 0(zb 仍持有打开的 archive)。 */ +int mr_zip_book_init(mr_zip_book *zb, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud); +void mr_zip_book_free(mr_zip_book *zb); +/* 把档案册绑定到通用 mr_book(zb 生命周期由调用方持有)。 */ +void mr_zip_book_bind(mr_book *b, mr_zip_book *zb); +/* 第 index 页在 zip 内的条目名(原始字节);越界输出空串。 */ +void mr_zip_book_page_name(const mr_zip_book *zb, int index, char *out, int outsz); +/* 确保第 index 页已解压到缓存并返回缓存文件路径;越界/坏档/缓存不可用 -1。 */ +int mr_zip_book_page_path(mr_zip_book *zb, int index, char *out, int outsz); + +/* 只数页(书库扫描/邻册判断用;不解压、不建缓存)。0=不是档案或没有图片页。 */ +int mr_zip_count_pages(const char *path); +/* 档案封面:档内自然序第一张图解到缓存,路径写入 out。1=成功。 */ +int mr_zip_cover_path(const char *path, char *out, int outsz); +/* 条目是否算一页(图片后缀 + 路径安全 + 非隐藏 + 非目录)。1=是。 */ +int mr_zip_entry_is_page(const char *name, unsigned name_len); + +/* ============================================================ */ +/* 统一开册(目录册 / 档案册) */ +/* ------------------------------------------------------------ + * 书库点入的行既可能是目录也可能是 .cbz/.zip;阅读器只调这里,不关心 provider。 + * 句柄由调用方提供(阅读器用静态变量),生命周期 = 打开到 close_auto。 + * ============================================================ */ +#define MR_BOOK_KIND_NONE 0 +#define MR_BOOK_KIND_DIR 1 +#define MR_BOOK_KIND_ZIP 2 + +typedef struct mr_book_handle { + int kind; + union { + mr_dir_book dir; + mr_zip_book zip; + } u; +} mr_book_handle; + +/* 按路径自动选 provider 并绑定 b。返回页数(>=0)或 -1(打不开)。 + * 页数为 0 时 b 不绑定(空册),句柄仍可安全 mr_book_close_auto。 */ +int mr_book_open_auto(mr_book *b, mr_book_handle *h, const char *path, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud); +/* 释放 provider(先 mr_book_destroy 再释放各自索引);可重复调用。 */ +void mr_book_close_auto(mr_book *b, mr_book_handle *h); #ifdef __cplusplus } diff --git a/tuku/test/make_cbz_samples.py b/tuku/test/make_cbz_samples.py new file mode 100755 index 0000000..90f467e --- /dev/null +++ b/tuku/test/make_cbz_samples.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""生成 tuku 档案册(CBZ)集成测试夹具:真实 zipfile 产物 + 定点字节修补。 + +用法: python3 make_cbz_samples.py + +产出: + /library/ 书库夹具(文件夹册 + 档案册混排) + bookA/001.png 002.png 文件夹册(2 页,PNG) + vol2.cbz 3 页:PNG(41x31) / PNG(40x30) / 中文名 PNG(12x34) + + 非图片、目录项、0 字节条目(都应被过滤) + Vol10.cbz 1 页 BMP(7x9),stored + box/box1.cbz box2.cbz 只含档案册的子目录(书库行应写 "2 books") + notabook.zip 只有文本 → 不是书 + emptypages.cbz 目录项 + 文本 → 不是书 + broken.cbz 非 zip 字节 → 不是书 + truncated.cbz vol2 砍半(EOCD 丢失) → 不是书 + .hidden.cbz 隐藏名 → 不参与 + /features/ + pathsafe.cbz 安全/不安全条目混排(只应收 2 个安全图片页) + cjk.cbz 中文名自然序(第1页 < 第2页 < 第10页) + big.cbz 80 页 8x8 PNG(解压缓存 LRU 上限用) + bomb.cbz CD 声明单页 96MiB(应被过滤,防 zip 炸弹) + /manifest.txt 每档案每条目一行:<档案相对路径>\t<条目名>\t\t + +图片内容都是结构合法的 PNG/BMP/JPEG 头(PNG 是真图),C 测试用假解码器, +只校验解压字节与 manifest 的 CRC/尺寸一致、以及 meta 探测出的原始尺寸。 +""" +import os +import struct +import sys +import zipfile +import zlib + +DATE = (2024, 1, 1, 0, 0, 0) +HUGE_PAGE_LEN = 33 * 1024 * 1024 # > 32MiB 预算:锁定「单条目一次性溢出」行为 + + +# ---------------------------------------------------------------- 图片构造 +def png_bytes(w, h, seed=0): + raw = bytearray() + for y in range(h): + raw.append(0) # filter: none + for x in range(w): + raw += bytes(((x * 7 + y * 13 + seed) & 0xFF, + (x * 3 + y * 5 + seed) & 0xFF, + (x + y + seed) & 0xFF)) + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) # 8-bit RGB + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b"")) + + +def bmp_bytes(w, h): + stride = (w * 3 + 3) & ~3 + px = bytes(((i * 11) & 0xFF) for i in range(stride * h)) + hdr = b"BM" + struct.pack("H", 16) + + b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + dqt = b"\xFF\xDB" + struct.pack(">H", 67) + b"\x00" + bytes(64) + sof = b"\xFF\xC0" + struct.pack(">HBHHB", 11, 8, h, w, 1) + b"\x01\x11\x00" + return b"\xFF\xD8" + app0 + dqt + sof + b"\xFF\xD9" + + +# ---------------------------------------------------------------- zip 构造 +def write_zip(path, entries): + """entries: [(name, data, zipfile 压缩方式)]""" + with zipfile.ZipFile(path, "w") as z: + for name, data, method in entries: + zi = zipfile.ZipInfo(name, DATE) + zi.compress_type = method + z.writestr(zi, data) + + +def find_cd_record(data, name): + eocd = data.rfind(b"PK\x05\x06") + if eocd < 0: + raise SystemExit("generator: no EOCD") + total, _cd_size, cd_off = struct.unpack_from(" 64MiB 上限 + with open(bomb, "wb") as f: + f.write(bytes(data)) + + raw = open(os.path.join(feat, "pathsafe.cbz"), "rb").read() + with open(os.path.join(feat, "truncated.cbz"), "wb") as f: + f.write(raw[:len(raw) // 2]) + + # 单页 33MiB(PNG 头 + 可压零填充,deflate 后只有几十 KB): + # 页过滤放行(< 64MiB),解压后落缓存时应允许一次性超过 32MiB 预算。 + head = png_bytes(64, 64, 31) + payload = head + b"\x00" * (HUGE_PAGE_LEN - len(head)) + write_zip(os.path.join(feat, "hugepage.cbz"), [ + ("huge.png", payload, zipfile.ZIP_DEFLATED), + ]) + + +def write_manifest(ro): + """每档案每条目:<相对路径>\t<条目名>\t\t(Tab 分隔,UTF-8 原名)。""" + lines = [] + for base, _dirs, files in os.walk(ro): + for name in sorted(files): + if not name.lower().endswith((".cbz", ".zip")): + continue + if name == "hugepage.cbz": + continue # 33MiB 页只为缓存溢出测试,manifest 不校验它 + path = os.path.join(base, name) + rel = os.path.relpath(path, ro) + try: + with zipfile.ZipFile(path) as z: + for info in z.infolist(): + if info.is_dir(): + continue + payload = z.read(info) + lines.append("%s\t%s\t%08x\t%d" % ( + rel, info.filename, zlib.crc32(payload) & 0xFFFFFFFF, + len(payload))) + except zipfile.BadZipFile: + continue + with open(os.path.join(ro, "manifest.txt"), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def main(): + if len(sys.argv) != 2: + raise SystemExit("usage: make_cbz_samples.py ") + ro = sys.argv[1] + os.makedirs(ro, exist_ok=True) + build_library(ro) + build_features(ro) + write_manifest(ro) + + +if __name__ == "__main__": + main() diff --git a/tuku/test/run_cbz_tests.sh b/tuku/test/run_cbz_tests.sh new file mode 100755 index 0000000..43a0d07 --- /dev/null +++ b/tuku/test/run_cbz_tests.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# run_cbz_tests.sh — M11 档案册(CBZ/zip)集成宿主测试(纯 C + -lz,无 SDK/设备)。 +# 夹具由 test/make_cbz_samples.py 现场生成到 $TMPDIR 临时目录,结束后删除。 +set -e +cd "$(dirname "$0")/.." +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" +trap 'rm -f test/test_cbz' EXIT +gcc -Wall -Wextra -Werror -O1 -I. -I"$SDK" \ + test/test_cbz.c logic/library.c logic/reader.c \ + logic/reader/page_source.c logic/reader/archive_source.c \ + logic/reader/page_cache.c logic/zip/zip.c test/settings_reader_stub.c \ + -lz -o test/test_cbz +./test/test_cbz ./test/make_cbz_samples.py diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 8be17e3..91a0e78 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -52,3 +52,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then else echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi + +# ---- 档案册(M11):zip 容器 + CBZ 书库/阅读器集成 ---- +./test/run_zip_tests.sh +./test/run_cbz_tests.sh diff --git a/tuku/test/run_webtoon_tests.sh b/tuku/test/run_webtoon_tests.sh index 4fd3e79..81f5d9d 100755 --- a/tuku/test/run_webtoon_tests.sh +++ b/tuku/test/run_webtoon_tests.sh @@ -31,7 +31,9 @@ gcc $CFLAGS test/test_cont_adjacent.c logic/library.c logic/reader.c \ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then gcc $CFLAGS -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I"$SDK/include" \ test/test_mode_switch_host.c ui/reader/reader.c \ - logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ + logic/library.c logic/reader.c logic/reader/page_source.c \ + logic/reader/archive_source.c logic/reader/page_cache.c logic/zip/zip.c \ + logic/reader/nav.c \ "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ @@ -40,7 +42,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ - -pthread -lm -o "$OUT/test_mode_switch_host" + -pthread -lm -lz -o "$OUT/test_mode_switch_host" "$OUT/test_mode_switch_host" else echo "SKIP test_mode_switch_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c new file mode 100644 index 0000000..4e4b29f --- /dev/null +++ b/tuku/test/test_cbz.c @@ -0,0 +1,920 @@ +/* test_cbz.c — M11 档案册(CBZ/zip)集成宿主测试(纯 C + -lz,无 SDK/硬件)。 + * + * 夹具由 test/make_cbz_samples.py 用 python3 zipfile 现场生成(真实 zip 产物 + + * 定点字节修补),放在 $TMPDIR 下临时目录,结束后删除。 + * + * 覆盖: + * T1 扩展名判据:cbz/zip 大小写不敏感;cbr/gz/无扩展名不算档案。 + * T2 页枚举:只收图片页(目录项/文本/0 字节/加密/超限过滤),跨子目录自然序; + * 中文条目名按原始 UTF-8 字节透传。 + * T3 路径安全:绝对路径、'..' 段、反斜杠、内嵌隐藏段一律不收;解压字节与 + * manifest 的 CRC/尺寸一致,缓存文件落在 MeowData//cache。 + * T4 meta:PNG/BMP/JPEG 原始尺寸经「解压到内存只读头」探测(翻页/条漫排版共用)。 + * T5 解压缓存纪律:64 页 LRU 淘汰最旧、命中不重写(inode 不变)、32MiB 字节上限、 + * 启动清孤儿(只删 mrpage_*,不动缩略图 *.bmp)、缓存不可用时优雅失败。 + * T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。 + * T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy), + * 越界 open 返回 NULL,close_auto 幂等。 + * T8 书库列档:.cbz 与文件夹册同列自然序(bookA < box < vol2.cbz < Vol10.cbz), + * 行=文件名+页数;坏档/无图档案不列;只含档案册的目录写 "N books"; + * 不注入探测回调时行为与 M7 完全一致(纯目录书库)。 + * T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz), + * 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。 + * T10 续读角标:档案路径作为册标识,副标题后缀「· 续读 Pn」。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \ + * logic/reader/page_source.c logic/reader/archive_source.c \ + * logic/reader/page_cache.c logic/zip/zip.c test/settings_reader_stub.c \ + * -lz -o test/test_cbz && ./test/test_cbz + * 或 test/run_cbz_tests.sh。 + */ +#define _POSIX_C_SOURCE 200809L + +#include "logic/library.h" +#include "logic/reader.h" +#include "logic/reader/page_cache.h" +#include "logic/reader/page_source.h" +#include "test/settings_reader_stub.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define PATH_CAP 1024 +#define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */ +#define HUGE_PAGE_LEN (33 * 1024 * 1024) /* 与 make_cbz_samples.py 的 HUGE_PAGE_LEN 一致 */ + +static int g_pass; +static int g_fail; +static char g_root[PATH_CAP]; /* 夹具根 */ +static char g_lib[PATH_CAP]; /* /library */ +static char g_feat[PATH_CAP]; /* /features */ +static char g_data[PATH_CAP]; /* /app/os.meow.picbrowser/data */ +static const char *g_argv0; + +#define CHECK(cond, ...) do { \ + if (cond) { \ + g_pass++; \ + } else { \ + g_fail++; \ + printf("FAIL %s:%d ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ +} while (0) + +/* ============================================================ */ +/* 夹具/文件辅助 */ +/* ============================================================ */ +static void joinp(char *out, size_t cap, const char *a, const char *b) +{ + snprintf(out, cap, "%s/%s", a, b); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[PATH_CAP]; + joinp(full, sizeof full, path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static int make_temp_dir(void) +{ + const char *bases[3]; + int nb = 0; + const char *env = getenv("TMPDIR"); + if (env && env[0]) bases[nb++] = env; + bases[nb++] = "/data/data/com.termux/files/usr/tmp"; + bases[nb++] = "/tmp"; + + for (int i = 0; i < nb; i++) { + char tmpl[PATH_CAP]; + snprintf(tmpl, sizeof tmpl, "%s/tuku_cbz_XXXXXX", bases[i]); + if (mkdtemp(tmpl)) { + snprintf(g_root, sizeof g_root, "%s", tmpl); + return 1; + } + } + return 0; +} + +static const char *find_generator(char *buf, size_t cap) +{ + static const char *fallbacks[] = { + "test/make_cbz_samples.py", "make_cbz_samples.py", + }; + const char *a0 = g_argv0 ? g_argv0 : ""; + const char *slash = strrchr(a0, '/'); + if (a0[0] && strcmp(a0, "-") != 0 && slash) { + snprintf(buf, cap, "%.*s/make_cbz_samples.py", (int)(slash - a0), a0); + if (access(buf, R_OK) == 0) return buf; + } + for (size_t i = 0; i < sizeof fallbacks / sizeof fallbacks[0]; i++) + if (access(fallbacks[i], R_OK) == 0) return fallbacks[i]; + return fallbacks[0]; +} + +static int run_generator(const char *gen) +{ + char cmd[PATH_CAP * 2 + 32]; + snprintf(cmd, sizeof cmd, "python3 '%s' '%s'", gen, g_root); + int st = system(cmd); + return st != -1 && WIFEXITED(st) && WEXITSTATUS(st) == 0; +} + +static int file_crc(const char *path, unsigned long *crc, long *size) +{ + FILE *f = fopen(path, "rb"); + if (!f) return 0; + unsigned char buf[8192]; + uLong c = crc32(0L, Z_NULL, 0); + long total = 0; + size_t n; + while ((n = fread(buf, 1, sizeof buf, f)) > 0) { + c = crc32(c, buf, (uInt)n); + total += (long)n; + } + fclose(f); + if (crc) *crc = (unsigned long)c; + if (size) *size = total; + return 1; +} + +/* manifest.txt 行:<档案相对路径>\t<条目名>\t\t */ +static int manifest_lookup(const char *rel, const char *entry, + unsigned long *crc, long *size) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_root, "manifest.txt"); + FILE *f = fopen(path, "r"); + if (!f) return 0; + char line[PATH_CAP * 2]; + char m_rel[PATH_CAP], m_ent[512]; + unsigned long m_crc; + long m_size; + int found = 0; + while (fgets(line, sizeof line, f) != NULL) { + if (sscanf(line, "%1023[^\t]\t%511[^\t]\t%lx\t%ld", + m_rel, m_ent, &m_crc, &m_size) != 4) + continue; + if (strcmp(m_rel, rel) == 0 && strcmp(m_ent, entry) == 0) { + if (crc) *crc = m_crc; + if (size) *size = m_size; + found = 1; + break; + } + } + fclose(f); + return found; +} + +/* ============================================================ */ +/* 假解码回调(契约边界:logic 不链 SDK) */ +/* ============================================================ */ +static int g_dummy; +static int g_load_n; +static int g_unload_n; +static char g_load_path[MR_CACHE_FILE_MAX]; + +static meow_img *fake_load(const char *path, void *ud, int maxdim) +{ + (void)ud; + (void)maxdim; + g_load_n++; + snprintf(g_load_path, sizeof g_load_path, "%s", path ? path : ""); + return (meow_img *)&g_dummy; +} + +static void fake_unload(meow_img *img) +{ + if (img) g_unload_n++; +} + +static int archive_pages_probe(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + +static int count_cache_files(void) +{ + const char *dir = mr_cache_dir(); + if (!dir || !dir[0]) return -1; + DIR *d = opendir(dir); + if (!d) return -1; + int n = 0; + size_t plen = strlen(MR_CACHE_PREFIX); + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strncmp(de->d_name, MR_CACHE_PREFIX, plen) == 0) n++; + } + closedir(d); + return n; +} + +/* ============================================================ */ +/* T1 扩展名 */ +/* ============================================================ */ +static void test_ext(void) +{ + CHECK(mr_path_is_archive("a.cbz") == 1, "cbz"); + CHECK(mr_path_is_archive("A.CBZ") == 1, "CBZ"); + CHECK(mr_path_is_archive("x.zip") == 1, "zip"); + CHECK(mr_path_is_archive("X.ZiP") == 1, "ZiP"); + CHECK(mr_path_is_archive("a.cbr") == 0, "cbr not archive"); + CHECK(mr_path_is_archive("a.gz") == 0, "gz not archive"); + CHECK(mr_path_is_archive("cbz") == 0, "no extension"); + CHECK(mr_path_is_archive(".cbz") == 0, "dot name"); + CHECK(mr_path_is_archive(NULL) == 0, "NULL safe"); +} + +/* ============================================================ */ +/* T2 页枚举 / 排序 / 名字透传 */ +/* ============================================================ */ +static void test_enum(void) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_lib, "vol2.cbz"); + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL); + CHECK(n == 3, "vol2 pages=%d (want 3)", n); + CHECK(zb.count == 3, "zb.count=%d", zb.count); + if (n != 3) { + mr_zip_book_free(&zb); + return; + } + + char name[512]; + mr_zip_book_page_name(&zb, 0, name, sizeof name); + CHECK(strcmp(name, "ch01/p2.png") == 0, "page0=%s", name); + mr_zip_book_page_name(&zb, 1, name, sizeof name); + CHECK(strcmp(name, "ch01/p10.png") == 0, "page1=%s", name); + mr_zip_book_page_name(&zb, 2, name, sizeof name); + CHECK(strcmp(name, CJK_PAGE) == 0, "page2=%s", name); + + mr_zip_book_page_name(&zb, -1, name, sizeof name); + CHECK(name[0] == '\0', "page_name(-1) not empty"); + mr_zip_book_page_name(&zb, 3, name, sizeof name); + CHECK(name[0] == '\0', "page_name(count) not empty"); + mr_zip_book_page_name(&zb, 0, NULL, 10); + mr_zip_book_page_name(&zb, 0, name, 0); + + /* 目录项/文本/0 字节条目都被过滤(count==3 已证明)。 */ + + mr_book b; + memset(&b, 0, sizeof b); + mr_zip_book_bind(&b, NULL); + CHECK(!mr_book_valid(&b), "bind NULL must leave book invalid"); + CHECK(mr_book_count(&b) == 0 && mr_book_open(&b, 0) == NULL, "invalid book safe"); + + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_valid(&b), "valid after bind"); + CHECK(mr_book_count(&b) == 3, "book count=%d", mr_book_count(&b)); + CHECK(mr_book_open(&b, 3) == NULL, "open(count) != NULL"); + CHECK(mr_book_open(&b, -1) == NULL, "open(-1) != NULL"); + + int before = g_load_n; + meow_img *img = mr_book_open(&b, 0); + CHECK(img != NULL, "open(0) returned NULL"); + CHECK(g_load_n == before + 1, "load callback not called"); + CHECK(strncmp(g_load_path, mr_cache_dir(), strlen(mr_cache_dir())) == 0, + "load path not in cache dir: %s", g_load_path); + CHECK(access(g_load_path, R_OK) == 0, "extracted file missing: %s", g_load_path); + CHECK(strstr(g_load_path, ".png") != NULL, "extension not preserved: %s", g_load_path); + mr_book_close(&b, img); + CHECK(g_unload_n >= 1, "unload not called"); + mr_book_close(&b, NULL); /* 容忍 NULL */ + + mr_book_destroy(&b); /* 走 destroy→zip_free */ + CHECK(!mr_book_valid(&b), "destroy left book valid"); + mr_zip_book_free(&zb); /* 幂等 */ +} + +/* ============================================================ */ +/* T3 路径安全 + 解压内容 */ +/* ============================================================ */ +static void test_pathsafe(void) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_feat, "pathsafe.cbz"); + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL); + CHECK(n == 2, "pathsafe pages=%d (want 2: ok1.png, sub/ok2.jpg)", n); + if (n != 2) { + mr_zip_book_free(&zb); + return; + } + + char name[512]; + mr_zip_book_page_name(&zb, 0, name, sizeof name); + CHECK(strcmp(name, "ok1.png") == 0, "page0=%s", name); + mr_zip_book_page_name(&zb, 1, name, sizeof name); + CHECK(strcmp(name, "sub/ok2.jpg") == 0, "page1=%s", name); + + const char *bad[] = { "../evil.png", "/abs.png", "a/../../b.png", "sub/../ok3.png", + ".hidden/x.png", "._mac.png", "sub\\win.png" }; + for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) { + CHECK(mr_zip_entry_is_page(bad[i], (unsigned)strlen(bad[i])) == 0, + "unsafe entry accepted: %s", bad[i]); + } + + for (int i = 0; i < n; i++) { + char out[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&zb, i, out, (int)sizeof out) == 0, + "page_path(%d) failed", i); + if (!out[0]) continue; + CHECK(strncmp(out, mr_cache_dir(), strlen(mr_cache_dir())) == 0, + "cache path escaped dir: %s", out); + mr_zip_book_page_name(&zb, i, name, sizeof name); + unsigned long crc = 0; + long size = 0; + CHECK(manifest_lookup("features/pathsafe.cbz", name, &crc, &size), + "manifest miss %s", name); + unsigned long got_crc = 0; + long got_size = 0; + CHECK(file_crc(out, &got_crc, &got_size), "crc read failed: %s", out); + CHECK(got_crc == crc && got_size == size, + "extracted bytes differ for %s (crc %lx/%lx size %ld/%ld)", + name, got_crc, crc, got_size, size); + } + mr_zip_book_free(&zb); +} + +/* ============================================================ */ +/* T4 meta(原始像素尺寸) */ +/* ============================================================ */ +static void test_meta(void) +{ + char path[PATH_CAP]; + joinp(path, sizeof path, g_lib, "vol2.cbz"); + + mr_zip_book zb; + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 3, + "vol2 init for meta"); + mr_book b; + mr_zip_book_bind(&b, &zb); + + int w = 0, h = 0; + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 41 && h == 31, + "meta0=%dx%d (want 41x31)", w, h); + CHECK(mr_book_meta(&b, 1, &w, &h) == 1 && w == 40 && h == 30, + "meta1=%dx%d (want 40x30)", w, h); + CHECK(mr_book_meta(&b, 2, &w, &h) == 1 && w == 12 && h == 34, + "meta2=%dx%d (want 12x34)", w, h); + CHECK(mr_book_meta(&b, 3, &w, &h) == 0 && w == 0 && h == 0, "meta(count)"); + mr_book_destroy(&b); + + joinp(path, sizeof path, g_lib, "Vol10.cbz"); + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1, + "Vol10 init"); + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 7 && h == 9, + "bmp meta=%dx%d (want 7x9)", w, h); + /* P2-1:BMP 页的缓存文件名也是 *.bmp,但必须能被缩略图 LRU 判据识别为解压页 */ + char vp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&zb, 0, vp, (int)sizeof vp) == 0, "bmp page path"); + const char *vbase = strrchr(vp, '/'); + CHECK(vbase && mr_cache_is_page_name(vbase + 1) == 1, + "bmp page cache looks like a thumbnail: %s", vp); + CHECK(vbase && strstr(vbase, ".bmp") != NULL, "bmp page lost its extension: %s", vp); + mr_book_destroy(&b); + + joinp(path, sizeof path, g_lib, "box/box2.cbz"); + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1, + "box2 init"); + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 12 && h == 34, + "jpeg meta=%dx%d (want 12x34)", w, h); + mr_book_destroy(&b); + + /* 内存探测边界 */ + CHECK(mr_probe_size_mem(NULL, 100, &w, &h) == 0, "probe NULL"); + CHECK(mr_probe_size_mem("ABCDEFGH", 8, &w, &h) == 0, "probe junk"); + + /* meta 只解压到内存读头,不落解压缓存(box1 从未被 open 过)。 */ + joinp(path, sizeof path, g_lib, "box/box1.cbz"); + CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 2, + "box1 init"); + mr_zip_book_bind(&b, &zb); + CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 8 && h == 8, + "box1 meta=%dx%d (want 8x8)", w, h); + struct stat st; + char pname[256], cpath[MR_CACHE_FILE_MAX]; + CHECK(stat(path, &st) == 0, "stat box1"); + mr_zip_book_page_name(&zb, 0, pname, (int)sizeof pname); + CHECK(mr_cache_path(path, (long long)st.st_mtime, (long long)st.st_size, + pname, 0, cpath, (int)sizeof cpath) == 0, "meta cache path"); + CHECK(access(cpath, F_OK) != 0, "meta wrote the page cache"); + mr_book_destroy(&b); +} + +/* ============================================================ */ +/* T5 解压缓存纪律 */ +/* ============================================================ */ +static void test_cache(void) +{ + char path[PATH_CAP]; + char data_dir[PATH_CAP]; + char cache_copy[MR_CACHE_DIR_MAX]; + + /* 解压页缓存与缩略图的文件名判据(P2-1):缩略图 LRU 依赖它跳过 mrpage_*。 */ + CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.bmp") == 1, "page name bmp"); + CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.png") == 1, "page name png"); + CHECK(mr_cache_is_page_name("0123456789abcdef_1700000000_1234.bmp") == 0, "thumb name"); + CHECK(mr_cache_is_page_name("thumb.bmp") == 0, "plain thumb"); + CHECK(mr_cache_is_page_name("") == 0 && mr_cache_is_page_name(NULL) == 0, + "page name edges"); + + joinp(path, sizeof path, g_feat, "big.cbz"); + + /* ---- (a) 64 页 LRU ---- */ + joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picbrowser/data"); + CHECK(mr_cache_init(data_dir) == 0, "cache init c1"); + snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); + CHECK(cache_copy[0] && strstr(cache_copy, "/cache") != NULL, + "cache dir=%s", cache_copy); + + mr_zip_book zb; + int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL); + CHECK(n == 80, "big pages=%d (want 80)", n); + char p[MR_CACHE_FILE_MAX]; + for (int i = 0; i < n; i++) + CHECK(mr_zip_book_page_path(&zb, i, p, (int)sizeof p) == 0, + "big page %d path", i); + CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES, + "cache count=%d (want 64)", mr_cache_count()); + CHECK(mr_cache_bytes() <= MR_CACHE_MAX_BYTES, "cache bytes over cap"); + CHECK(count_cache_files() == mr_cache_count(), + "disk files=%d index=%d", count_cache_files(), mr_cache_count()); + + /* 最旧的 16 页被淘汰,最新的仍在 */ + struct stat st; + char arc[PATH_CAP]; + char name[512]; + snprintf(arc, sizeof arc, "%s", path); + CHECK(stat(arc, &st) == 0, "stat big.cbz"); + char p0[MR_CACHE_FILE_MAX], p79[MR_CACHE_FILE_MAX]; + mr_zip_book_page_name(&zb, 0, name, sizeof name); + CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size, + name, 0, p0, (int)sizeof p0) == 0, "cache_path p0"); + mr_zip_book_page_name(&zb, 79, name, sizeof name); + CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size, + name, 79, p79, (int)sizeof p79) == 0, "cache_path p79"); + CHECK(access(p0, F_OK) != 0, "LRU kept the oldest page"); + CHECK(access(p79, F_OK) == 0, "LRU dropped the newest page"); + + /* 命中不重写:同一 inode */ + struct stat s1, s2; + CHECK(stat(p79, &s1) == 0, "stat p79"); + CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 re-get"); + CHECK(stat(p, &s2) == 0 && s1.st_ino == s2.st_ino, "cache hit rewrote the file"); + + /* 假命中自愈(P2-1):外部删掉缓存文件后再取同页,应重新解压而不是返回死路径 */ + unlink(p79); + CHECK(access(p79, F_OK) != 0, "unlink p79"); + CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 after unlink"); + CHECK(access(p79, F_OK) == 0, "page79 not re-extracted after external unlink"); + CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES, "count after self-heal=%d", + mr_cache_count()); + mr_zip_book_free(&zb); + + /* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */ + joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picbrowser/data"); + mr_cache_shutdown(); + CHECK(mr_cache_init(data_dir) == 0, "cache init c2"); + snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); + + char orphan[MR_CACHE_FILE_MAX], keep[MR_CACHE_FILE_MAX]; + snprintf(orphan, sizeof orphan, "%s/%sdeadbeef.png", cache_copy, MR_CACHE_PREFIX); + snprintf(keep, sizeof keep, "%s/thumb.bmp", cache_copy); + FILE *f = fopen(orphan, "wb"); + CHECK(f != NULL, "create orphan"); + if (f) { fputs("orphan", f); fclose(f); } + f = fopen(keep, "wb"); + CHECK(f != NULL, "create thumb"); + if (f) { fputs("thumb", f); fclose(f); } + + mr_cache_shutdown(); + CHECK(mr_cache_init(data_dir) == 0, "cache re-init c2"); + CHECK(access(orphan, F_OK) != 0, "startup did not clean orphan mrpage_*"); + CHECK(access(keep, F_OK) == 0, "startup deleted non-cache file"); + + size_t big_len = 20u * 1024u * 1024u; + unsigned char *big = (unsigned char *)malloc(big_len); + CHECK(big != NULL, "malloc 20MiB"); + if (big) { + memset(big, 0xA5, big_len); + char b1[MR_CACHE_FILE_MAX], b2[MR_CACHE_FILE_MAX]; + snprintf(b1, sizeof b1, "%s/%sbig1.img", cache_copy, MR_CACHE_PREFIX); + snprintf(b2, sizeof b2, "%s/%sbig2.img", cache_copy, MR_CACHE_PREFIX); + mr_cache_shutdown(); + mr_cache_init(data_dir); + CHECK(mr_cache_store(b1, big, big_len) == 1, "store 20MiB #1"); + CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len, + "count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes()); + CHECK(mr_cache_store(b2, big, big_len) == 1, "store 20MiB #2"); + CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len, + "byte cap: count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes()); + CHECK(access(b1, F_OK) != 0, "byte cap did not evict the oldest"); + free(big); + } + unlink(keep); + + /* ---- (c) 单页 > 32MiB:文档化的一次性溢出(大页可读,其它条目按预算淘汰) ---- */ + joinp(data_dir, sizeof data_dir, g_root, "c3/os.meow.picbrowser/data"); + mr_cache_shutdown(); + CHECK(mr_cache_init(data_dir) == 0, "cache init c3"); + + joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); + mr_zip_book small; + CHECK(mr_zip_book_init(&small, arc, 720, fake_load, fake_unload, NULL) == 2, + "small book init"); + char sp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&small, 0, sp, (int)sizeof sp) == 0, "small page"); + CHECK(mr_cache_count() == 1, "small cache count=%d", mr_cache_count()); + mr_zip_book_free(&small); + + joinp(arc, sizeof arc, g_feat, "hugepage.cbz"); + mr_zip_book huge; + CHECK(mr_zip_book_init(&huge, arc, 720, fake_load, fake_unload, NULL) == 1, + "hugepage init"); + char hp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&huge, 0, hp, (int)sizeof hp) == 0, "huge page path"); + CHECK(mr_cache_count() == 1, "overflow count=%d", mr_cache_count()); + CHECK(mr_cache_bytes() == (long long)HUGE_PAGE_LEN, + "overflow bytes=%lld (want %d)", mr_cache_bytes(), HUGE_PAGE_LEN); + CHECK(mr_cache_bytes() > MR_CACHE_MAX_BYTES, "huge page should exceed the budget"); + CHECK(access(sp, F_OK) != 0, "huge page should evict the small page"); + CHECK(access(hp, F_OK) == 0, "huge page file missing"); + CHECK(count_cache_files() == 1, "overflow left extra cache files"); + + mr_book hb; + mr_zip_book_bind(&hb, &huge); + int before_load = g_load_n; + meow_img *him = mr_book_open(&hb, 0); + CHECK(him != NULL && g_load_n == before_load + 1, "huge page not readable"); + mr_book_close(&hb, him); + mr_book_destroy(&hb); + + /* 再来一个小页:整体预算仍然生效,大页被淘汰 */ + joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); + mr_zip_book small2; + CHECK(mr_zip_book_init(&small2, arc, 720, fake_load, fake_unload, NULL) == 2, + "small2 init"); + CHECK(mr_zip_book_page_path(&small2, 0, sp, (int)sizeof sp) == 0, "small2 page"); + CHECK(mr_cache_count() == 1 && mr_cache_bytes() < MR_CACHE_MAX_BYTES, + "budget not restored: count=%d bytes=%lld", mr_cache_count(), + mr_cache_bytes()); + CHECK(access(hp, F_OK) != 0, "huge page should be evicted by the budget"); + mr_zip_book_free(&small2); + + /* ---- (d) 缓存不可用时优雅失败(不崩、不写文件) ---- */ + mr_cache_shutdown(); + CHECK(mr_cache_ready() == 0, "shutdown left cache ready"); + joinp(path, sizeof path, g_lib, "vol2.cbz"); + mr_zip_book zb2; + CHECK(mr_zip_book_init(&zb2, path, 720, fake_load, fake_unload, NULL) == 3, + "init without cache"); + CHECK(mr_zip_book_page_path(&zb2, 0, p, (int)sizeof p) == -1, + "page_path without cache should fail"); + int before = g_load_n; + mr_book b; + mr_zip_book_bind(&b, &zb2); + CHECK(mr_book_open(&b, 0) == NULL, "open without cache should fail"); + CHECK(g_load_n == before, "decode callback called without cache"); + mr_book_destroy(&b); + + joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picbrowser/data"); + CHECK(mr_cache_init(data_dir) == 0, "cache init app"); +} + +/* ============================================================ */ +/* T6 坏档容错 */ +/* ============================================================ */ +static void expect_bad(const char *path, const char *tag) +{ + mr_book_handle h; + mr_book b; + int n = mr_book_open_auto(&b, &h, path, 720, fake_load, fake_unload, NULL); + CHECK(n == -1, "%s: open_auto=%d (want -1)", tag, n); + CHECK(!mr_book_valid(&b), "%s: book valid after failure", tag); + CHECK(mr_book_open(&b, 0) == NULL, "%s: open after failure", tag); + mr_book_close_auto(&b, &h); + mr_book_close_auto(&b, &h); /* 幂等 */ + CHECK(mr_zip_count_pages(path) == 0, "%s: count_pages != 0", tag); +} + +static void test_bad(void) +{ + char p[PATH_CAP]; + + joinp(p, sizeof p, g_lib, "broken.cbz"); + expect_bad(p, "broken"); + joinp(p, sizeof p, g_lib, "truncated.cbz"); + expect_bad(p, "truncated"); + joinp(p, sizeof p, g_feat, "truncated.cbz"); + expect_bad(p, "truncated(feat)"); + + joinp(p, sizeof p, g_lib, "missing.cbz"); + expect_bad(p, "missing"); + + mr_book_handle h; + mr_book b; + CHECK(mr_book_open_auto(&b, &h, NULL, 720, fake_load, fake_unload, NULL) == -1, + "open_auto(NULL)"); + CHECK(mr_book_open_auto(NULL, &h, p, 720, fake_load, fake_unload, NULL) == -1, + "open_auto(NULL book)"); + CHECK(mr_book_open_auto(&b, NULL, p, 720, fake_load, fake_unload, NULL) == -1, + "open_auto(NULL handle)"); + + /* 合法 zip 但没有图片页 / 声明尺寸超上限(zip 炸弹) */ + const char *zero[] = { "notabook.zip", "emptypages.cbz" }; + for (size_t i = 0; i < sizeof zero / sizeof zero[0]; i++) { + joinp(p, sizeof p, g_lib, zero[i]); + int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 0, "%s: open_auto=%d (want 0)", zero[i], n); + CHECK(!mr_book_valid(&b), "%s: book bound with 0 pages", zero[i]); + CHECK(mr_book_open(&b, 0) == NULL, "%s: open on empty book", zero[i]); + mr_book_close_auto(&b, &h); + CHECK(mr_zip_count_pages(p) == 0, "%s: count_pages != 0", zero[i]); + } + + joinp(p, sizeof p, g_feat, "bomb.cbz"); + CHECK(mr_zip_count_pages(p) == 0, "bomb.cbz should have no page (96MiB)"); + CHECK(mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL) == 0, + "bomb: open_auto should be 0 pages"); + mr_book_close_auto(&b, &h); +} + +/* ============================================================ */ +/* T7 统一开册契约 */ +/* ============================================================ */ +static void test_open_auto(void) +{ + char p[PATH_CAP]; + joinp(p, sizeof p, g_lib, "bookA"); + + mr_book_handle h; + mr_book b; + int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 2 && h.kind == MR_BOOK_KIND_DIR, "dir open n=%d kind=%d", n, h.kind); + CHECK(mr_book_valid(&b) && mr_book_count(&b) == 2, "dir book contract"); + int before = g_load_n; + meow_img *img = mr_book_open(&b, 1); + CHECK(img != NULL && g_load_n == before + 1, "dir open(1)"); + CHECK(strstr(g_load_path, "002.png") != NULL, "dir page path=%s", g_load_path); + mr_book_close(&b, img); + int mw = 0, mh = 0; + CHECK(mr_book_meta(&b, 0, &mw, &mh) == 1 && mw == 30 && mh == 20, + "dir meta=%dx%d (want 30x20)", mw, mh); + mr_book_close_auto(&b, &h); + CHECK(h.kind == MR_BOOK_KIND_NONE && !mr_book_valid(&b), "dir close_auto"); + mr_book_close_auto(&b, &h); /* 幂等 */ + + joinp(p, sizeof p, g_lib, "vol2.cbz"); + n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 3 && h.kind == MR_BOOK_KIND_ZIP, "zip open n=%d kind=%d", n, h.kind); + CHECK(mr_book_valid(&b) && mr_book_count(&b) == 3, "zip book contract"); + CHECK(mr_book_open(&b, 99) == NULL, "zip out of range"); + mr_book_close_auto(&b, &h); + CHECK(!mr_book_valid(&b), "zip close_auto"); + + joinp(p, sizeof p, g_lib, "emptypages.cbz"); + n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL); + CHECK(n == 0 && h.kind == MR_BOOK_KIND_ZIP, "empty zip n=%d kind=%d", n, h.kind); + CHECK(!mr_book_valid(&b), "empty zip bound"); + mr_book_close_auto(&b, &h); +} + +/* ============================================================ */ +/* T8 书库列档 */ +/* ============================================================ */ +static const mr_library_entry *lib_find(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + return NULL; +} + +static void test_library(void) +{ + mr_test_cfg_clear(); + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + CHECK(n == 4, "library rows=%d (want bookA, box, vol2.cbz, Vol10.cbz)", n); + if (n == 4) { + CHECK(strcmp(lib.items[0].name, "bookA") == 0, "row0=%s", lib.items[0].name); + CHECK(strcmp(lib.items[1].name, "box") == 0, "row1=%s", lib.items[1].name); + CHECK(strcmp(lib.items[2].name, "vol2.cbz") == 0, "row2=%s", lib.items[2].name); + CHECK(strcmp(lib.items[3].name, "Vol10.cbz") == 0, "row3=%s", lib.items[3].name); + } + + const mr_library_entry *e = lib_find(&lib, "bookA"); + CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "bookA fields"); + CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "bookA subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s", + e ? e->cover : ""); + + e = lib_find(&lib, "box"); + CHECK(e && e->has_images == 0 && e->subdirs == 0, "box fields"); + CHECK(e && strcmp(e->subtitle, "2 books") == 0, "box subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && e->cover[0] == '\0', "box cover should be empty"); + + e = lib_find(&lib, "vol2.cbz"); + CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0, + "vol2.cbz fields"); + CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)"); + CHECK(e && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz path=%s", + e ? e->path : ""); + + e = lib_find(&lib, "Vol10.cbz"); + CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0, + "Vol10.cbz pages/subtitle"); + + CHECK(lib_find(&lib, "notabook.zip") == NULL, "text-only zip listed"); + CHECK(lib_find(&lib, "emptypages.cbz") == NULL, "no-image cbz listed"); + CHECK(lib_find(&lib, "broken.cbz") == NULL, "broken cbz listed"); + CHECK(lib_find(&lib, "truncated.cbz") == NULL, "truncated cbz listed"); + CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz listed"); + mr_library_free(&lib); + + /* 不注入探测回调:与 M7 完全一致(档案册不参与,只含档案册的目录也不 nav)。 */ + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 1, + "legacy scan rows=%d (want 1: bookA)", lib.count); + CHECK(lib.count >= 1 && strcmp(lib.items[0].name, "bookA") == 0, + "legacy scan row0"); + mr_library_free(&lib); +} + +/* ============================================================ */ +/* T9 册间衔接 */ +/* ============================================================ */ +static void expect_adj(const char *from, int delta, const char *want, const char *tag) +{ + char out[MR_PATH_MAX]; + int r = mr_book_adjacent_ex(from, delta, out, (int)sizeof out, + archive_pages_probe, NULL); + if (!want) { + CHECK(r == 0 && out[0] == '\0', "%s: r=%d out=%s", tag, r, out); + } else { + CHECK(r == 1 && strcmp(out, want) == 0, "%s: got %s (want %s)", tag, out, want); + } +} + +static void test_adjacent(void) +{ + char bookA[MR_PATH_MAX], vol2[MR_PATH_MAX], vol10[MR_PATH_MAX]; + char broken[MR_PATH_MAX]; + joinp(bookA, sizeof bookA, g_lib, "bookA"); + joinp(vol2, sizeof vol2, g_lib, "vol2.cbz"); + joinp(vol10, sizeof vol10, g_lib, "Vol10.cbz"); + joinp(broken, sizeof broken, g_lib, "broken.cbz"); + + /* 兄弟表 = bookA + vol2.cbz + Vol10.cbz(box 无直接图、坏档不算书)。 */ + expect_adj(bookA, 1, vol2, "bookA next"); + expect_adj(vol2, 1, vol10, "vol2 next"); + expect_adj(vol10, 1, NULL, "vol10 next (end)"); + expect_adj(bookA, -1, NULL, "bookA prev (start)"); + expect_adj(vol2, -1, bookA, "vol2 prev"); + expect_adj(vol10, -1, vol2, "vol10 prev"); + expect_adj(broken, 1, NULL, "broken not a book"); + expect_adj(broken, -1, NULL, "broken not a book prev"); + + /* 不注入探测回调:档案册不参与(M7 语义) */ + char out[MR_PATH_MAX]; + CHECK(mr_book_adjacent(vol2, 1, out, (int)sizeof out) == 0, + "legacy adjacency should ignore archives (r=%d)", 0); + CHECK(mr_book_adjacent(bookA, 1, out, (int)sizeof out) == 0, + "legacy adjacency bookA next should be none"); + + /* 参数边界 */ + CHECK(mr_book_adjacent_ex(vol2, 0, out, (int)sizeof out, archive_pages_probe, + NULL) == -1, "delta 0"); + CHECK(mr_book_adjacent_ex(NULL, 1, out, (int)sizeof out, archive_pages_probe, + NULL) == -1, "NULL dir"); + CHECK(mr_book_adjacent_ex(vol2, 1, NULL, 32, archive_pages_probe, NULL) == -1, + "NULL out"); + CHECK(mr_book_adjacent_ex("/", 1, out, (int)sizeof out, archive_pages_probe, + NULL) == -1, "root"); +} + +/* ============================================================ */ +/* T10 续读角标 + 封面 */ +/* ============================================================ */ +static void test_badge_cover(void) +{ + char vol2[MR_PATH_MAX]; + joinp(vol2, sizeof vol2, g_lib, "vol2.cbz"); + + mr_test_cfg_clear(); + mr_cont_save(vol2, 2, MR_MODE_WEBTOON); + + mr_library lib; + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, + NULL) == 4, "rescan for badge"); + const mr_library_entry *e = lib_find(&lib, "vol2.cbz"); + CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s", + e ? e->subtitle : ""); + CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s", + e ? e->subtitle : ""); + e = lib_find(&lib, "Vol10.cbz"); + CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge"); + mr_library_free(&lib); + + /* 清除记录后角标消失 */ + mr_cont_save(vol2, 0, MR_MODE_WEBTOON); + memset(&lib, 0, sizeof lib); + mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + e = lib_find(&lib, "vol2.cbz"); + CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone"); + mr_library_free(&lib); + + /* 封面 = 档内自然序首图(ch01/p2.png),解压到缓存 */ + char cover[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_cover_path(vol2, cover, (int)sizeof cover) == 1, "cover path"); + CHECK(cover[0] && access(cover, R_OK) == 0, "cover file missing: %s", cover); + unsigned long crc = 0; + long size = 0; + CHECK(file_crc(cover, &crc, &size), "cover crc"); + unsigned long want_crc = 0; + long want_size = 0; + CHECK(manifest_lookup("library/vol2.cbz", "ch01/p2.png", &want_crc, &want_size), + "cover manifest"); + CHECK(crc == want_crc && size == want_size, "cover bytes differ"); + + char broken[MR_PATH_MAX]; + joinp(broken, sizeof broken, g_lib, "broken.cbz"); + CHECK(mr_zip_cover_path(broken, cover, (int)sizeof cover) == 0, "broken cover"); + CHECK(mr_zip_cover_path(vol2, NULL, 16) == 0, "cover NULL out"); +} + +/* ============================================================ */ +int main(int argc, char **argv) +{ + g_argv0 = argv[0]; + char gen_buf[PATH_CAP]; + const char *gen = argc > 1 ? argv[1] : find_generator(gen_buf, sizeof gen_buf); + + if (!make_temp_dir()) { + perror("mkdtemp"); + return 2; + } + joinp(g_lib, sizeof g_lib, g_root, "library"); + joinp(g_feat, sizeof g_feat, g_root, "features"); + joinp(g_data, sizeof g_data, g_root, "app/os.meow.picbrowser/data"); + + if (!run_generator(gen)) { + printf("generator failed: %s\n", gen); + rm_rf(g_root); + return 2; + } + if (mr_cache_init(g_data) != 0) { + printf("cache init failed: %s\n", g_data); + rm_rf(g_root); + return 2; + } + + test_ext(); + test_enum(); + test_pathsafe(); + test_meta(); + test_cache(); + test_bad(); + test_open_auto(); + test_library(); + test_adjacent(); + test_badge_cover(); + + mr_cache_shutdown(); + rm_rf(g_root); + + printf("cbz tests: %d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 50b69e4..7d6083a 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -1,21 +1,24 @@ /* ui/library/library.c — 图库根页(ACT_LIBRARY): * * TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。 - * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册)。 - * 行=缩略图 + 目录名 + 页数;目录由 logic/library 扫描并自然排序。 + * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册); + * M11 起 .cbz/.zip 也作为「册」与文件夹并列(行=封面+文件名+页数,点入直接阅读)。 + * 行数据由 logic/library 扫描并自然排序;档案册页数/首图由 logic/reader 的 zip 页源提供。 * * 流畅度(用户指令优先): * - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码, * 完成后在 on_tick 补位并只 invalidate 列表视口; * - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData//cache - * (BMP 原始缩略图,文件名含源文件 mtime+size,源文件变化自动失效;≥64 张按 - * mtime LRU 删除); + * (BMP 原始缩略图,文件名含源 mtime+size,源文件变化自动失效;≥64 张按 + * mtime LRU 删除);档案册封面=档内首图,先解压到同一 cache(page_cache 管 LRU); * - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。 * * 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的 * listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。 */ #include "tuku_internal.h" +#include "logic/reader/page_source.h" +#include "logic/reader/page_cache.h" #include #include @@ -106,20 +109,10 @@ static void cache_init(void) const char *data = meow_config_dir(); if (!data || !data[0]) return; - int n = snprintf(g_cache_dir, sizeof g_cache_dir, "%s", data); - if (n <= 0 || n >= (int)sizeof g_cache_dir) { g_cache_dir[0] = '\0'; return; } - - char *slash = strrchr(g_cache_dir, '/'); - if (slash && strcmp(slash, "/data") == 0) *slash = '\0'; - - size_t len = strlen(g_cache_dir); - n = snprintf(g_cache_dir + len, sizeof g_cache_dir - len, "/cache"); - if (n <= 0 || n >= (int)(sizeof g_cache_dir - len)) { g_cache_dir[0] = '\0'; return; } - - if (mkdir(g_cache_dir, 0755) != 0 && errno != EEXIST) { - g_cache_dir[0] = '\0'; - return; - } + /* 缩略图缓存与解压页缓存同目录(MeowData//cache)。目录推导、建立与 + * 启动清孤儿统一交给 logic/reader/page_cache,避免两处目录规则分叉。 */ + if (mr_cache_init(data) != 0 || mr_cache_dir()[0] == '\0') return; + snprintf(g_cache_dir, sizeof g_cache_dir, "%s", mr_cache_dir()); g_cache_on = 1; } @@ -144,6 +137,10 @@ static void cache_prune(void) time_t oldest_t = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { + /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 + * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 + * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ + if (mr_cache_is_page_name(de->d_name)) continue; const char *dot = strrchr(de->d_name, '.'); if (!dot || strcmp(dot, ".bmp") != 0) continue; char full[MR_PATH_MAX + 96]; @@ -220,12 +217,21 @@ static int write_bmp(const char *path, const meow_img *im) return ok ? 0 : -1; } -/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 */ +/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 + * 档案册(M11)的 cover 是 .cbz/.zip 路径:先解压档内首图到 page_cache, + * 再按普通图片路径解码;缩略图缓存键仍用档案本身(mtime/size 变化即失效)。 */ static meow_img *thumb_load(const char *cover) { struct stat st; if (stat(cover, &st) != 0 || !S_ISREG(st.st_mode)) return NULL; + char extracted[MR_CACHE_FILE_MAX]; + const char *src = cover; + if (mr_path_is_archive(cover)) { + if (!mr_zip_cover_path(cover, extracted, (int)sizeof extracted)) return NULL; + src = extracted; + } + char cp[MR_PATH_MAX + 96]; if (g_cache_on && g_cache_dir[0]) { cache_path(cp, (int)sizeof cp, cover, &st); @@ -239,7 +245,7 @@ static meow_img *thumb_load(const char *cover) } } - meow_img *im = meow_img_load_file(cover); + meow_img *im = meow_img_load_file(src); if (!im) return NULL; meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); if (small) { meow_img_free(im); im = small; } @@ -380,7 +386,11 @@ static void lib_bind(int idx, meow_lv_item *item, void *ctx) item->icon = im; } else { item->icon_kind = e->has_images ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; - if (e->cover[0]) thumb_enqueue(idx, e->cover); + /* 文件夹册用缩略图源图;档案册没有图片文件,用档案路径(worker 解压首图)。 */ + const char *src = NULL; + if (e->cover[0]) src = e->cover; + else if (e->has_images && mr_path_is_archive(e->path)) src = e->path; + if (src) thumb_enqueue(idx, src); } } @@ -411,7 +421,7 @@ static void list_rebuild(void) /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ meow_listview_set_container(g_lv, 1); - meow_listview_set_empty_text(g_lv, "No folders with PNG/JPEG/BMP"); + meow_listview_set_empty_text(g_lv, "No folders or CBZ books"); g_scroll = meow_scroll_wrap(g_lv, 1); if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); @@ -446,12 +456,20 @@ static void reset_async(void) pthread_mutex_unlock(&g_lock); } +/* 书库扫描的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ +static int cbz_archive_pages(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + static void load_dir(const char *dir) { snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); reset_async(); thumbs_free(); - if (mr_library_scan(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH) < 0) { + if (mr_library_scan_ex(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH, + cbz_archive_pages, NULL) < 0) { mr_library_free(&g_lib); meow_log(MEOW_LOG_WARN, "library scan failed: %s", g_cur_dir); } diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index ae80fe2..a00aea2 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -39,6 +39,7 @@ #include "tuku_internal.h" #include "ui/reader/reader.h" #include "logic/reader/page_source.h" +#include "logic/reader/page_cache.h" #include "logic/reader/nav.h" #include @@ -74,7 +75,7 @@ static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; -static mr_dir_book g_db; +static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */ static mr_book g_book; static int g_count; /* 0=空册/不可读 */ static int g_open_err; @@ -765,7 +766,7 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) int w = g_vw, h = g_vh; if (g_open_err) { - meow_ui_text_center(w / 2, h / 2 - 20, "无法打开目录", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, h / 2 - 20, "无法打开此册", th->font_title, th->onSurface); meow_ui_text_center(w / 2, h / 2 + 24, g_dir, th->font_body, th->onSurfaceVariant); return; } @@ -1152,8 +1153,7 @@ static void open_book_reset_state(void) mr_strip_free(&g_strip); g_strip_ok = 0; slots_free_all(); - mr_book_destroy(&g_book); - mr_dir_book_free(&g_db); + mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } g_gen++; /* 作废旧册的异步结果 */ g_count = 0; @@ -1183,10 +1183,11 @@ static int open_book(const char *dir, int start_page, int mode) g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; int maxdim = g_vw > g_vh ? g_vw : g_vh; - int n = mr_dir_book_init(&g_db, g_dir, maxdim, - reader_load_page, reader_unload_page, NULL); + /* 目录 → 目录册;.cbz/.zip → 档案册(M11)。两者同一个 mr_book 契约, + * 下面的翻页/条漫消费代码完全不区分 provider。 */ + int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim, + reader_load_page, reader_unload_page, NULL); if (n < 0) { - mr_dir_book_bind(&g_book, NULL); g_open_err = 1; worker_start(); update_title(); @@ -1195,7 +1196,6 @@ static int open_book(const char *dir, int start_page, int mode) return 0; } g_count = n; - mr_dir_book_bind(&g_book, g_count > 0 ? &g_db : NULL); int page = start_page < 0 ? g_count - 1 : start_page; page = mr_nav_clamp_page(page, g_count); @@ -1233,12 +1233,20 @@ static int open_book(const char *dir, int start_page, int mode) return 1; } +/* 册间衔接的档案册探测(M11):兄弟表 = 含图目录 ∪ 含图片页的 .cbz/.zip。 */ +static int cbz_pages_probe(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + static int book_switch(int delta) { if (delta == 0) return 0; if (g_count <= 0) return 0; char next[MR_PATH_MAX]; - int r = mr_book_adjacent(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next); + int r = mr_book_adjacent_ex(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next, + cbz_pages_probe, NULL); if (r != 1) { toast(delta > 0 ? "已是最后一册" : "已是第一册"); return 0; @@ -1770,6 +1778,13 @@ static void reader_page_enter(meow_view *v, void *ctx) } if (g_tb) meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOP_BAR_H); + /* 档案册解压缓存(M11):MeowData//cache。library 页通常已初始化, + * 这里幂等兜底(进程重启后直接进阅读器、或交付环境只剩 reader 入口)。 */ + { + const char *data = meow_config_dir(); + if (data && data[0]) mr_cache_init(data); + } + /* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); int page = mr_cont_get_page(g_dir, 0); @@ -1801,8 +1816,7 @@ static void reader_page_leave(meow_view *v, void *ctx) mr_strip_free(&g_strip); g_strip_ok = 0; slots_free_all(); - mr_book_destroy(&g_book); - mr_dir_book_free(&g_db); + mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; }