frond-js 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +247 -0
  3. package/README.md +211 -0
  4. package/dist/abort-BY8vBk0v.d.cts +99 -0
  5. package/dist/abort-BY8vBk0v.d.ts +99 -0
  6. package/dist/adapter-3G46J3CA.cjs +503 -0
  7. package/dist/adapter-3ONQWJVQ.js +501 -0
  8. package/dist/adapter-55QHWRSE.js +124 -0
  9. package/dist/adapter-DF34GBWJ.cjs +19 -0
  10. package/dist/adapter-EXTNILTC.cjs +126 -0
  11. package/dist/adapter-GOFC7TMC.js +284 -0
  12. package/dist/adapter-LRJTQ47I.cjs +286 -0
  13. package/dist/adapter-TWWZML4A.js +17 -0
  14. package/dist/adapter-ZM5FQTJT.js +1415 -0
  15. package/dist/adapter-ZRNSDQUV.cjs +1417 -0
  16. package/dist/chunk-2SUG7YFZ.cjs +108 -0
  17. package/dist/chunk-B2L2YVXD.js +89 -0
  18. package/dist/chunk-D4KWSEZD.js +393 -0
  19. package/dist/chunk-EZTIZO6R.cjs +430 -0
  20. package/dist/chunk-G7DLWGBW.cjs +103 -0
  21. package/dist/chunk-GTGLDLJD.cjs +479 -0
  22. package/dist/chunk-IIV6VIUJ.cjs +83 -0
  23. package/dist/chunk-JDTHZQUK.js +102 -0
  24. package/dist/chunk-JJVXT3AC.js +30 -0
  25. package/dist/chunk-LIXRYQL2.js +473 -0
  26. package/dist/chunk-NABYHI6X.cjs +400 -0
  27. package/dist/chunk-SJVOYNTF.js +425 -0
  28. package/dist/chunk-SLI2YL25.cjs +252 -0
  29. package/dist/chunk-U4264IQH.js +78 -0
  30. package/dist/chunk-UY2YRCFC.js +250 -0
  31. package/dist/chunk-WCZTTQ7Z.cjs +32 -0
  32. package/dist/core/index.cjs +162 -0
  33. package/dist/core/index.d.cts +321 -0
  34. package/dist/core/index.d.ts +321 -0
  35. package/dist/core/index.js +49 -0
  36. package/dist/default-DRLIJX73.js +1183 -0
  37. package/dist/default-UK52WOO5.cjs +1192 -0
  38. package/dist/formats/epub/index.cjs +29 -0
  39. package/dist/formats/epub/index.d.cts +286 -0
  40. package/dist/formats/epub/index.d.ts +286 -0
  41. package/dist/formats/epub/index.js +11 -0
  42. package/dist/index.cjs +655 -0
  43. package/dist/index.d.cts +335 -0
  44. package/dist/index.d.ts +335 -0
  45. package/dist/index.js +600 -0
  46. package/dist/render/index.cjs +2 -0
  47. package/dist/render/index.d.cts +116 -0
  48. package/dist/render/index.d.ts +116 -0
  49. package/dist/render/index.js +1 -0
  50. package/dist/types-B76GOMxj.d.ts +129 -0
  51. package/dist/types-B7mslPBY.d.cts +166 -0
  52. package/dist/types-B7mslPBY.d.ts +166 -0
  53. package/dist/types-BH88rUYt.d.cts +129 -0
  54. package/dist/types-C-5eHSRH.d.ts +379 -0
  55. package/dist/types-CPUqTEPW.d.cts +379 -0
  56. package/dist/types-DQYmArgv.d.cts +17 -0
  57. package/dist/types-DQYmArgv.d.ts +17 -0
  58. package/package.json +115 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * EPUB CFI(Canonical Fragment Identifier)类型契约。
