feat(tuku): 阅读器左右翻页核心——页源契约/预解码窗口/跟手翻页/方向感知 tap

- 页源契约(logic/reader):count/meta/open/close + 目录册(文件夹即书)为第一个实现;
  meta 由纯 C 图片头探测(PNG IHDR/JPEG SOF/BMP)提供原始尺寸,条漫可先排版再取图,
  zip 册(M9/M11)只需另写 mr_book_ops,两种消费方式零改动复用
- 翻页引擎:pthread 后台解码窗口=当前±1 页(目录册 downscale 到屏幕长边),翻页只 blit
  缓存页图 + meow_anim 150ms 滑动吸附;未命中立即切页显示加载态、不在翻页路径解码;
  只 flush 内容区;decode/flip 首帧/翻页完成/进出内存全部 meow_log 打点
- 阅读页 UI:fit 三档(适宽/适高/原始,TopAppBar 动作循环,标题显示页码 x/y)+
  双击以点击点为锚放大(临时缓冲超上限自动降倍率)+ 单指拖拽/滑动步进平移;
  tap 三分区方向感知(RTL 默认:左=下一页,LTR 反之),中区切 chrome;
  左缘返回交给 app.c、屏底热区交给 SDK 回桌面,两者都不拦截
- 跟手:app.c 仅补 2 处极小路由(ACT_READER 的 down/move),reader 自行算位移;
  屏底 28px/左缘 24px 不进拖动,竖向主导放弃横滑
- 内存纪律:驻留 ≤3 张 downscale 页图;放大时窗口收缩到当前页;on_leave 停解码线程、
  释放全部页图/册索引/TopAppBar,并记录 VmRSS/MemAvailable 前后对比
- 测试/构建:新增 test_reader_core(契约边界/空册/越界/重复 open-close/首末页/头探测/
  方向语义/fit/平移夹取,108 项);run_tests.sh 与 build_tuku.sh 源列表接线;
  宿主 46+108 全 PASS,交叉构建静态 MIPS32r2 ELF、-Wall -Wextra 零警告
