feat(tuku): CBZ 档案册集成——档案页源/书库列册/解压缓存

- logic/reader/archive_source.c:档案册页源实现 M6 mr_book 契约
  (mz_open/mz_count/mz_entry_at/mz_read_alloc),条目自然序 + 图片后缀 +
  路径安全(`..`/绝对路径/反斜杠/隐藏段/内嵌 NUL)+ 单页尺寸上限过滤;
  meta 按需解压只读图片头(不落缓存,结果按页记忆);mr_book_open_auto
  统一目录册/档案册开册,翻页与条漫两套消费代码零改动。
- logic/reader/page_cache.c/h:解压页落 MeowData/<pkg>/cache,LRU 64 页或
  32MiB,启动清 mrpage_* 孤儿,写入 .part + rename;与缩略图缓存同目录、
  前缀隔离;互斥量保护,解码/缩略图线程可并发。
- logic/library.c/h:扫描直接子文件里的 .cbz/.zip 成册(行=文件名+页数,
  点入直接阅读);档案探测由调用方注入(logic 不依赖 zip 容器),不注入时
  与 M7 行为逐字一致;册间衔接兄弟表=含图目录∪含页档案册,同列自然序。
  条目构造/副标题/角标/兄弟判据拆成小函数,便于 M13 书库重写拼接。
- ui/library:档案册行 + 封面缩略图(解压档内首图)、扫描注入探测、缓存
  目录统一由 page_cache 推导(一份规则)。
- ui/reader:open_book 走 mr_book_open_auto;跨册衔接纳入档案册。
- build_tuku.sh 补 zip/archive/cache 源与 sysroot zlib 头(zip.c include
  <zlib.h>);run_tests.sh 纳入 zip+cbz 用例;run_webtoon_tests.sh 的宿主
  集成构建补新源与 -lz;.gitignore 同步测试产物。
- test/test_cbz.c(267 项断言)+ make_cbz_samples.py 真实 cbz 夹具:
  列档/自然序/解压字节 CRC/路径安全/坏档与 zip 炸弹容错/LRU 与 32MiB 上限/
  启动清孤儿/续读角标/封面首图。
This commit is contained in:
AskaEth committed 2026-11-05 11:58:12 +08:00
1 parent 19620a7957
commit 562cc44931
16 files changed
+2330 -108

No files matched your search