3
+ *
4
+ * 设计依据(T1.7 决策,2026-09-14):
5
+ *
6
+ * - **判别联合**:单点与范围在类型上分开,`kind` 可穷尽判别。
7
+ * 范围的两端**恒非空** —— 省略 start 时显式归一,而不是留 `undefined`。
8
+ * 这正是基线 FI-010 的缺陷形状:foliate-js 的 `splitAt` 在分隔符只有 1 个时返回 2 段,
9
+ * 而 `parse` 按 3 段解构,导致 `end` 恒为 `undefined`、`collapse` 抛错或产出错误结果
10
+ * (见 `docs/KNOWN-ISSUES.md` FI-010 与 `scripts/refs/fi010-cfi-range.mjs`)。
11
+ * - **零 DOM**:本模块只做「字符串 ↔ 路径结构」,不触碰 `NodeFilter` 与真实节点
12
+ * (`docs/ARCHITECTURE.md` §3.1 / `docs/KNOWN-ISSUES.md` §3.3.5)。
13
+ * `toRange` / `fromRange` / `toElement` 属渲染层,不在此处。
14
+ * - **不静默降级**:非法输入一律抛错,不得返回 `[[]]` 或 `index: null`
15
+ * (`docs/KNOWN-ISSUES.md` §3.3.3)。
16
+ *
17
+ * 语法依据:EPUB CFI 规范 EBNF(idpf.org)——
18
+ *
19
+ * ```ebnf
20
+ * fragment = "epubcfi(" , ( path , [ range ] ) , ")" ;
21
+ * path = step , local_path ;
22
+ * range = "," , local_path , "," , local_path ;
23
+ * step = "/" , integer , [ "[" , assertion , "]" ] ;
24
+ * offset = ( ":" , integer ) , [ "[" , assertion , "]" ] ;
25
+ * assertion = ( ( value , [ "," , value ] ) | ( "," , value ) | ( parameter ) ) { parameter } ;
26
+ * ```
27
+ *
28
+ * 注意两个容易被忽略的点:
29
+ *
30
+ * 1. **`range` 恒为 2 个逗号** —— 范围是「父路径 + 起始子路径 + 结束子路径」的三元组。
31
+ * 「省略 start」的合法写法是 `epubcfi(P,,E)`(中间留空),**不是** 1 个逗号。
32
+ * 2. **断言分两类且位置不同** —— 元素步(偶数 index)上的断言是 **ID 断言**;
33
+ * 字符偏移(`:n`)后的断言是 **文本位置断言**。故 `id` 在 {@link CfiStep} 上,
34
+ * 而 `text` / `textAfter` 在 {@link CfiPoint} 上。
35
+ */
36
+ /**
37
+ * 单个路径步骤(step)。
38
+ *
39
+ * CFI 路径形如 `/6/4[chap01ref]!/4[body01]/10[para05]/2/1:3[yyy]`,
40
+ * 按 `/` 切分后每个片段即一个 step。
41
+ */
42
+ interface CfiStep {
43
+ /**
44
+ * 步骤索引。
45
+ *
46
+ * **奇偶有语义**:偶数指向子元素(含 `0` 与末尾虚拟元素),奇数指向字符数据块。
47
+ * 解析器不校验奇偶是否与真实文档树自洽 —— 那需要真实节点,属渲染层职责。
48
+ */
49
+ readonly index: number;
50
+ /**
51
+ * ID 断言,来自元素步的 `[chap01ref]`。
52
+ *
53
+ * 规范定位:出现在元素 step 之后,用于在位置漂移时按 ID 纠正目标位置。
54
+ * 排序/比较前必须逻辑上剥离(本模块不实现比较,仅保留原始信息)。
55
+ */
56
+ readonly id?: string;
57
+ /**
58
+ * side bias,来自参数 `;s=b`(before)或 `;s=a`(after)。
59
+ *
60
+ * 规范定位:**参数形式**,因此不参与 CFI 比较。
61
+ * 其他取值(非 `b` / `a`)抛 `FormatError`。
62
+ */
63
+ readonly side?: 'before' | 'after';
64
+ }
65
+ /**
66
+ * 一段路径:相邻两个 `!` 之间的部分。
67
+ *
68
+ * `!` 在 CFI 中表示 **indirection**(跳转进被引用的另一个文档),
69
+ * 因此一个 CFI 的路径是「段的序列」而非单一扁平数组。
70
+ */
71
+ interface CfiPath {
72
+ /** 该段内的步骤,按出现顺序。至少一个。 */
73
+ readonly steps: readonly CfiStep[];
74
+ }
75
+ /**
76
+ * 单点 CFI。
77
+ *
78
+ * 例:`epubcfi(/6/4!/4/2/2:0)`
79
+ */
80
+ interface CfiPoint {
81
+ readonly kind: 'point';
82
+ /** 按 `!` 分段的路径,至少一段。 */
83
+ readonly paths: readonly CfiPath[];
84
+ /** 字符偏移,来自末步之后的 `:3`。基于 UTF-16 码元,从 0 开始。 */
85
+ readonly offset?: number;
86
+ /**
87
+ * 文本断言的前导值,来自偏移后的 `[yyy]`。
88
+ *
89
+ * 表示期望出现在遇到点**之前**紧邻的子串(空白折叠后比较)。
90
+ */
91
+ readonly text?: string;
92
+ /**
93
+ * 文本断言的尾随值,来自 `[xx,y]` 或 `[,y]`。
94
+ *
95
+ * 表示期望出现在遇到点**之后**紧邻的子串。仅指定尾随值时写作 `[,y]`。
96
+ */
97
+ readonly textAfter?: string;
98
+ /** side bias,来自偏移后断言里的 `;s=b` / `;s=a`。 */
99
+ readonly side?: 'before' | 'after';
100
+ /**
101
+ * 规范化字符串,**含 `epubcfi()` 前缀**。
102
+ *
103
+ * 与 `serializeCfi()` 的返回值一致,便于下游直接使用而不必再序列化一次。
104
+ */
105
+ readonly value: string;
106
+ }
107
+ /**
108
+ * 范围 CFI。
109
+ *
110
+ * 规范(`range = "," , local_path , "," , local_path`)的两种合法写法:
111
+ *
112
+ * | 写法 | 语义 | `parent` |
113
+ * | ---------------- | ------------------------------------------------------------ | ---------- |
114
+ * | `epubcfi(P,S,E)` | 子路径拼接到父路径:`start = P+S`、`end = P+E` | 非 `null` |
115
+ * | `epubcfi(P,,E)` | 起始子路径留空 → `start = P`(退化为父路径位置)、`end = P+E` | 非 `null` |
116
+ *
117
+ * 另**有意接受一种简写**(登记于 `docs/API.md`「有意偏离规范之处」):
118
+ *
119
+ * | 写法 | 本项目语义 | `parent` |
120
+ * | -------------- | ------------------------------------------------- | -------- |
121
+ * | `epubcfi(A,B)` | 仅 1 个逗号 → `start = A`、`end = B`(两条独立绝对路径) | `null` |
122
+ *
123
+ * 简写形式按 EBNF 属**畸形输入**(`docs/KNOWN-ISSUES.md` FI-010 取证输入澄清)。
124
+ * 其语义定为「两条独立绝对路径」而非字面的 `epubcfi(A,,B)`,理由是后者会产出 `A+B`
125
+ * 的退化路径 —— 对 `scripts/refs/fi010-cfi-range.mjs` 的输入即 `/6/4!/4/2/2/6/4!/4/2/4`,
126
+ * 恰是基线错误输出的形状;而前者是唯一能让 `collapseCfi(range, true)` 产出有意义结果的解释。
127
+ *
128
+ * **省略 start 的范围是本项目的重点覆盖场景** —— 基线在此静默产出垃圾(FI-010)。
129
+ */
130
+ interface CfiRange {
131
+ readonly kind: 'range';
132
+ /**
133
+ * 起点,**恒非空**。
134
+ *
135
+ * 规范形态下由「父路径 + 起始子路径」拼接而成;简写形式下即第一个路径。
136
+ * **始终是绝对路径** —— 消费方永远不需要自己做路径拼接。
137
+ */
138
+ readonly start: CfiPoint;
139
+ /** 终点,**恒非空**。与 `start` 口径一致(始终是绝对路径)。 */
140
+ readonly end: CfiPoint;
141
+ /**
142
+ * 规范形态的共同父路径;**仅 1 逗号简写形式为 `null`**。
143
+ *
144
+ * 存在是为保留原始书写形式以便精确回写(round-trip):
145
+ * 非 `null` 时序列化输出 `epubcfi(P,S,E)` / `epubcfi(P,,E)`,
146
+ * `null` 时输出 `epubcfi(A,B)`。语义信息已完整包含在 `start` / `end` 中。
147
+ */
148
+ readonly parent: readonly CfiPath[] | null;
149
+ /** 规范化字符串,含 `epubcfi()` 前缀。 */
150
+ readonly value: string;
151
+ }
152
+ /**
153
+ * 解析后的 CFI。
154
+ *
155
+ * 用 `kind` 判别:
156
+ *
157
+ * ```ts
158
+ * const cfi = parseCfi(input);
159
+ * if (cfi.kind === 'range') {
160
+ * // cfi.start / cfi.end 恒可用,无需判空
161
+ * }
162
+ * ```
163
+ */
164
+ type Cfi = CfiPoint | CfiRange;
165
+
166
+ export type { Cfi as C, CfiPath as a, CfiPoint as b, CfiRange as c, CfiStep as d };
@@ -0,0 +1,129 @@
1
+ import { O as OperationOptions } from './types-DQYmArgv.cjs';
2
+
3
+ /**
4
+ * ZIP 读取的类型定义(ADR-0007)。
5
+ *
6
+ * 设计约束:
7
+ * - **零依赖**:只用 `DataView` / `TextDecoder` / `DecompressionStream`,不用 `@zip.js/zip.js`
8
+ * - **零 DOM**:可在 Node / Worker / 浏览器一致运行(REQ-CORE-001)
9
+ * - **只读**:本模块不写入、不修改 ZIP,也不解压到磁盘
10
+ *
11
+ * 明确不支持(ADR-0007 的已知边界):
12
+ * - ZIP64(>4 GB 条目)—— 检测到即抛 `FormatError`,**不静默按 32 位解析**
13
+ * - 加密条目 —— 属非目标(不做 DRM 解密)
14
+ * - 流式随机读 —— 当前一次性读入内存
15
+ */
16
+
17
+ /**
18
+ * ZIP 压缩方法。
19
+ *
20
+ * 仅这两种在 EPUB 中合法(OCF 规范要求 `mimetype` 必须为 stored)。
21
+ * 其他方法(如 12 = bzip2、14 = LZMA)一律抛 `FormatError`。
22
+ */
23
+ type ZipCompressionMethod = 0 | 8;
24
+ /** ZIP 中央目录中的一个条目。 */
25
+ interface ZipEntry {
26
+ /**
27
+ * 条目名,已按 UTF-8 解码,并**归一化到相对归档根的规范形**(FI-X22)。
28
+ *
29
+ * 规范化的规则与 `normalizeArchivePath`(`core/archive-path.ts`)**同一个函数**
30
+ * (该函数即查询键的产出者):`\` → `/`、折掉 `.` 段与重复 `/`、去掉前导 `/`。因此
31
+ * `entries[].name`、`entry()` / `read()` 的索引键、EPUB / CBZ / FB2 解析出的路径
32
+ * 四处**不可能分叉** —— 曾分叉的形态是归档里存 `./META-INF/container.xml`
33
+ * 时,条目名列得出、读不出,且探测层直接判不出格式。
34
+ *
35
+ * 以 `/` 结尾表示目录条目 —— 那个结尾斜杠**只在目录条目上保留**:
36
+ * `probeZipContainer` 与 `collectCbzPages` 都按 `endsWith('/')` 排除目录,
37
+ * 把它一并折掉会让 `book.fb2/` 这种目录被当成正文。
38
+ *
39
+ * ⚠️ 归一化后**越出归档根**、**含控制字符**、或**规范化后为空**的名字不会出现在这里 ——
40
+ * 那三种在 `parseZip` 阶段就抛 `FormatError`(不留「存进去但取不出」的哑条目)。
41
+ */
42
+ readonly name: string;
43
+ /** 压缩方法。 */
44
+ readonly method: ZipCompressionMethod;
45
+ /** 压缩后字节数(取自中央目录)。 */
46
+ readonly compressedSize: number;
47
+ /** 解压后字节数(取自中央目录)。 */
48
+ readonly uncompressedSize: number;
49
+ /** 未压缩数据的 CRC-32 校验和。 */
50
+ readonly crc32: number;
51
+ /** 是否为目录条目(名字以 `/` 结尾)。目录条目无内容。 */
52
+ readonly isDirectory: boolean;
53
+ }
54
+ /** {@link parseZip} 的选项。 */
55
+ interface ParseZipOptions {
56
+ /**
57
+ * 源标识,用于错误定位(REQ-EPUB-007)。
58
+ *
59
+ * 通常传文件名或 URL。失败时该值会出现在错误 message 中。
60
+ */
61
+ readonly source?: string;
62
+ /**
63
+ * **单个条目解压产物**的字节上限;超限即中止解压并抛 `ParseError`。
64
+ *
65
+ * 存在的理由:归档里的字节是不可信的,而 deflate 是**放大**通道 —— 实测 199 KB 的
66
+ * 压缩流可以解出 200 MB。中央目录声明的 `uncompressedSize` **也是文件说了算的数**,
67
+ * 拿它当校验依据只能事后拦(先物化、再比对),拦不住放大本身。
68
+ * 因此这里有两道:声明值的前置检查(拦「老实交代的大数」)+ 边解边数(拦「谎报小值」)。
69
+ *
70
+ * `stored`(未压缩)条目不受此上限约束 —— 其产物尺寸恒等于压缩尺寸,
71
+ * 而压缩尺寸已由「数据区间必须落在归档内」钉住,放大率天然是 1。
72
+ *
73
+ * @defaultValue 67108864(64 MiB)—— 本仓最大的合法单条目是 3.34 MB 的合成单章,
74
+ * 真实书最大章节约 2.9 MB,约 20 倍余量。
75
+ */
76
+ readonly maxEntrySizeBytes?: number;
77
+ }
78
+ /**
79
+ * 已解析的 ZIP 归档(只读视图)。
80
+ *
81
+ * 通过 {@link parseZip} 创建。实例持有原始字节,因此不要在归档存活期间修改
82
+ * 传入 `parseZip` 的那个 `Uint8Array`。
83
+ */
84
+ interface ZipArchive {
85
+ /** 全部条目,按中央目录顺序。包含目录条目;**原文**重复名全部保留。 */
86
+ readonly entries: readonly ZipEntry[];
87
+ /**
88
+ * 按名字查条目。
89
+ *
90
+ * 查询键先走与条目名**同一个**归一化函数(FI-X22),于是 `a.png`、`./a.png`、
91
+ * `/a.png`、`a/./b.png` 这类写法指向同一个条目;目录条目的两个键
92
+ * (`images` 与 `images/`)也命中同一条。
93
+ *
94
+ * 原文重名时返回**最后一个**(ZIP 惯例:后写覆盖)。
95
+ * 「归一化后同名而原文不同」的两条不会走到这里 —— `parseZip` 已判红。
96
+ *
97
+ * @returns 匹配条目;不存在、**或查询键本身不是合法的归档内路径**(`''`、`'../x'`)时
98
+ * 返回 `undefined`。本方法**不抛错**。
99
+ */
100
+ entry(name: string): ZipEntry | undefined;
101
+ /**
102
+ * 读取并解压条目。
103
+ *
104
+ * - 返回**独立副本**,调用方修改它不会影响归档内的原始字节
105
+ * - 解压后校验 CRC-32 与长度,不匹配抛 `ParseError`
106
+ * - 对目录条目抛 `FormatError`
107
+ *
108
+ * @param name - 条目名(**先归一化再查**,规则见 {@link ZipEntry.name};
109
+ * 大小写敏感,与 ZIP 内一致)。
110
+ * @param options - 取消信号。
111
+ * @throws {FormatError} 条目不存在、是目录、或压缩方法不支持。
112
+ * 查询键非法(`''`、`'../x'`)按**条目不存在**处理 —— 归档侧已保证不存在这类名字。
113
+ * @throws {ParseError} 数据损坏(CRC-32 或长度不匹配),**或解压产物超过
114
+ * `parseZip` 的 `maxEntrySizeBytes`**。
115
+ * @throws {AbortError} 操作被取消。
116
+ */
117
+ read(name: string, options?: OperationOptions): Promise<Uint8Array>;
118
+ /**
119
+ * 读取条目并按 UTF-8 解码为文本。
120
+ *
121
+ * 用于 `META-INF/container.xml`、OPF、NAV 等 XML 资源。
122
+ * BOM 会被自动去除。
123
+ *
124
+ * @throws 同 {@link ZipArchive.read}。
125
+ */
126
+ readText(name: string, options?: OperationOptions): Promise<string>;
127
+ }
128
+
129
+ export type { ParseZipOptions as P, ZipArchive as Z, ZipCompressionMethod as a, ZipEntry as b };
@@ -0,0 +1,379 @@
1
+ import { O as OperationOptions } from './types-DQYmArgv.js';
2
+
3
+ /**
4
+ * 可注入的字节源与资源加载抽象(REQ-CORE-005)。
5
+ *
6
+ * 通过实现这些接口,宿主可以接入任意存储后端(内存、OPFS、远端分片、加密容器),
7
+ * core 无需感知网络与文件系统差异 —— 测试里因此可以注入纯内存实现,
8
+ * **不触碰网络与文件系统**。
9
+ *
10
+ * 本模块属于 `frond-js/core`,**不得依赖 DOM,也不得依赖 render / reader**
11
+ * (ARCHITECTURE §1 依赖方向单向,H4)。
12
+ */
13
+
14
+ /**
15
+ * 可注入的字节源抽象。
16
+ *
17
+ * 实现者必须满足以下契约(`createMemoryBookSource` 是内置参考实现):
18
+ *
19
+ * 1. **区间语义**:`read({ start, end })` 返回覆盖**闭开区间** `[start, end)`
20
+ * 的字节,长度恒为 `end - start`;`[0, 0)` 是合法输入,返回零长度数组。
21
+ * 2. **取消(H6 / H7)**:`options.signal` 已 abort 时必须抛 `AbortError`,
22
+ * **且不得返回任何字节**。
23
+ * 3. **关闭后不可读**:`close()` 之后调用 `read` 必须抛 `StateError`
24
+ * —— 「已释放」与「读到空数据」是两回事,静默返回空会让缺陷延后暴露。
25
+ * 4. **区间越界即报错**:越界、起止倒置、非整数一律抛 `FormatError`,
26
+ * **不截断、不补齐**(对照 FI-005 的「不静默降级」)。
27
+ */
28
+ interface BookSource {
29
+ /** 总字节数;未知时为 `null`。 */
30
+ readonly size: number | null;
31
+ /**
32
+ * 按需读取指定字节区间。
33
+ *
34
+ * @param range 闭开区间 `[start, end)`
35
+ * @param options 取消信号
36
+ * @throws {AbortError} 操作被取消
37
+ * @throws {StateError} 源已 `close()`
38
+ * @throws {FormatError} 区间非法(越界 / 起止倒置 / 非整数)
39
+ */
40
+ read(range: {
41
+ readonly start: number;
42
+ readonly end: number;
43
+ }, options?: OperationOptions): Promise<Uint8Array>;
44
+ /** 释放底层资源。必须幂等(重复调用不抛错)。 */
45
+ close(): void;
46
+ }
47
+ /**
48
+ * 书内资源的加载抽象(图片、样式、字体等)。
49
+ *
50
+ * 与 {@link BookSource} 的分工:`BookSource` 面向**整本书的字节流**(可按偏移随机读),
51
+ * `ResourceIO` 面向**书内已定位的资源**(按 `href` 取整块)。
52
+ *
53
+ * ## 契约(自 T2.8 起生效)
54
+ *
55
+ * 1. **`href` 是归档路径**,不是相对路径、也不是 URL —— 相对引用的解析由渲染层
56
+ * 按「引用所在文档」完成(`core/archive-path`),实现方拿到的已经是可直接查表的形式。
57
+ * 2. **取不到时抛 `FormatError`**(不是返回空、也不是 `undefined`)—— 「归档里没有
58
+ * 这个条目」与「读到了零字节」是两回事,静默返回空会把缺陷延后到「图片不显示」才暴露。
59
+ * ⚠️ 与 `ResourceLoader` 的分工正在此处:后者用 `undefined` 表示「没有」,
60
+ * 适配层负责把 `FormatError` 翻译成 `undefined`(见 `src/reader/reader.ts`)。
61
+ * 3. **只对内置默认渲染器生效**:调用方自备 `renderer` 时,`Reader` **不**透传 `io`
62
+ * —— `Renderer` 契约里没有承载资源来源的位置,「谁渲染、谁负责取资源」。
63
+ * 4. **只管书内资源,不管章节**:章节是 XML 文本,走归档的 `readText`
64
+ * (自带 UTF-8 解码与 BOM 处理),不经过这里。
65
+ *
66
+ * 存在的意义是让宿主接远端分片或加密容器(见本模块头部)。
67
+ */
68
+ interface ResourceIO {
69
+ /**
70
+ * 读取指定资源对应的字节。
71
+ *
72
+ * @param href **归档路径**(如 `OPS/images/cover.png`)
73
+ * @param options 取消信号
74
+ * @throws {AbortError} 操作被取消(H6 / H7)
75
+ * @throws {FormatError} 归档里没有这个路径
76
+ */
77
+ load(href: string, options?: OperationOptions): Promise<Uint8Array>;
78
+ }
79
+ /** {@link createMemoryBookSource} 的选项。 */
80
+ interface MemoryBookSourceOptions {
81
+ /**
82
+ * 源标识,**仅用于错误 message**,便于定位是哪个源出的问题
83
+ * (与 `ParseZipOptions.source` 同口径)。缺省为 `'<anonymous>'`。
84
+ */
85
+ readonly source?: string;
86
+ }
87
+ /**
88
+ * 用一段已有字节造一个内存 {@link BookSource}(REQ-CORE-005 的内置参考实现)。
89
+ *
90
+ * 用途:让宿主(尤其**测试**)在不触碰网络与文件系统的前提下注入字节源 ——
91
+ * REQ-CORE-005 的验收标准「测试中可注入内存 IO,无需网络」正是由它满足。
92
+ *
93
+ * 两处刻意的实现选择:
94
+ *
95
+ * - **零复制**:`read` 返回 `subarray` 视图而非副本,与 T1.8「`archive` 不复制字节」
96
+ * 同一口径。代价是调用方修改返回的数组会穿透到底层字节 —— 需要隔离时请自行传入副本。
97
+ * - **`close()` 有实义**:关闭后 `read` 抛 `StateError`,而不是静默返回空数组。
98
+ * 「已释放」与「读到空数据」是两回事,静默返回空会让缺陷延后到很难定位的地方。
99
+ *
100
+ * @param bytes - 底层字节。**不被复制**,返回的源持有其视图。
101
+ * @param options - 仅 `source`(错误 message 用的标签)。
102
+ */
103
+ declare function createMemoryBookSource(bytes: Uint8Array, options?: MemoryBookSourceOptions): BookSource;
104
+
105
+ /**
106
+ * 通用书籍模型(`frond-js/core`)。
107
+ *
108
+ * 这是**跨格式契约**:EPUB、FB2、CBZ、MOBI 最终都产出同一个 `Book`,
109
+ * 渲染层与 Reader 只面向本模型编程,不感知格式差异
110
+ * (ARCHITECTURE §1 / §3.4 的数据流终点)。
111
+ *
112
+ * 设计约束:
113
+ * - **只读**:模型一旦产出即不可变,禁止渲染层反向修改
114
+ * - **零 DOM、零运行时依赖**(H4 / H12)
115
+ * - 本文件**只有类型**,不产生运行时代码,因此不计入覆盖率分母
116
+ */
117
+ /**
118
+ * 出版元数据。字段口径见 T1.5 决策 + **FI-X38 修订**(2026-09-22):三个字段**类型上恒为 `string`**
119
+ * (无来源就给空串,不改成可选);而「**缺了就拒开**」只适用于该格式既有判据本就要求它的场合 ——
120
+ * EPUB 的 `dc:identifier`、FB2 的 `document-info/id` 判红不变;MOBI 的 EXTH 104(ISBN)/ 113(ASIN)
121
+ * 是 **Kindle 产物**、规范不要求 ⇒ 缺失给 `""`(**不再拒开**);PDF / CBZ 本无标识符来源 ⇒ 恒 `""`。
122
+ * ⚠️ 三条不变:不拿 PalmDB 名 / 文件名顶替标识符(那是造假不是回退);`creators` 仍非必填;
123
+ * `title` 在 MOBI 侧仍要求「EXTH 503 与完整书名至少有一个」。
124
+ */
125
+ interface BookMetadata {
126
+ /** `dc:title`。EPUB 3 要求至少一个,取**第一个**。 */
127
+ readonly title: string;
128
+ /** 全部 `dc:creator`,按文档顺序。可为空数组(创作者非必填)。 */
129
+ readonly creators: readonly string[];
130
+ /** `dc:language`。EPUB 3 要求至少一个,取**第一个**。 */
131
+ readonly language: string;
132
+ /** `dc:identifier`(优先取 `unique-identifier` 指向的那条)。 */
133
+ readonly identifier: string;
134
+ /**
135
+ * `dcterms:modified`。
136
+ *
137
+ * ⚠️ EPUB 3 规范要求该字段,但真实文件常缺失。缺失**不报错**(T1.5 决策):
138
+ * 为合规而拒掉现实中能正常阅读的书,代价高于收益。
139
+ */
140
+ readonly modified?: string;
141
+ }
142
+ /** manifest 中的一个资源条目。 */
143
+ interface ManifestItem {
144
+ /** `id`。在同一个 OPF 内必须唯一。 */
145
+ readonly id: string;
146
+ /**
147
+ * `href`,**已相对 OPF 所在目录规范化**(不含前导 `/`,可直接交给归档读取层)。
148
+ *
149
+ * 有意处理:`#` 之后的 fragment 被剥离(manifest href 的 fragment 无意义,
150
+ * 保留会导致资源读取失败);**不做**百分号解码(ZIP 条目名本身不编码)。
151
+ */
152
+ readonly href: string;
153
+ /** `media-type`。 */
154
+ readonly mediaType: string;
155
+ /**
156
+ * `properties`,按空白拆分后的数组。
157
+ *
158
+ * EPUB 3 的 `nav`、`scripted`、`svg` 等标记在此。EPUB 2 无该属性 → 空数组。
159
+ */
160
+ readonly properties: readonly string[];
161
+ }
162
+ /** spine 中的一个阅读顺序项。 */
163
+ interface SpineItem {
164
+ /** `idref`,指向 manifest 条目。 */
165
+ readonly idref: string;
166
+ /** `linear`:`false` 表示 `linear="no"`(辅助内容,如封面、版权页)。默认 `true`。 */
167
+ readonly linear: boolean;
168
+ /** 在 spine 中的顺序,从 0 开始。 */
169
+ readonly index: number;
170
+ /** 已解析的 manifest 条目。**解析期已校验存在性**,调用方无需再查表。 */
171
+ readonly item: ManifestItem;
172
+ }
173
+ /**
174
+ * 目录中的一个条目。
175
+ *
176
+ * **跨格式契约**:EPUB(NAV / NCX)、FB2(`<section>` 嵌套标题)、
177
+ * MOBI(索引)最终都产出同一模型,渲染层与 Reader 只面向它编程。
178
+ *
179
+ * 与 {@link ManifestItem} 的关系:两者的 `href` **口径一致** —— 都**不含** fragment。
180
+ * 目录项多出来的 fragment 单独成字段,因此 `href` 可以直接交给归档读取层,
181
+ * 调用方不必再判断「这个 href 要不要拆」。这是 T1.6 的取舍:
182
+ * 拆解在**解析时做一次**,而不是让每个消费方各拆一次。
183
+ */
184
+ interface TocItem {
185
+ /** 显示文本。取源文档原文(仅折叠两侧空白)。 */
186
+ readonly label: string;
187
+ /**
188
+ * 目标文档在归档内的路径,**已规范化且不含 fragment**。
189
+ *
190
+ * 相对链接按**引用它的文档**(NAV / NCX 文件)所在目录解析。
191
+ * 该目录通常与 OPF 同目录,此时等价于 {@link Book.basePath}。
192
+ */
193
+ readonly href: string;
194
+ /**
195
+ * `#` 之后的定位信息,**不含 `#`**。无 fragment(或 fragment 为空串)时该字段不存在。
196
+ *
197
+ * 内容可能是元素 id(`"s2"`)、EPUB CFI(`"epubcfi(/6/4!/4/2)"`)—— 本模型**不解释**它,
198
+ * 交由 CFI 解析(`src/core/cfi/**`)与渲染层按需处理。
199
+ *
200
+ * ✅ **MOBI 的两个版本都已收编为元素 id**(MOBI6 见 T3.2b ④,KF8 见 T3.2b ③b):
201
+ *
202
+ * - **MOBI6**:正文是**单个** HTML 文档,目录靠 `guide` 的 `<reference type="toc" filepos=N>`
203
+ * 指向**目录页**,目录页里的锚点又是 `<a filepos=N>` —— `N` 是**字节**偏移(不是字符下标)。
204
+ * `openMobi` 先用 `shiftOffset` 把它换算到改写后的空间,再在目标开标签上**就地**
205
+ * 注入 `id="mobi-toc-N"`(`src/formats/mobi/anchors.ts`)⇒ 本字段拿到的是**元素 id**。
206
+ * 落点不是开标签时**保留原字节偏移字符串**(降级,不抛错)⇒ 那一条仍定位不到。
207
+ * - **KF8**:目录数据来自 **INDX 表**(不是正文超链接)。`parseKf8Toc` 把
208
+ * `insertpos + off − skelpos(章)` 换算成全局 `flow[0]` 偏移,再在**重写之前**的章节字节里
209
+ * 反搜最近的 `id=` / `name=` ⇒ 本字段拿到的是**元素 id**(`src/formats/mobi/kf8toc.ts`)。
210
+ * 反搜不到时该字段**不写**(而非写空串)。
211
+ *
212
+ * ⇒ **消费方不要把 `fragment` 无条件当元素 id 用**:取不到就只是「定位不到」,
213
+ * 而不是「文档有问题」。形态只剩**两种**(元素 id / CFI 字符串),
214
+ * 判据是**前缀**(见 `src/reader/navigation.ts` 的 `locationForTocItem`)。
215
+ */
216
+ readonly fragment?: string;
217
+ /** 子条目,按文档顺序。无子项时为空数组。 */
218
+ readonly children: readonly TocItem[];
219
+ }
220
+ /**
221
+ * 支持的书籍格式。**`Book` 的判别字段**(T3.0.2 / ADR-0014 新增)。
222
+ *
223
+ * 有了它,调用方不必靠「哪些字段是 `undefined`」反推拿到的是什么格式。
224
+ */
225
+ type BookFormat = 'epub' | 'fb2' | 'mobi' | 'cbz' | 'pdf';
226
+ /**
227
+ * 书级**阅读方向**(D-7 新增)—— {@link Book.direction} 的取值。
228
+ *
229
+ * ## 为什么不含 `'default'`
230
+ *
231
+ * EPUB 的 `spine@page-progression-direction` 合法值是 `ltr` / `rtl` / `default`,
232
+ * 而 `default` 的语义是「**没有声明方向**」。把它收进类型会让每个消费方都要处理
233
+ * 一个「第三种方向」,而它其实等价于「没有值」⇒ 解析时直接映射为 `undefined`,
234
+ * 本类型只留两个**真的方向**。
235
+ *
236
+ * ## 与 CSS `direction` 的关系
237
+ *
238
+ * 消费方把它写成内容文档的 `<html dir="…">`,浏览器据此映射出 CSS `direction`
239
+ * (`src/render/default/sandbox.ts`)。⚠️ 它是**书的元数据**,与 `paginator.ts`
240
+ * 从 `getComputedStyle()` 读到的**计算值**是两件事 —— 后者是前者的下游结果。
241
+ */
242
+ type ReadingDirection = 'ltr' | 'rtl';
243
+ /**
244
+ * 通用资源条目(跨格式)。
245
+ *
246
+ * **EPUB 的 {@link ManifestItem} 是它的超集**(多 `id` 与 `properties`)——
247
+ * 非 OPF 格式(CBZ 的图片序列、MOBI 的记录表)只需给出「归档内路径 + 媒体类型」。
248
+ */
249
+ interface ResourceItem {
250
+ /** 归档内路径,**已规范化**(口径同 {@link ManifestItem.href})。 */
251
+ readonly href: string;
252
+ /** 媒体类型。 */
253
+ readonly mediaType: string;
254
+ }
255
+ /**
256
+ * 通用阅读顺序条目(跨格式)。
257
+ *
258
+ * **EPUB 的 {@link SpineItem} 是它的超集**(多 `idref` / `index` / 已解析的 `item`)。
259
+ */
260
+ interface ChapterItem {
261
+ /** 归档内路径,**已规范化**(口径同 {@link ManifestItem.href})。 */
262
+ readonly href: string;
263
+ /** 媒体类型。 */
264
+ readonly mediaType: string;
265
+ /** 是否属主阅读顺序(EPUB 的 `linear="no"` 对应 `false`)。 */
266
+ readonly linear: boolean;
267
+ }
268
+ /**
269
+ * 一本已解析的书。
270
+ *
271
+ * 数据流(规格 §3):
272
+ * `BookInput/BookSource → ZIP → container.xml → OPF → metadata + manifest + spine
273
+ * → NAV or NCX → Book + CFI → Renderer → Reader`
274
+ *
275
+ * ## 字段分层(T3.0.2 / ADR-0014)
276
+ *
277
+ * `Book` 是**跨格式契约**,但字段里混着两套口径,现显式分层:
278
+ *
279
+ * | 层 | 字段 | 非 OPF 格式下的取值 |
280
+ * | ------------- | ---------------------------------------------------------------------- | ------------------------------- |
281
+ * | **通用** | `format` / `metadata` / `resources` / `chapters` | **必须有值** |
282
+ * | **通用(可选)** | `direction` | **不存在**(`undefined`) |
283
+ * | **EPUB 专属** | `opfPath` / `basePath` | **不存在**(`undefined`) |
284
+ * | **EPUB 专属** | `manifest` / `spine` / `manifestIndex` / `tocItem` / `ncxPath` | 空数组 / 空 `Map` / `undefined` |
285
+ *
286
+ * ⚠️ **「本格式没有这个概念」≠「解析失败」** —— 前者是**如实为空**,后者抛错。
287
+ *
288
+ * ### 可选化的判据:该字段的「没有」**有没有诚实的空值**(T3.2a 定稿)
289
+ *
290
+ * - `opfPath` / `basePath`:**没有**。填 `''` 会被读成「OPF 在归档根目录」,
291
+ * 那是**假信息** —— 非 OPF 格式根本没有 OPF。故改为可选:`undefined`
292
+ * 表示「本格式没有 OPF 这个概念」,与「解析失败」区分开。
293
+ * - `manifest` / `spine` / `manifestIndex`:**有**。`[]` / `new Map()` 直白地
294
+ * 表示「没有条目」,不指认任何具体位置,故保持必填、取空值。
295
+ *
296
+ * ⚠️ **`spine` 保持必填**另有一条独立理由:`reader/` 仍有一处读它 ——
297
+ * `spineIndexFromCfi`,因为 CFI 的 `/6/2n` 描述的就是 **EPUB package 文档内**的
298
+ * 位置,本就是 OPF 概念(非 OPF 格式收到 CFI 报 `FormatError` 是**诚实**的)。
299
+ * **导航面(定位 / 翻页)自 T3.2a 起读 `chapters`**(`reader/navigation.ts`),
300
+ * 非 OPF 格式因此可读 —— 这条由 `tests/reader/navigation.test.ts` 守住。
301
+ */
302
+ interface Book {
303
+ /**
304
+ * 本 `Book` 的来源格式(**判别字段**)。
305
+ *
306
+ * **必填**:EPUB 解析器产出 `'epub'`;后续 FB2 / CBZ / MOBI / PDF 适配器各自产出自己的值。
307
+ */
308
+ readonly format: BookFormat;
309
+ readonly metadata: BookMetadata;
310
+ /**
311
+ * 通用资源表,按文档顺序。
312
+ *
313
+ * EPUB 下由 {@link manifest} **派生** —— 由**同一个函数**产出(ADR-0014 的「单一事实来源」)。
314
+ */
315
+ readonly resources: readonly ResourceItem[];
316
+ /**
317
+ * 通用阅读顺序,按阅读顺序。
318
+ *
319
+ * EPUB 下由 {@link spine} **派生**:`chapters[i].href === spine[i].item.href`
320
+ * 由单测**逐项断言**守住 —— **不靠约定靠断言**(同 ADR-0008 的「唯一写入点」思路)。
321
+ */
322
+ readonly chapters: readonly ChapterItem[];
323
+ /**
324
+ * 书级阅读方向(`ltr` / `rtl`)。**未声明时为 `undefined`**(D-7 新增)。
325
+ *
326
+ * 唯一来源是 EPUB 的 `spine@page-progression-direction`(`formats/epub/opf.ts`):
327
+ * `ltr` / `rtl` 原样取值,`default` 与**任何非法值**都映射为 `undefined`
328
+ * —— 「非法方向 = 没有方向」,与 `spine@toc` 悬空时 `ncxPath` 置空同一口径
329
+ * (T1.5 对**可选线索**的处置:不因一个可选线索有瑕疵就拒掉整本能读的书)。
330
+ *
331
+ * ⚠️ **它是「书的元数据」,不是「内容文档的排版结果」**:消费方(渲染层)把它写成
332
+ * `<html dir="…">`,浏览器再据此得出 CSS `direction`。`paginator.ts` 读的是后者
333
+ * (计算值)—— 两者不在同一层,别把这里的值当成排版事实。
334
+ *
335
+ * ⚠️ **竖排(`writing-mode: vertical-rl`)不在本字段的表达范围内**:那是 CSS 的事,
336
+ * 由书籍自己的样式表声明,`Book` 不解析样式表(详见 `paginator.ts` 的镜像轴说明)。
337
+ */
338
+ readonly direction?: ReadingDirection;
339
+ /**
340
+ * package document(OPF)在归档内的路径。**非 OPF 格式下不存在**(T3.2a 可选化)。
341
+ *
342
+ * ⚠️ **与 `spine` 的取舍相反**(那里刻意保持必填):`spine` 的「没有」有**诚实的空值**
343
+ * (空数组,语义是「没有阅读顺序项」);而 `opfPath` 的「没有」**没有** ——
344
+ * 填 `''` 会被读成「OPF 在归档根目录」,那是**假信息**。
345
+ * 因此这里可选:`undefined` 表示「本格式没有 OPF 这个概念」,与「解析失败」区分开。
346
+ */
347
+ readonly opfPath?: string;
348
+ /**
349
+ * OPF 所在目录,用于解析书内其他相对引用。根目录时为 `''`。
350
+ *
351
+ * **非 OPF 格式下不存在**(理由同 {@link opfPath})。
352
+ */
353
+ readonly basePath?: string;
354
+ /** 全部 manifest 条目,按文档顺序。 */
355
+ readonly manifest: readonly ManifestItem[];
356
+ /** 全部 spine 项,按阅读顺序(`index` 递增)。 */
357
+ readonly spine: readonly SpineItem[];
358
+ /**
359
+ * 按 `id` 索引的 manifest,用于 O(1) 查找。
360
+ *
361
+ * 键与 {@link manifest} 中的条目一一对应。**重复 id 在解析期即抛错**,
362
+ * 因此这里不会出现覆盖。
363
+ */
364
+ readonly manifestIndex: ReadonlyMap<string, ManifestItem>;
365
+ /**
366
+ * EPUB 3 的导航文档条目(manifest 中 `properties` 含 `nav` 者)。
367
+ *
368
+ * 仅**识别**,不解析 —— 目录解析属 T1.6。
369
+ */
370
+ readonly tocItem?: ManifestItem;
371
+ /**
372
+ * EPUB 2 的 NCX 路径(已规范化,相对归档根),来自 `spine@toc`。
373
+ *
374
+ * 仅**识别**,不解析 —— 目录解析属 T1.6。
375
+ */
376
+ readonly ncxPath?: string;
377
+ }
378
+
379
+ export { type BookSource as B, type ChapterItem as C, type ManifestItem as M, type ResourceIO as R, type SpineItem as S, type TocItem as T, type Book as a, type BookFormat as b, type BookMetadata as c, type MemoryBookSourceOptions as d, type ReadingDirection as e, type ResourceItem as f, createMemoryBookSource as g };