This commit is contained in:
AskaEth committed 2026-11-04 20:58:05 +08:00
1 parent 28b67a7b8d
commit 23c9db73df
10 files changed
+2088 -69

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
/* logic/reader/nav.c — 阅读导航纯逻辑实现(见 nav.h 的方向语义说明)。 */
#include "logic/reader/nav.h"
int mr_nav_tap_zone(int x, int view_w)
{
if (view_w <= 0) return MR_ZONE_MIDDLE;
if (x < view_w / 3) return MR_ZONE_LEFT;
if (x >= (view_w * 2) / 3) return MR_ZONE_RIGHT;
return MR_ZONE_MIDDLE;
}
int mr_nav_tap_delta(int rtl, int zone)
{
if (zone == MR_ZONE_MIDDLE) return 0;
if (rtl) return (zone == MR_ZONE_LEFT) ? 1 : -1; /* RTL:左进右退 */
return (zone == MR_ZONE_LEFT) ? -1 : 1; /* LTR:右进左退 */
}
int mr_nav_swipe_delta(int rtl, int dx)
{
if (dx == 0) return 0;
if (rtl) return dx > 0 ? 1 : -1;
return dx > 0 ? -1 : 1;
}
int mr_nav_clamp_page(int page, int count)
{
if (count <= 0) return -1;
if (page < 0) return 0;
if (page >= count) return count - 1;
return page;
}
int mr_nav_commit_dist(int view_w)
{
int t = view_w / 4;
if (t < 60) t = 60;
if (t > 160) t = 160;
return t;
}
int mr_nav_commit(int dx, int view_w)
{
int adx = dx < 0 ? -dx : dx;
return adx >= mr_nav_commit_dist(view_w);
}
int mr_nav_fit_size(int fit, int src_w, int src_h, int vw, int vh,
int *out_w, int *out_h)
{
if (!out_w || !out_h || src_w <= 0 || src_h <= 0 || vw <= 0 || vh <= 0) return -1;
long long w, h;
if (fit == MR_FIT_ORIGINAL) {
w = src_w;
h = src_h;
} else if (fit == MR_FIT_HEIGHT) {
h = vh;
w = (long long)src_w * vh / src_h;
} else {
w = vw;
h = (long long)src_h * vw / src_w;
}
if (w < 1) w = 1;
if (h < 1) h = 1;
*out_w = (int)w;
*out_h = (int)h;
return 0;
}
int mr_nav_zoom_size(int w, int h, int zoom_pct, int *out_w, int *out_h)
{
if (!out_w || !out_h || w <= 0 || h <= 0 || zoom_pct <= 0) return -1;
long long ww = (long long)w * zoom_pct / 100;
long long hh = (long long)h * zoom_pct / 100;
if (ww < 1) ww = 1;
if (hh < 1) hh = 1;
*out_w = (int)ww;
*out_h = (int)hh;
return 0;
}
void mr_nav_pan_limit(int content_w, int content_h, int vw, int vh,
int *lim_x, int *lim_y)
{
int lx = content_w - vw;
int ly = content_h - vh;
if (lx < 0) lx = 0;
if (ly < 0) ly = 0;
/* 平移量是「中心偏移」:可左右各移动溢出量的一半,两边才都看得到。 */
if (lim_x) *lim_x = lx / 2;
if (lim_y) *lim_y = ly / 2;
}
void mr_nav_pan_clamp(int content_w, int content_h, int vw, int vh,
int *pan_x, int *pan_y)
{
int lx, ly;
mr_nav_pan_limit(content_w, content_h, vw, vh, &lx, &ly);
if (pan_x) {
if (*pan_x > lx) *pan_x = lx;
if (*pan_x < -lx) *pan_x = -lx;
}
if (pan_y) {
if (*pan_y > ly) *pan_y = ly;
if (*pan_y < -ly) *pan_y = -ly;
}
}
int mr_nav_slide_from(int rtl, int delta, int view_w)
{
if (!delta) return 0;
int from = rtl ? -delta : delta;
return from * view_w;
}
int mr_nav_fit_next(int fit)
{
if (fit < 0 || fit >= MR_FIT_COUNT) return MR_FIT_WIDTH;
return (fit + 1) % MR_FIT_COUNT;
}
+70
View File
@@ -0,0 +1,70 @@
/* logic/reader/nav.h — 阅读导航纯逻辑(方向语义 / tap 分区 / fit / 平移 clamp)。
*
* 纯 C、无 SDK 依赖:reader.c 与宿主测试共用同一实现(logic/reader/nav.c)。
*
* 方向语义(mr_reader_get_direction,默认 RTL):
* - LTR(左→右读):页码沿屏幕向右递增;屏左 1/3 = 上一页,屏右 1/3 = 下一页;
* 手指向右拖露出左边那页 = 上一页。
* - RTL(日漫,默认):页码沿屏幕向左递增;屏左 1/3 = **下一页**,屏右 1/3 = 上一页;
* 手指向右拖露出左边那页 = 下一页。
* 图像本身不做镜像(RTL 只影响翻页语义)。
*/
#ifndef MR_READER_NAV_H
#define MR_READER_NAV_H
#ifdef __cplusplus
extern "C" {
#endif
/* ---- fit 三档 ---- */
#define MR_FIT_WIDTH 0 /* 适宽:撑满屏宽 */
#define MR_FIT_HEIGHT 1 /* 适高:撑满屏高 */
#define MR_FIT_ORIGINAL 2 /* 原始:1:1 像素 */
#define MR_FIT_COUNT 3
/* ---- tap 分区(按屏幕横向三分) ---- */
#define MR_ZONE_LEFT 0
#define MR_ZONE_MIDDLE 1
#define MR_ZONE_RIGHT 2
/* 屏宽三分:左/中/右。view_w<=0 返回 MIDDLE。 */
int mr_nav_tap_zone(int x, int view_w);
/* tap 分区的页增量:+1=下一页 / -1=上一页 / 0=chrome 显隐(中区)。 */
int mr_nav_tap_delta(int rtl, int zone);
/* 中部横向拖动的页增量。dx>0 = 手指向右(露出屏幕左侧那页)。
* 首/末页不在这里判断——调用方用 mr_nav_clamp_page 落到实际目标页。 */
int mr_nav_swipe_delta(int rtl, int dx);
/* 页下标 clamp;count<=0 返回 -1。 */
int mr_nav_clamp_page(int page, int count);
/* 翻页提交阈值:视口宽的 1/4,夹在 [60,160] px。 */
int mr_nav_commit_dist(int view_w);
/* 松手位移是否达到翻页阈值。 */
int mr_nav_commit(int dx, int view_w);
/* fit 目标尺寸(未含 zoom)。0=成功;参数非法 -1。 */
int mr_nav_fit_size(int fit, int src_w, int src_h, int vw, int vh, int *out_w, int *out_h);
/* 按百分比放大(zoom_pct=100 原样;200=2×)。0=成功。 */
int mr_nav_zoom_size(int w, int h, int zoom_pct, int *out_w, int *out_h);
/* 平移半幅:内容比视口每侧多出的像素(>=0)。内容不溢出该轴 → 0(该轴不可平移)。 */
void mr_nav_pan_limit(int content_w, int content_h, int vw, int vh, int *lim_x, int *lim_y);
/* 把平移量夹到 ±半幅。 */
void mr_nav_pan_clamp(int content_w, int content_h, int vw, int vh, int *pan_x, int *pan_y);
/* 翻页动画的进场起点(px):下一页在 RTL 从左侧进、LTR 从右侧进。delta=0 返回 0。 */
int mr_nav_slide_from(int rtl, int delta, int view_w);
/* fit 档循环:适宽 → 适高 → 原始 → 适宽。 */
int mr_nav_fit_next(int fit);
#ifdef __cplusplus
}
#endif
#endif /* MR_READER_NAV_H */
+282
View File
@@ -0,0 +1,282 @@
/* logic/reader/page_source.c — 页源契约实现(纯 C):目录册 + 图片头探测。
*
* 契约与两种消费方式的说明见 page_source.h。本文件不含 SDK 依赖:解码/释放由
* UI 层注回回调,因此可在宿主测试里用假回调覆盖「空册/越界/重复 open-close/首末页」。
*/
#include "logic/reader/page_source.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
/* ============================================================ */
/* 契约包装 */
/* ============================================================ */
int mr_book_valid(const mr_book *b)
{
return b && b->ops && b->ops->count && b->ops->open;
}
int mr_book_count(const mr_book *b)
{
if (!mr_book_valid(b)) return 0;
int n = b->ops->count(b->ctx);
return n > 0 ? n : 0;
}
int mr_book_meta(const mr_book *b, int index, int *w, int *h)
{
if (w) *w = 0;
if (h) *h = 0;
if (!mr_book_valid(b) || !b->ops->meta) return 0;
if (index < 0 || index >= mr_book_count(b)) return 0;
return b->ops->meta(b->ctx, index, w, h) ? 1 : 0;
}
meow_img *mr_book_open(mr_book *b, int index)
{
if (!mr_book_valid(b)) return NULL;
if (index < 0 || index >= mr_book_count(b)) return NULL; /* 越界:契约边界 */
return b->ops->open(b->ctx, index);
}
void mr_book_close(mr_book *b, meow_img *img)
{
if (!img) return;
if (!mr_book_valid(b) || !b->ops->close) return;
b->ops->close(b->ctx, img);
}
void mr_book_destroy(mr_book *b)
{
if (!b) return;
if (b->ops && b->ops->destroy && b->ctx) b->ops->destroy(b->ctx);
b->ops = NULL;
b->ctx = NULL;
}
/* ============================================================ */
/* 目录册 */
/* ============================================================ */
static int name_cmp_qsort(const void *pa, const void *pb)
{
const char *a = *(const char *const *)pa;
const char *b = *(const char *const *)pb;
return mr_natural_cmp(a, b);
}
void mr_dir_book_path(const mr_dir_book *db, int index, char *out, int outsz)
{
if (!out || outsz <= 0) return;
out[0] = '\0';
if (!db || index < 0 || index >= db->count || !db->names[index]) return;
size_t dl = strlen(db->dir);
if (dl > 0 && db->dir[dl - 1] == '/')
snprintf(out, (size_t)outsz, "%s%s", db->dir, db->names[index]);
else
snprintf(out, (size_t)outsz, "%s/%s", db->dir, db->names[index]);
}
void mr_dir_book_free(mr_dir_book *db)
{
if (!db) return;
for (int i = 0; i < db->count; i++) free(db->names[i]);
free(db->names);
db->names = NULL;
db->count = 0;
}
int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim,
mr_book_load_fn load, mr_book_unload_fn unload, void *ud)
{
if (!db) return -1;
memset(db, 0, sizeof *db);
if (!dir || !dir[0] || !load || !unload) return -1;
if (maxdim <= 0) maxdim = 720;
if (strlen(dir) >= sizeof db->dir) return -1;
DIR *d = opendir(dir);
if (!d) return -1;
snprintf(db->dir, sizeof db->dir, "%s", dir);
db->maxdim = maxdim;
db->load = load;
db->unload = unload;
db->ud = ud;
int cap = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
const char *name = de->d_name;
if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue;
if (name[0] == '.') continue; /* 隐藏文件不参与成册 */
if (!mr_path_is_image(name)) continue;
char full[MR_PATH_MAX];
size_t dl = strlen(db->dir);
int n = (dl > 0 && db->dir[dl - 1] == '/')
? snprintf(full, sizeof full, "%s%s", db->dir, name)
: snprintf(full, sizeof full, "%s/%s", db->dir, name);
if (n <= 0 || n >= (int)sizeof full) continue;
struct stat st;
if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; /* 跳过符号链接/目录 */
if (db->count >= MR_BOOK_MAX_PAGES) break;
if (db->count >= cap) {
int nc = cap ? cap * 2 : 32;
char **nn = (char **)realloc(db->names, (size_t)nc * sizeof *nn);
if (!nn) { mr_dir_book_free(db); closedir(d); return -1; }
db->names = nn;
cap = nc;
}
size_t len = strlen(name);
db->names[db->count] = (char *)malloc(len + 1);
if (!db->names[db->count]) { mr_dir_book_free(db); closedir(d); return -1; }
memcpy(db->names[db->count], name, len + 1);
db->count++;
}
closedir(d);
if (db->count > 1)
qsort(db->names, (size_t)db->count, sizeof *db->names, name_cmp_qsort);
return db->count;
}
/* ============================================================ */
/* 图片头探测(不解码像素) */
/* ============================================================ */
static unsigned probe_be32(const unsigned char *p)
{
return ((unsigned)p[0] << 24) | ((unsigned)p[1] << 16) | ((unsigned)p[2] << 8) | p[3];
}
static int probe_png(FILE *f, int *w, int *h)
{
unsigned char hdr[24];
if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0;
static const unsigned char sig[8] = { 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A };
if (memcmp(hdr, sig, 8) != 0 || memcmp(hdr + 12, "IHDR", 4) != 0) return 0;
unsigned ww = probe_be32(hdr + 16), hh = probe_be32(hdr + 20);
if (ww == 0 || hh == 0) return 0;
*w = (int)ww;
*h = (int)hh;
return 1;
}
static int probe_jpeg(FILE *f, int *w, int *h)
{
unsigned char b[2];
if (fread(b, 1, 2, f) != 2 || b[0] != 0xFF || b[1] != 0xD8) return 0;
for (;;) {
int c = fgetc(f);
if (c == EOF) return 0;
if (c != 0xFF) continue;
int m = fgetc(f);
while (m == 0xFF) m = fgetc(f); /* 填充字节 */
if (m == EOF) return 0;
if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) continue; /* 无长度段 */
if (m == 0xD9 || m == 0xDA) return 0; /* EOI/SOS 前没等到 SOF */
unsigned char lb[2];
if (fread(lb, 1, 2, f) != 2) return 0;
int len = ((int)lb[0] << 8) | lb[1];
if (len < 2) return 0;
int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC;
if (is_sof) {
unsigned char so[5];
if (len < 7 || fread(so, 1, 5, f) != 5) return 0;
int hh = ((int)so[1] << 8) | so[2];
int ww = ((int)so[3] << 8) | so[4];
if (ww <= 0 || hh <= 0) return 0;
*w = ww;
*h = hh;
return 1;
}
if (fseek(f, len - 2, SEEK_CUR) != 0) return 0;
}
}
static int probe_bmp(FILE *f, int *w, int *h)
{
unsigned char hdr[26];
if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0;
if (hdr[0] != 'B' || hdr[1] != 'M') return 0;
int ww = (int)((unsigned)hdr[18] | ((unsigned)hdr[19] << 8) |
((unsigned)hdr[20] << 16) | ((unsigned)hdr[21] << 24));
int hh = (int)((unsigned)hdr[22] | ((unsigned)hdr[23] << 8) |
((unsigned)hdr[24] << 16) | ((unsigned)hdr[25] << 24));
if (hh < 0) hh = -hh; /* 自底向上 BMP */
if (ww <= 0 || hh <= 0) return 0;
*w = ww;
*h = hh;
return 1;
}
int mr_probe_size(const char *path, int *w, int *h)
{
if (w) *w = 0;
if (h) *h = 0;
if (!path || !path[0] || !w || !h) return 0;
FILE *f = fopen(path, "rb");
if (!f) return 0;
int ok = probe_png(f, w, h);
if (!ok) { rewind(f); ok = probe_jpeg(f, w, h); }
if (!ok) { rewind(f); ok = probe_bmp(f, w, h); }
fclose(f);
if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; }
return ok;
}
/* ============================================================ */
/* mr_book_ops 适配器(目录册) */
/* ============================================================ */
static int dir_ops_count(void *ctx)
{
mr_dir_book *db = (mr_dir_book *)ctx;
return db ? db->count : 0;
}
static int dir_ops_meta(void *ctx, int index, int *w, int *h)
{
mr_dir_book *db = (mr_dir_book *)ctx;
if (w) *w = 0;
if (h) *h = 0;
if (!db || index < 0 || index >= db->count) return 0;
char path[MR_PATH_MAX];
mr_dir_book_path(db, index, path, (int)sizeof path);
return mr_probe_size(path, w, h);
}
static meow_img *dir_ops_open(void *ctx, int index)
{
mr_dir_book *db = (mr_dir_book *)ctx;
if (!db || index < 0 || index >= db->count) return NULL;
char path[MR_PATH_MAX];
mr_dir_book_path(db, index, path, (int)sizeof path);
if (!path[0]) return NULL;
return db->load(path, db->ud, db->maxdim);
}
static void dir_ops_close(void *ctx, meow_img *img)
{
mr_dir_book *db = (mr_dir_book *)ctx;
if (db && db->unload && img) db->unload(img);
}
static void dir_ops_destroy(void *ctx)
{
mr_dir_book *db = (mr_dir_book *)ctx;
if (db) mr_dir_book_free(db);
}
static const mr_book_ops g_dir_ops = {
dir_ops_count, dir_ops_meta, dir_ops_open, dir_ops_close, dir_ops_destroy,
};
void mr_dir_book_bind(mr_book *b, mr_dir_book *db)
{
if (!b) return;
b->ops = db ? &g_dir_ops : NULL;
b->ctx = db;
}
+91
View File
@@ -0,0 +1,91 @@
/* logic/reader/page_source.h — 页源契约(纯 C,不依赖 meow_ui.h)。
*
* 一册 = 有序页序列。契约只有四件事:
* count 总页数
* meta(i) 页的原始像素尺寸(可选;条漫连续滚动先用它排版,未知时返回 0)
* open(i) 取第 i 页:解码 + downscale 后返回 meow_img*(调用方拥有,必须 close)
* close(img) 释放 open 返回的图像
*
* 两种消费方式共用同一契约,后续实现零改动:
* - 左右翻页(本任务):预解码窗口 = 当前 ±1 页,翻页只 blit 窗口内已备好的图;
* - 条漫连续滚动(M7):先 meta(i) 累加高度排版,再按滚动位置 open(视口±1 页)。
* zip 册(M9/M11)只需另写一套 mr_book_ops,目录册是第一个实现。
*
* meow_img 在 logic 层是前向声明的不透明句柄;解码/释放由 UI 层注入回调
* (logic 不 include SDK,宿主测试用假回调即可覆盖契约边界)。
*/
#ifndef 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 */
#ifdef __cplusplus
extern "C" {
#endif
struct meow_img;
typedef struct meow_img meow_img; /* SDK 不透明句柄;logic 层只透传 */
#define MR_BOOK_MAX_PAGES 4096 /* 单册页数上限(防坏卡/巨目录撑爆内存) */
/* 解码回调(UI 层注入):解码 path,长边 >maxdim 时等比 downscale;失败返回 NULL。
* 必须可在后台线程调用(宿主测试用假实现)。 */
typedef meow_img *(*mr_book_load_fn)(const char *path, void *ud, int maxdim);
/* 释放 open 返回的图像(UI 层注入 meow_img_free;宿主测试用假实现计数)。 */
typedef void (*mr_book_unload_fn)(meow_img *img);
typedef struct mr_book_ops {
int (*count)(void *ctx);
/* 页原始尺寸(未 downscale);1=已知,0=未知。可为 NULL。 */
int (*meta)(void *ctx, int index, int *w, int *h);
/* 取页:越界/解码失败返回 NULL。返回值的所有权交给调用方。 */
meow_img *(*open)(void *ctx, int index);
/* 释放 open 返回的图像;须容忍 NULL。 */
void (*close)(void *ctx, meow_img *img);
/* 释放 provider 自身(册内索引等)。可为 NULL。 */
void (*destroy)(void *ctx);
} mr_book_ops;
typedef struct mr_book {
const mr_book_ops *ops;
void *ctx;
} mr_book;
/* ---- 契约包装:所有入口对 NULL/越界安全(越界 open 返回 NULL) ---- */
int mr_book_valid(const mr_book *b);
int mr_book_count(const mr_book *b);
int mr_book_meta(const mr_book *b, int index, int *w, int *h);
meow_img *mr_book_open(mr_book *b, int index);
void mr_book_close(mr_book *b, meow_img *img);
/* 释放 provider 自身(不含已 open 的图像——那些由调用方先 close)。幂等。 */
void mr_book_destroy(mr_book *b);
/* ---- 目录册(文件夹即书):目录直接子文件里的图片按自然序成册 ---- */
typedef struct mr_dir_book {
char dir[MR_PATH_MAX];
char **names; /* 自然序文件名(堆分配) */
int count;
int maxdim; /* open 时传给解码回调的长边上限 */
mr_book_load_fn load;
mr_book_unload_fn unload;
void *ud; /* 解码回调的宿主上下文 */
} mr_dir_book;
/* 扫描 dir 的直接子文件,收图片名并自然排序。
* 返回页数(>=0);参数非法 / 目录不可读 / 内存不足返回 -1(db 已清空、可安全 free)。 */
int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim,
mr_book_load_fn load, mr_book_unload_fn unload, void *ud);
void mr_dir_book_free(mr_dir_book *db);
/* 第 index 页的完整路径;越界时输出空串。 */
void mr_dir_book_path(const mr_dir_book *db, int index, char *out, int outsz);
/* 把目录册绑定到通用 mr_book(db 生命周期由调用方持有)。 */
void mr_dir_book_bind(mr_book *b, mr_dir_book *db);
/* 纯 C 头解析:取图片原始像素尺寸(不解码像素)。
* PNG(IHDR)/ JPEG(SOF0-15)/ BMP(BITMAPINFOHEADER+)。1=已知,0=未知。 */
int mr_probe_size(const char *path, int *w, int *h);
#ifdef __cplusplus
}
#endif
#endif /* MR_PAGE_SOURCE_H */