+6
View File
@@ -49,6 +49,12 @@ tuku/picbrowser
tuku/*.mpk tuku/*.mpk
tuku/build/ tuku/build/
tuku/test/test_library 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 *.orig
+5 -2
View File
@@ -4,15 +4,18 @@
# SDK 路径用绝对路径(默认 $HOME/Proj/MeowOS-SDK/sdk),可用 MEOW_SDK_DIR 覆盖; # SDK 路径用绝对路径(默认 $HOME/Proj/MeowOS-SDK/sdk),可用 MEOW_SDK_DIR 覆盖;
# 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/os.meow.picbrowser。 # 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/os.meow.picbrowser。
# 交叉编译器/sysroot 同 SDK:MEOW_CC / MEOW_SYSROOT 可覆盖。 # 交叉编译器/sysroot 同 SDK:MEOW_CC / MEOW_SYSROOT 可覆盖。
# -I"$SYSROOT/include":zip 容器层(logic/zip/zip.c)直接 include <zlib.h>,
# 头文件随 sysroot 的 libz.a 一起提供(SDK 自身只用最小 zlib ABI 自声明)。
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
CC="${MEOW_CC:-$HOME/.tools/zig-mipsel-cc}" CC="${MEOW_CC:-$HOME/.tools/zig-mipsel-cc}"
SYSROOT="${MEOW_SYSROOT:-$HOME/.tools/mipsel-linux-musleabi-sysroot}" SYSROOT="${MEOW_SYSROOT:-$HOME/.tools/mipsel-linux-musleabi-sysroot}"
SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" 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 \ 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 "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser
ls -la picbrowser ls -la picbrowser
file picbrowser file picbrowser
+146 -43
View File
@@ -12,6 +12,14 @@
* - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page); * - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page);
* - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」, * - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」,
* 供阅读器末页前进/首页后退跨册;mr_library_dir_has_images 是共享的「是不是一本书」判据。 * 供阅读器末页前进/首页后退跨册;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 "library.h"
#include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #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; 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) int mr_natural_cmp(const char *a, const char *b)
{ {
const unsigned char *pa = (const unsigned char *)a; 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 { typedef struct scan_info {
int pages; /* 直接图片数 */ int pages; /* 直接图片数 */
int subdirs; /* 含图子目录数 */ int subdirs; /* 含图子目录数 */
int nav; /* 1=本目录最终含图(可直接看或可导航到图) */ int archives; /* 直接档案册数(.cbz/.zip 且探测到图片页) */
int nav; /* 1=本目录最终含书(可直接看或可导航到书) */
char cover[MR_PATH_MAX]; /* 缩略图源 */ char cover[MR_PATH_MAX]; /* 缩略图源 */
char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */ char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */
} scan_info; } 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); memset(si, 0, sizeof *si);
DIR *d = opendir(path); 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 (S_ISDIR(st.st_mode)) {
if (depth >= max_depth) continue; if (depth >= max_depth) continue;
scan_info child; 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; if (!child.nav) continue;
si->subdirs++; si->subdirs++;
if (!si->cover[0] && child.cover[0]) if (!si->cover[0] && child.cover[0])
copy_str(si->cover, (int)sizeof si->cover, child.cover); copy_str(si->cover, (int)sizeof si->cover, child.cover);
} else if (S_ISREG(st.st_mode) && mr_path_is_image(name)) { } else if (S_ISREG(st.st_mode)) {
si->pages++; if (mr_path_is_image(name)) {
if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { si->pages++;
copy_str(si->first_name, (int)sizeof si->first_name, name); if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) {
copy_str(si->cover, (int)sizeof si->cover, full); 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); 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) 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); 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; if (!lib) return -1;
mr_library_free(lib); mr_library_free(lib);
@@ -201,38 +294,22 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth)
struct stat st; struct stat st;
if (lstat(full, &st) != 0) continue; 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; int rc = 0;
scan_dir(full, 1, max_depth, &si); if (S_ISDIR(st.st_mode)) {
if (!si.nav) continue; scan_info si;
scan_dir(full, 1, max_depth, &si, archive_pages, ud);
mr_library_entry e; if (!si.nav) continue;
memset(&e, 0, sizeof e); rc = add_dir_entry(lib, name, full, &si);
if (copy_str(e.name, (int)sizeof e.name, name) != 0) continue; } else if (S_ISREG(st.st_mode) && mr_path_is_archive(name)) {
if (copy_str(e.path, (int)sizeof e.path, full) != 0) continue; int pages = archive_pages_at(archive_pages, ud, full);
copy_str(e.cover, (int)sizeof e.cover, si.cover); if (pages <= 0) continue; /* 坏档/无图片页:不当书 */
e.pages = si.pages; rc = add_archive_entry(lib, name, full, pages);
e.subdirs = si.subdirs; } else {
e.has_images = (si.pages > 0); continue;
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);
}
} }
if (lib_add(lib, &e) != 0) { if (rc != 0) {
closedir(d); closedir(d);
mr_library_free(lib); mr_library_free(lib);
return -1; return -1;
@@ -244,6 +321,11 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth)
return lib->count; 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) int mr_path_parent(const char *path, char *out, int outsz)
{ {
if (!path || !out || outsz <= 0) return -1; 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); 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; if (!out || outsz <= 0) return -1;
out[0] = '\0'; 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]; char full[MR_PATH_MAX];
if (join_path(full, (int)sizeof full, parent, name) != 0) continue; if (join_path(full, (int)sizeof full, parent, name) != 0) continue;
struct stat st; struct stat st;
if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode)) continue;
if (!mr_library_dir_has_images(full)) continue; /* 只算「一本书」,中间目录跳过 */ if (!sibling_is_book(full, &st, archive_pages, ud)) continue;
if (n >= cap) { if (n >= cap) {
int nc = cap ? cap * 2 : 16; 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); free(names);
return rc; 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);
}
+42 -11
View File
@@ -4,10 +4,12 @@
* (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化, * (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化,
* 本层只读它来拼副标题,宿主测试用配置存根顶替。) * 本层只读它来拼副标题,宿主测试用配置存根顶替。)
* 书库语义: * 书库语义:
* - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」; * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」;M11 起另列直接子文件里的
* - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含图的中间目录(可继续导航); * .cbz/.zip(档案册即一本书,与文件夹册同列自然序混排);
* - 行的 pages=该目录直接图片数;subdirs=其下含图子目录数(中间目录用于副标题); * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含书的中间目录(可继续导航);
* - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover; * - 行的 pages=该目录直接图片数(档案册=档内图片页数);subdirs=其下含图子目录数;
* - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover
* (档案册留空,UI 解压档内首图);
* - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。 * - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。
* *
* 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录 * 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录
@@ -25,13 +27,13 @@ extern "C" {
#define MR_LIB_MAX_DEPTH 8 #define MR_LIB_MAX_DEPTH 8
typedef struct mr_library_entry { typedef struct mr_library_entry {
char name[MR_NAME_MAX]; /* 目录名(行主标题) */ char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */
char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */ char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */
char cover[MR_PATH_MAX]; /* 缩略图源图(空=none) */ char cover[MR_PATH_MAX]; /* 缩略图源图(空=none;档案册由 UI 解压首图) */
char subtitle[48]; /* 预先格式化好的 "N pages" / "N folders" */ char subtitle[48]; /* 预先格式化好的 "N pages" / "N books" / "N folders" */
int pages; /* 本目录直接图片数 */ int pages; /* 本目录直接图片数(档案册=档内图片页数) */
int subdirs; /* 本目录下含图子目录数 */ int subdirs; /* 本目录下含图子目录数 */
int has_images; /* 1=本目录直接含图(点入进 reader) */ int has_images; /* 1=可直接阅读(直接含图 或 档案册) */
} mr_library_entry; } mr_library_entry;
typedef struct mr_library { typedef struct mr_library {
@@ -43,6 +45,10 @@ typedef struct mr_library {
/* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ /* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */
int mr_path_is_image(const char *name); int mr_path_is_image(const char *name);
/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。
* 注意这只是路径判据;能不能当书读还要看档案里有没有图片页(探测回调)。 */
int mr_path_is_archive(const char *name);
/* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前; /* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前;
* 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */
int mr_natural_cmp(const char *a, const char *b); 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 时按上限处理。 */ * max_depth<=0 或超过 MR_LIB_MAX_DEPTH 时按上限处理。 */
int mr_library_scan(mr_library *lib, const char *dir, int 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); void mr_library_free(mr_library *lib);
@@ -63,12 +86,20 @@ int mr_path_parent(const char *path, char *out, int outsz);
* 1=有;0=没有/不可读。 */ * 1=有;0=没有/不可读。 */
int mr_library_dir_has_images(const char *dir); int mr_library_dir_has_images(const char *dir);
/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 /* 册间衔接(M7):同父目录下按书库自然序排列的「可直接阅读」兄弟册。
* delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径;
* 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。
* 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 */ * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。
* 只算文件夹册(直接含图目录)——M7 口径不变。 */
int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); 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 #ifdef __cplusplus
} }
#endif #endif
+395
View File
@@ -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 <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
/* ============================================================ */
/* 条目过滤:图片后缀 + 路径安全 + 非隐藏 + 非目录 */
/* ============================================================ */
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);
}
+350
View File
@@ -0,0 +1,350 @@
/* logic/reader/page_cache.c — 解压页磁盘缓存实现,见 page_cache.h 的纪律说明。 */
#include "logic/reader/page_cache.h"
#include <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <unistd.h>
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;
}
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);
}
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);
if (i >= 0) {
g_tick++;
g_idx[i].used = g_tick;
pthread_mutex_unlock(&g_lock);
return 1;
}
/* 索引里没有(外部写入/索引满):磁盘上有就算命中,能装进索引就装。 */
struct stat st;
int ok = stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0;
if (ok && 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 ok ? 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;
}
+68
View File
@@ -0,0 +1,68 @@
/* logic/reader/page_cache.h — 档案册解压页的磁盘缓存(纯 C,不依赖 SDK)。
*
* 为什么需要磁盘缓存:SDK 的图像解码入口只吃文件路径(meow_img_load_file_dim),
* 而 zip 里的页是压缩流。档案页源在 open(i) 时把条目解压成缓存文件,再用同一个
* 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。
*
* 目录与上限(纪律):
* - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picobrowser/data,
* 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响);
* - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数);
* - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页);
* - 写入走 "<name>.part" + rename,进程崩溃不会留下半个缓存文件。
*
* 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或
* 同名条目重复出现时不会命中旧内容。文件名保留图片扩展名(解码入口按扩展名区分
* JPEG 的「解码期缩解」路径)。
*
* 线程:内部互斥量;可在解码线程/缩略图线程/主线程并发调用。
* 纯 C:宿主测试可直接编译(见 tuku/test/test_cbz.c)。
*/
#ifndef MR_PAGE_CACHE_H
#define MR_PAGE_CACHE_H
#include <stddef.h>
#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.picobrowser/data → /…/MeowData/os.meow.picobrowser/cache。
* 成功 0;参数非法/缓冲不足 -1。 */
int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz);
/* 建立/切换缓存目录并清孤儿。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 */
+81 -16
View File
@@ -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) static unsigned probe_be32(const unsigned char *p)
{ {
return ((unsigned)p[0] << 24) | ((unsigned)p[1] << 16) | ((unsigned)p[2] << 8) | p[3]; 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]; 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 }; 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; 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); 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; 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]; 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 (;;) { for (;;) {
int c = fgetc(f); int c = ps_getc(s);
if (c == EOF) return 0; if (c == EOF) return 0;
if (c != 0xFF) continue; if (c != 0xFF) continue;
int m = fgetc(f); int m = ps_getc(s);
while (m == 0xFF) m = fgetc(f); /* 填充字节 */ while (m == 0xFF) m = ps_getc(s); /* 填充字节 */
if (m == EOF) return 0; if (m == EOF) return 0;
if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) continue; /* 无长度段 */ if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) continue; /* 无长度段 */
if (m == 0xD9 || m == 0xDA) return 0; /* EOI/SOS 前没等到 SOF */ if (m == 0xD9 || m == 0xDA) return 0; /* EOI/SOS 前没等到 SOF */
unsigned char lb[2]; 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]; int len = ((int)lb[0] << 8) | lb[1];
if (len < 2) return 0; if (len < 2) return 0;
int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC; int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC;
if (is_sof) { if (is_sof) {
unsigned char so[5]; 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 hh = ((int)so[1] << 8) | so[2];
int ww = ((int)so[3] << 8) | so[4]; int ww = ((int)so[3] << 8) | so[4];
if (ww <= 0 || hh <= 0) return 0; if (ww <= 0 || hh <= 0) return 0;
@@ -193,14 +233,14 @@ static int probe_jpeg(FILE *f, int *w, int *h)
*h = hh; *h = hh;
return 1; 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]; 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; if (hdr[0] != 'B' || hdr[1] != 'M') return 0;
int ww = (int)((unsigned)hdr[18] | ((unsigned)hdr[19] << 8) | int ww = (int)((unsigned)hdr[18] | ((unsigned)hdr[19] << 8) |
((unsigned)hdr[20] << 16) | ((unsigned)hdr[21] << 24)); ((unsigned)hdr[20] << 16) | ((unsigned)hdr[21] << 24));
@@ -213,6 +253,32 @@ static int probe_bmp(FILE *f, int *w, int *h)
return 1; 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) int mr_probe_size(const char *path, int *w, int *h)
{ {
if (w) *w = 0; 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; if (!path || !path[0] || !w || !h) return 0;
FILE *f = fopen(path, "rb"); FILE *f = fopen(path, "rb");
if (!f) return 0; if (!f) return 0;
int ok = probe_png(f, w, h); probe_src s = { NULL, 0, 0, f };
if (!ok) { rewind(f); ok = probe_jpeg(f, w, h); } int ok = probe_any(&s, w, h);
if (!ok) { rewind(f); ok = probe_bmp(f, w, h); }
fclose(f); fclose(f);
if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; } if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; }
return ok; return ok;
+78
View File
@@ -18,6 +18,7 @@
#define MR_PAGE_SOURCE_H #define MR_PAGE_SOURCE_H
#include "logic/library.h" /* mr_path_is_image / mr_natural_cmp / MR_PATH_MAX / MR_NAME_MAX */ #include "logic/library.h" /* mr_path_is_image / mr_natural_cmp / MR_PATH_MAX / MR_NAME_MAX */
#include <stddef.h> /* size_t(mr_probe_size_mem) */
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
@@ -84,6 +85,83 @@ void mr_dir_book_bind(mr_book *b, mr_dir_book *db);
/* 纯 C 头解析:取图片原始像素尺寸(不解码像素)。 /* 纯 C 头解析:取图片原始像素尺寸(不解码像素)。
* PNG(IHDR)/ JPEG(SOF0-15)/ BMP(BITMAPINFOHEADER+)。1=已知,0=未知。 */ * PNG(IHDR)/ JPEG(SOF0-15)/ BMP(BITMAPINFOHEADER+)。1=已知,0=未知。 */
int mr_probe_size(const char *path, int *w, int *h); 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 #ifdef __cplusplus
} }
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""生成 tuku 档案册(CBZ)集成测试夹具:真实 zipfile 产物 + 定点字节修补。
用法: python3 make_cbz_samples.py <out_dir>
产出:
<out>/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 隐藏名 → 不参与
<out>/features/
pathsafe.cbz 安全/不安全条目混排(只应收 2 个安全图片页)
cjk.cbz 中文名自然序(第1页 < 第2页 < 第10页)
big.cbz 80 页 8x8 PNG(解压缓存 LRU 上限用)
bomb.cbz CD 声明单页 96MiB(应被过滤,防 zip 炸弹)
<out>/manifest.txt 每档案每条目一行:<档案相对路径>\t<条目名>\t<crc32>\t<size>
图片内容都是结构合法的 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)
# ---------------------------------------------------------------- 图片构造
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("<IHHI", 14 + 40 + len(px), 0, 0, 54)
ih = struct.pack("<IiiHHIIiiII", 40, w, h, 1, 24, 0, len(px), 2835, 2835, 0, 0)
return hdr + ih + px
def jpeg_header_bytes(w, h):
"""SOI + APP0 + DQT + SOF0 + EOI:头段合法(探测只看 SOF0),不做熵编码。"""
app0 = (b"\xFF\xE0" + 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("<HII", data, eocd + 10)
p = cd_off
for _ in range(total):
if bytes(data[p:p + 4]) != b"PK\x01\x02":
raise SystemExit("generator: bad CD signature")
nlen, elen, clen = struct.unpack_from("<HHH", data, p + 28)
got = bytes(data[p + 46:p + 46 + nlen]).decode("utf-8")
if got == name:
return p
p += 46 + nlen + elen + clen
raise SystemExit("generator: entry not found: " + name)
# ---------------------------------------------------------------- 夹具
def build_library(ro):
lib = os.path.join(ro, "library")
os.makedirs(lib, exist_ok=True)
os.makedirs(os.path.join(lib, "bookA"), exist_ok=True)
with open(os.path.join(lib, "bookA", "001.png"), "wb") as f:
f.write(png_bytes(30, 20, 1))
with open(os.path.join(lib, "bookA", "002.png"), "wb") as f:
f.write(png_bytes(30, 21, 2))
write_zip(os.path.join(lib, "vol2.cbz"), [
("ch01/p10.png", png_bytes(40, 30, 3), zipfile.ZIP_DEFLATED),
("readme.txt", b"not a page\n", zipfile.ZIP_STORED),
("ch01/p2.png", png_bytes(41, 31, 4), zipfile.ZIP_DEFLATED),
("onlydir/", b"", zipfile.ZIP_STORED),
("\u4e2d\u6587\u540d.png", png_bytes(12, 34, 5), zipfile.ZIP_DEFLATED),
("empty.bin", b"", zipfile.ZIP_DEFLATED),
])
write_zip(os.path.join(lib, "Vol10.cbz"), [
("cover.bmp", bmp_bytes(7, 9), zipfile.ZIP_STORED),
])
os.makedirs(os.path.join(lib, "box"), exist_ok=True)
write_zip(os.path.join(lib, "box", "box1.cbz"), [
("a.png", png_bytes(8, 8, 6), zipfile.ZIP_DEFLATED),
("b.png", png_bytes(8, 9, 7), zipfile.ZIP_DEFLATED),
])
write_zip(os.path.join(lib, "box", "box2.cbz"), [
("c.jpg", jpeg_header_bytes(12, 34), zipfile.ZIP_DEFLATED),
])
write_zip(os.path.join(lib, "notabook.zip"), [
("readme.txt", b"nothing to read\n", zipfile.ZIP_STORED),
])
write_zip(os.path.join(lib, "emptypages.cbz"), [
("onlydir/", b"", zipfile.ZIP_STORED),
("readme.txt", b"x\n", zipfile.ZIP_STORED),
])
with open(os.path.join(lib, "broken.cbz"), "wb") as f:
f.write(b"NOTAZIP!" * 100)
raw = open(os.path.join(lib, "vol2.cbz"), "rb").read()
with open(os.path.join(lib, "truncated.cbz"), "wb") as f:
f.write(raw[:len(raw) // 2])
write_zip(os.path.join(lib, ".hidden.cbz"), [
("h.png", png_bytes(8, 8, 8), zipfile.ZIP_DEFLATED),
])
def build_features(ro):
feat = os.path.join(ro, "features")
os.makedirs(feat, exist_ok=True)
write_zip(os.path.join(feat, "pathsafe.cbz"), [
("ok1.png", png_bytes(10, 10, 9), zipfile.ZIP_DEFLATED),
("../evil.png", png_bytes(10, 10, 10), zipfile.ZIP_DEFLATED),
("/abs.png", png_bytes(10, 10, 11), zipfile.ZIP_DEFLATED),
("a/../../b.png", png_bytes(10, 10, 12), zipfile.ZIP_DEFLATED),
("sub/ok2.jpg", jpeg_header_bytes(13, 17), zipfile.ZIP_DEFLATED),
("sub/../ok3.png", png_bytes(10, 10, 13), zipfile.ZIP_DEFLATED),
(".hidden/x.png", png_bytes(10, 10, 14), zipfile.ZIP_DEFLATED),
("._mac.png", png_bytes(10, 10, 15), zipfile.ZIP_DEFLATED),
("sub\\win.png", png_bytes(10, 10, 16), zipfile.ZIP_DEFLATED),
("text.txt", b"nope\n", zipfile.ZIP_STORED),
("dir/", b"", zipfile.ZIP_STORED),
])
write_zip(os.path.join(feat, "cjk.cbz"), [
("\u7b2c2\u9875.png", png_bytes(9, 9, 17), zipfile.ZIP_DEFLATED),
("\u7b2c10\u9875.png", png_bytes(9, 9, 18), zipfile.ZIP_DEFLATED),
("\u7b2c1\u9875.png", png_bytes(9, 9, 19), zipfile.ZIP_DEFLATED),
])
entries = []
for i in range(80):
entries.append(("p%03d.png" % i, png_bytes(8, 8, 20 + i), zipfile.ZIP_DEFLATED))
write_zip(os.path.join(feat, "big.cbz"), entries)
bomb = os.path.join(feat, "bomb.cbz")
write_zip(bomb, [("huge.png", png_bytes(10, 10, 30), zipfile.ZIP_STORED)])
data = bytearray(open(bomb, "rb").read())
rec = find_cd_record(data, "huge.png")
struct.pack_into("<I", data, rec + 24, 0x06000000) # 96MiB > 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])
def write_manifest(ro):
"""每档案每条目:<相对路径>\t<条目名>\t<crc32>\t<size>(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
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 <out_dir>")
ro = sys.argv[1]
os.makedirs(ro, exist_ok=True)
build_library(ro)
build_features(ro)
write_manifest(ro)
if __name__ == "__main__":
main()
+13
View File
@@ -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
+4
View File
@@ -52,3 +52,7 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then
else else
echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)"
fi fi
# ---- 档案册(M11):zip 容器 + CBZ 书库/阅读器集成 ----
./test/run_zip_tests.sh
./test/run_cbz_tests.sh
+4 -2
View File
@@ -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 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" \ gcc $CFLAGS -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I"$SDK/include" \
test/test_mode_switch_host.c ui/reader/reader.c \ 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_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_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 \ "$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_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_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 \ "$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" "$OUT/test_mode_switch_host"
else else
echo "SKIP test_mode_switch_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" echo "SKIP test_mode_switch_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)"
+847
View File
@@ -0,0 +1,847 @@
/* 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/<pkg>/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 <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <zlib.h>
#define PATH_CAP 1024
#define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */
static int g_pass;
static int g_fail;
static char g_root[PATH_CAP]; /* 夹具根 */
static char g_lib[PATH_CAP]; /* <root>/library */
static char g_feat[PATH_CAP]; /* <root>/features */
static char g_data[PATH_CAP]; /* <root>/app/os.meow.picobrowser/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<crc32>\t<size> */
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);
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];
joinp(path, sizeof path, g_feat, "big.cbz");
/* ---- (a) 64 页 LRU ---- */
joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picobrowser/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");
mr_zip_book_free(&zb);
/* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */
joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picobrowser/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) 缓存不可用时优雅失败(不崩、不写文件) ---- */
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.picobrowser/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.picobrowser/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;
}
+37 -23
View File
@@ -1,21 +1,24 @@
/* ui/library/library.c — 图库根页(ACT_LIBRARY): /* ui/library/library.c — 图库根页(ACT_LIBRARY):
* *
* TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。 * TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。
* 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册)。 * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册);
* 行=缩略图 + 目录名 + 页数;目录由 logic/library 扫描并自然排序。 * M11 起 .cbz/.zip 也作为「册」与文件夹并列(行=封面+文件名+页数,点入直接阅读)。
* 行数据由 logic/library 扫描并自然排序;档案册页数/首图由 logic/reader 的 zip 页源提供。
* *
* 流畅度(用户指令优先): * 流畅度(用户指令优先):
* - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码, * - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码,
* 完成后在 on_tick 补位并只 invalidate 列表视口; * 完成后在 on_tick 补位并只 invalidate 列表视口;
* - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData/<pkg>/cache * - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData/<pkg>/cache
* (BMP 原始缩略图,文件名含源文件 mtime+size,源文件变化自动失效;≥64 张按 * (BMP 原始缩略图,文件名含源 mtime+size,源文件变化自动失效;≥64 张按
* mtime LRU 删除); * mtime LRU 删除);档案册封面=档内首图,先解压到同一 cache(page_cache 管 LRU);
* - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。 * - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。
* *
* 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的 * 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的
* listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。 * listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。
*/ */
#include "tuku_internal.h" #include "tuku_internal.h"
#include "logic/reader/page_source.h"
#include "logic/reader/page_cache.h"
#include <dirent.h> #include <dirent.h>
#include <errno.h> #include <errno.h>
@@ -106,20 +109,10 @@ static void cache_init(void)
const char *data = meow_config_dir(); const char *data = meow_config_dir();
if (!data || !data[0]) return; if (!data || !data[0]) return;
int n = snprintf(g_cache_dir, sizeof g_cache_dir, "%s", data); /* 缩略图缓存与解压页缓存同目录(MeowData/<pkg>/cache)。目录推导、建立与
if (n <= 0 || n >= (int)sizeof g_cache_dir) { g_cache_dir[0] = '\0'; return; } * 启动清孤儿统一交给 logic/reader/page_cache,避免两处目录规则分叉。 */
if (mr_cache_init(data) != 0 || mr_cache_dir()[0] == '\0') return;
char *slash = strrchr(g_cache_dir, '/'); snprintf(g_cache_dir, sizeof g_cache_dir, "%s", mr_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;
}
g_cache_on = 1; g_cache_on = 1;
} }
@@ -220,12 +213,21 @@ static int write_bmp(const char *path, const meow_img *im)
return ok ? 0 : -1; return ok ? 0 : -1;
} }
/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 */ /* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。
* 档案册(M11)的 cover 是 .cbz/.zip 路径:先解压档内首图到 page_cache,
* 再按普通图片路径解码;缩略图缓存键仍用档案本身(mtime/size 变化即失效)。 */
static meow_img *thumb_load(const char *cover) static meow_img *thumb_load(const char *cover)
{ {
struct stat st; struct stat st;
if (stat(cover, &st) != 0 || !S_ISREG(st.st_mode)) return NULL; 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]; char cp[MR_PATH_MAX + 96];
if (g_cache_on && g_cache_dir[0]) { if (g_cache_on && g_cache_dir[0]) {
cache_path(cp, (int)sizeof cp, cover, &st); cache_path(cp, (int)sizeof cp, cover, &st);
@@ -239,7 +241,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; if (!im) return NULL;
meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); meow_img *small = meow_img_downscale(im, MR_THUMB_DIM);
if (small) { meow_img_free(im); im = small; } if (small) { meow_img_free(im); im = small; }
@@ -380,7 +382,11 @@ static void lib_bind(int idx, meow_lv_item *item, void *ctx)
item->icon = im; item->icon = im;
} else { } else {
item->icon_kind = e->has_images ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; 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 +417,7 @@ static void list_rebuild(void)
/* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式:
* 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */
meow_listview_set_container(g_lv, 1); 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); g_scroll = meow_scroll_wrap(g_lv, 1);
if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H);
@@ -446,12 +452,20 @@ static void reset_async(void)
pthread_mutex_unlock(&g_lock); 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) static void load_dir(const char *dir)
{ {
snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir);
reset_async(); reset_async();
thumbs_free(); 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); mr_library_free(&g_lib);
meow_log(MEOW_LOG_WARN, "library scan failed: %s", g_cur_dir); meow_log(MEOW_LOG_WARN, "library scan failed: %s", g_cur_dir);
} }
+25 -11
View File
@@ -39,6 +39,7 @@
#include "tuku_internal.h" #include "tuku_internal.h"
#include "ui/reader/reader.h" #include "ui/reader/reader.h"
#include "logic/reader/page_source.h" #include "logic/reader/page_source.h"
#include "logic/reader/page_cache.h"
#include "logic/reader/nav.h" #include "logic/reader/nav.h"
#include <pthread.h> #include <pthread.h>
@@ -74,7 +75,7 @@
static mr_page_ctrl g_ctrl; static mr_page_ctrl g_ctrl;
static char g_dir[MR_PATH_MAX]; 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 mr_book g_book;
static int g_count; /* 0=空册/不可读 */ static int g_count; /* 0=空册/不可读 */
static int g_open_err; 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; int w = g_vw, h = g_vh;
if (g_open_err) { 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); meow_ui_text_center(w / 2, h / 2 + 24, g_dir, th->font_body, th->onSurfaceVariant);
return; return;
} }
@@ -1152,8 +1153,7 @@ static void open_book_reset_state(void)
mr_strip_free(&g_strip); mr_strip_free(&g_strip);
g_strip_ok = 0; g_strip_ok = 0;
slots_free_all(); slots_free_all();
mr_book_destroy(&g_book); mr_book_close_auto(&g_book, &g_handle);
mr_dir_book_free(&g_db);
if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; }
g_gen++; /* 作废旧册的异步结果 */ g_gen++; /* 作废旧册的异步结果 */
g_count = 0; 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; g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED;
int maxdim = g_vw > g_vh ? g_vw : g_vh; int maxdim = g_vw > g_vh ? g_vw : g_vh;
int n = mr_dir_book_init(&g_db, g_dir, maxdim, /* 目录 → 目录册;.cbz/.zip → 档案册(M11)。两者同一个 mr_book 契约,
reader_load_page, reader_unload_page, NULL); * 下面的翻页/条漫消费代码完全不区分 provider。 */
int n = mr_book_open_auto(&g_book, &g_handle, g_dir, maxdim,
reader_load_page, reader_unload_page, NULL);
if (n < 0) { if (n < 0) {
mr_dir_book_bind(&g_book, NULL);
g_open_err = 1; g_open_err = 1;
worker_start(); worker_start();
update_title(); update_title();
@@ -1195,7 +1196,6 @@ static int open_book(const char *dir, int start_page, int mode)
return 0; return 0;
} }
g_count = n; 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; int page = start_page < 0 ? g_count - 1 : start_page;
page = mr_nav_clamp_page(page, g_count); 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; 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) static int book_switch(int delta)
{ {
if (delta == 0) return 0; if (delta == 0) return 0;
if (g_count <= 0) return 0; if (g_count <= 0) return 0;
char next[MR_PATH_MAX]; 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) { if (r != 1) {
toast(delta > 0 ? "已是最后一册" : "已是第一册"); toast(delta > 0 ? "已是最后一册" : "已是第一册");
return 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); if (g_tb) meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOP_BAR_H);
/* 档案册解压缓存(M11):MeowData/<pkg>/cache。library 页通常已初始化,
* 这里幂等兜底(进程重启后直接进阅读器、或交付环境只剩 reader 入口)。 */
{
const char *data = meow_config_dir();
if (data && data[0]) mr_cache_init(data);
}
/* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ /* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */
int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED);
int page = mr_cont_get_page(g_dir, 0); 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); mr_strip_free(&g_strip);
g_strip_ok = 0; g_strip_ok = 0;
slots_free_all(); slots_free_all();
mr_book_destroy(&g_book); mr_book_close_auto(&g_book, &g_handle);
mr_dir_book_free(&g_db);
if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; }
if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; }