@nexlibris/crosspoint-tools 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +24 -2
- package/convert.d.ts +6 -0
- package/convert.js +22 -0
- package/fonts.d.ts +29 -0
- package/fonts.js +54 -0
- package/formats.d.ts +22 -0
- package/formats.js +19 -0
- package/libraryIndex.d.ts +72 -0
- package/libraryIndex.js +191 -0
- package/package.json +21 -4
- package/sleepImage.d.ts +42 -0
- package/sleepImage.js +102 -0
- package/stardict.d.ts +41 -0
- package/stardict.js +172 -0
- package/xteink/image.d.ts +54 -0
- package/xteink/image.js +202 -0
- package/xteink/optimize.d.ts +33 -0
- package/xteink/optimize.js +157 -0
- package/xteink/options.d.ts +29 -0
- package/xteink/options.js +108 -0
- package/xteink/textsplit.d.ts +71 -0
- package/xteink/textsplit.js +431 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nolan Hawkins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nexlibris/crosspoint-tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Files for [CrossPoint](https://github.com/crosspoint-reader/crosspoint-reader) e-readers (the Xteink X4 and X3), built on [`@nexlibris/tools`](https://www.npmjs.com/package/@nexlibris/tools):
|
|
4
|
+
|
|
5
|
+
- `formats` and `convert`: the X4 and X3 EPUB formats and their converter from EPUB (the optimizer, `xteink/`, a port of the CrossPoint Calibre plugin's), registered with the tools' format registry.
|
|
6
|
+
- `sleepImage`: sleep images as screen-sized grayscale BMPs.
|
|
7
|
+
- `stardict`: StarDict dictionaries unpacked and checked.
|
|
8
|
+
- `fonts`: fonts sorted into families.
|
|
9
|
+
- `libraryIndex`: the reader's `/.crosspoint/library.idx`.
|
|
10
|
+
|
|
11
|
+
Modules are imported one by one, as with the tools:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { registerFormats } from '@nexlibris/tools/formats/formats';
|
|
15
|
+
import { registerConverters } from '@nexlibris/tools/formats/convert/converters';
|
|
16
|
+
import { CROSSPOINT_FORMATS } from '@nexlibris/crosspoint-tools/formats';
|
|
17
|
+
import { CROSSPOINT_CONVERTERS } from '@nexlibris/crosspoint-tools/convert';
|
|
18
|
+
|
|
19
|
+
registerFormats(CROSSPOINT_FORMATS);
|
|
20
|
+
registerConverters(CROSSPOINT_CONVERTERS);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Like the tools, they're written for a bundler such as Vite (assets are imported as `?url`). Under Node, the `nexlibris` command ([`@nexlibris/cli`](https://www.npmjs.com/package/@nexlibris/cli)) runs them.
|
|
24
|
+
|
|
25
|
+
Part of [nexlibris](https://github.com/digitaltembo/nexlibris), under the MIT licence.
|
package/convert.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Converter } from '@nexlibris/tools/formats/convert/converters';
|
|
2
|
+
import { type OptimizeOptions } from './xteink/options';
|
|
3
|
+
/** The CrossPoint optimizer for a reader's screen, with its options recorded in its version. */
|
|
4
|
+
export declare function xteinkConverter(profile: 'X4' | 'X3', options?: OptimizeOptions): Converter;
|
|
5
|
+
/** Converting EPUBs to the X4's and X3's optimized EPUBs. */
|
|
6
|
+
export declare const CROSSPOINT_CONVERTERS: Converter[];
|
package/convert.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { DEFAULT_OPTIMIZE_OPTIONS, OPTIMIZE_FIELDS, optimizeOptionsFrom, optimizerVersion } from "./xteink/options.js";
|
|
2
|
+
//#region crosspoint-tools/src/convert.ts
|
|
3
|
+
/** The CrossPoint optimizer for a reader's screen, with its options recorded in its version. */
|
|
4
|
+
function xteinkConverter(profile, options = DEFAULT_OPTIMIZE_OPTIONS) {
|
|
5
|
+
return {
|
|
6
|
+
id: profile === "X4" ? "xteink-x4" : "xteink-x3",
|
|
7
|
+
from: ["epub"],
|
|
8
|
+
to: profile === "X4" ? "x4.epub" : "x3.epub",
|
|
9
|
+
version: optimizerVersion(options),
|
|
10
|
+
fidelity: .9,
|
|
11
|
+
options: OPTIMIZE_FIELDS,
|
|
12
|
+
withOptions: (values) => xteinkConverter(profile, optimizeOptionsFrom(values)),
|
|
13
|
+
run: async (data) => {
|
|
14
|
+
const [{ optimizeEpub }, { PROFILES }] = await Promise.all([import("./xteink/optimize.js"), import("./xteink/image.js")]);
|
|
15
|
+
return optimizeEpub(data, PROFILES[profile], options);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Converting EPUBs to the X4's and X3's optimized EPUBs. */
|
|
20
|
+
const CROSSPOINT_CONVERTERS = [xteinkConverter("X4"), xteinkConverter("X3")];
|
|
21
|
+
//#endregion
|
|
22
|
+
export { CROSSPOINT_CONVERTERS, xteinkConverter };
|
package/fonts.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fonts for CrossPoint (crosspoint-reader's docs/sd-card-fonts.md). `.cpfont` files, made for the
|
|
3
|
+
* reader at fixed sizes (`Literata_12.cpfont`), go through its font API into a family; TTF, OTF and
|
|
4
|
+
* TTC files are copied into `/fonts/<family>/`, and only readers with external RAM use those.
|
|
5
|
+
*/
|
|
6
|
+
export declare const FONT_ROOT = "/fonts";
|
|
7
|
+
export interface FontUpload {
|
|
8
|
+
family: string;
|
|
9
|
+
/** `.cpfont` through the font API; anything else copied into the family's folder. */
|
|
10
|
+
kind: 'cpfont' | 'direct';
|
|
11
|
+
name: string;
|
|
12
|
+
data: Blob;
|
|
13
|
+
}
|
|
14
|
+
/** A family name as the reader takes it: letters, digits, hyphens and underscores. */
|
|
15
|
+
export declare const familyName: (text: string) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Sorts picked font files into families. A `.cpfont`'s family is its name without the size
|
|
18
|
+
* (`Literata_12`); a TTF/OTF/TTC's is its folder's, when picked in one, else its name without the
|
|
19
|
+
* style (`Bookerly-Bold`). Other files are reported.
|
|
20
|
+
*/
|
|
21
|
+
export declare function sortFonts(picked: {
|
|
22
|
+
path: string;
|
|
23
|
+
data: Blob;
|
|
24
|
+
}[]): {
|
|
25
|
+
uploads: FontUpload[];
|
|
26
|
+
skipped: string[];
|
|
27
|
+
};
|
|
28
|
+
/** Whether a file starts as a `.cpfont` must (the reader checks the same). */
|
|
29
|
+
export declare function isCpfont(data: Blob): Promise<boolean>;
|
package/fonts.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region crosspoint-tools/src/fonts.ts
|
|
2
|
+
/**
|
|
3
|
+
* Fonts for CrossPoint (crosspoint-reader's docs/sd-card-fonts.md). `.cpfont` files, made for the
|
|
4
|
+
* reader at fixed sizes (`Literata_12.cpfont`), go through its font API into a family; TTF, OTF and
|
|
5
|
+
* TTC files are copied into `/fonts/<family>/`, and only readers with external RAM use those.
|
|
6
|
+
*/
|
|
7
|
+
const FONT_ROOT = "/fonts";
|
|
8
|
+
/** A family name as the reader takes it: letters, digits, hyphens and underscores. */
|
|
9
|
+
const familyName = (text) => text.replace(/\s+/g, "_").replace(/[^\w-]+/g, "").slice(0, 48);
|
|
10
|
+
const STYLES = /[-_ ]?(regular|bold|italic|bolditalic|bold[-_ ]?italic|book|medium|light|semibold|oblique)$/i;
|
|
11
|
+
/**
|
|
12
|
+
* Sorts picked font files into families. A `.cpfont`'s family is its name without the size
|
|
13
|
+
* (`Literata_12`); a TTF/OTF/TTC's is its folder's, when picked in one, else its name without the
|
|
14
|
+
* style (`Bookerly-Bold`). Other files are reported.
|
|
15
|
+
*/
|
|
16
|
+
function sortFonts(picked) {
|
|
17
|
+
const uploads = [];
|
|
18
|
+
const skipped = [];
|
|
19
|
+
for (const { path, data } of picked) {
|
|
20
|
+
const parts = path.split("/");
|
|
21
|
+
const name = parts.at(-1);
|
|
22
|
+
const folder = parts.length > 1 ? parts.at(-2) : void 0;
|
|
23
|
+
if (/\.cpfont$/i.test(name)) {
|
|
24
|
+
const family = familyName(name.replace(/\.cpfont$/i, "").replace(/_\d+$/, ""));
|
|
25
|
+
if (family) uploads.push({
|
|
26
|
+
family,
|
|
27
|
+
kind: "cpfont",
|
|
28
|
+
name: name.replace(/ /g, "_"),
|
|
29
|
+
data
|
|
30
|
+
});
|
|
31
|
+
else skipped.push(name);
|
|
32
|
+
} else if (/\.(ttf|otf|ttc)$/i.test(name)) {
|
|
33
|
+
const family = familyName(folder ?? name.replace(/\.[^.]+$/, "").replace(STYLES, ""));
|
|
34
|
+
if (family) uploads.push({
|
|
35
|
+
family,
|
|
36
|
+
kind: "direct",
|
|
37
|
+
name,
|
|
38
|
+
data
|
|
39
|
+
});
|
|
40
|
+
else skipped.push(name);
|
|
41
|
+
} else if (!name.startsWith(".")) skipped.push(name);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
uploads,
|
|
45
|
+
skipped
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Whether a file starts as a `.cpfont` must (the reader checks the same). */
|
|
49
|
+
async function isCpfont(data) {
|
|
50
|
+
const head = new Uint8Array(await data.slice(0, 8).arrayBuffer());
|
|
51
|
+
return new TextDecoder().decode(head) === "CPFONT\0\0";
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { FONT_ROOT, familyName, isCpfont, sortFonts };
|
package/formats.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
declare module '@nexlibris/tools/formats/formats' {
|
|
2
|
+
interface PluginFormats {
|
|
3
|
+
'x4.epub': true;
|
|
4
|
+
'x3.epub': true;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The CrossPoint readers' optimized EPUBs, made by the optimizer (`convert.ts`). They're EPUBs to
|
|
9
|
+
* the rest of the tools, once registered (`registerFormats`): read, parsed, indexed and written as any EPUB is.
|
|
10
|
+
*/
|
|
11
|
+
export declare const CROSSPOINT_FORMATS: {
|
|
12
|
+
'x4.epub': {
|
|
13
|
+
label: string;
|
|
14
|
+
mediaType: string;
|
|
15
|
+
epub: true;
|
|
16
|
+
};
|
|
17
|
+
'x3.epub': {
|
|
18
|
+
label: string;
|
|
19
|
+
mediaType: string;
|
|
20
|
+
epub: true;
|
|
21
|
+
};
|
|
22
|
+
};
|
package/formats.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region crosspoint-tools/src/formats.ts
|
|
2
|
+
/**
|
|
3
|
+
* The CrossPoint readers' optimized EPUBs, made by the optimizer (`convert.ts`). They're EPUBs to
|
|
4
|
+
* the rest of the tools, once registered (`registerFormats`): read, parsed, indexed and written as any EPUB is.
|
|
5
|
+
*/
|
|
6
|
+
const CROSSPOINT_FORMATS = {
|
|
7
|
+
"x4.epub": {
|
|
8
|
+
label: "EPUB (X4)",
|
|
9
|
+
mediaType: "application/epub+zip",
|
|
10
|
+
epub: true
|
|
11
|
+
},
|
|
12
|
+
"x3.epub": {
|
|
13
|
+
label: "EPUB (X3)",
|
|
14
|
+
mediaType: "application/epub+zip",
|
|
15
|
+
epub: true
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { CROSSPOINT_FORMATS };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CrossPoint's library index, `/.crosspoint/library.idx` on the reader's SD card (format CLX1).
|
|
3
|
+
* The reader writes it describing every book on the card, with the metadata it read from each,
|
|
4
|
+
* so one download says what's on the card. A port of the Kaitai Struct definition in
|
|
5
|
+
* `crosspoint-reader/docs/files/library_idx.ksy` (the format is documented in its
|
|
6
|
+
* `docs/file-formats.md`); keep the two in step.
|
|
7
|
+
*
|
|
8
|
+
* Only format version 3 is read: the reader rebuilds an index of any other version when it opens
|
|
9
|
+
* it, so an older one is out of date anyway.
|
|
10
|
+
*/
|
|
11
|
+
export declare const LIBRARY_INDEX_PATH = "/.crosspoint/library.idx";
|
|
12
|
+
export declare const LIBRARY_INDEX_VERSION = 3;
|
|
13
|
+
export type MetadataStatus = 'not-attempted' | 'extracted' | 'failed';
|
|
14
|
+
/** A book on the card, as the index records it. */
|
|
15
|
+
export interface IndexedBook {
|
|
16
|
+
/** The file's full path on the card, e.g. `/Books/Dune - Frank Herbert.epub`. */
|
|
17
|
+
path: string;
|
|
18
|
+
folder: string;
|
|
19
|
+
name: string;
|
|
20
|
+
size: number;
|
|
21
|
+
/** The book's own title, or the file name without its extension when it gave none. */
|
|
22
|
+
title: string;
|
|
23
|
+
/** The display author: one spelling for the same person across the library; empty if none. */
|
|
24
|
+
author: string;
|
|
25
|
+
/** The author as this book spells it. */
|
|
26
|
+
sourceAuthor: string;
|
|
27
|
+
/** The primary author's sort form ("Herbert, Frank"), if the book gave one. */
|
|
28
|
+
authorSort: string;
|
|
29
|
+
series: string;
|
|
30
|
+
/** As written in the book, e.g. "3" or "1.5"; empty without a series. */
|
|
31
|
+
seriesIndex: string;
|
|
32
|
+
tags: string[];
|
|
33
|
+
metadata: MetadataStatus;
|
|
34
|
+
/** When the file landed on the card, as an ISO timestamp; undefined if the card didn't say. */
|
|
35
|
+
modified?: string;
|
|
36
|
+
/** The order the reader first saw books in, for those without a time. */
|
|
37
|
+
firstSeen: number;
|
|
38
|
+
}
|
|
39
|
+
export type ListKind = 'identity' | 'books' | 'groups' | 'mixed';
|
|
40
|
+
export type ListRole = 'external' | 'recent' | 'title' | 'author' | 'series' | 'tags' | 'folders' | 'generated' | 'custom';
|
|
41
|
+
/**
|
|
42
|
+
* One of the reader's lists (shelves). `books` are indexes into `LibraryIndex.books`; `lists`
|
|
43
|
+
* are indexes into `LibraryIndex.lists` of the lists it contains (for groups and mixed lists).
|
|
44
|
+
*/
|
|
45
|
+
export interface IndexList {
|
|
46
|
+
kind: ListKind;
|
|
47
|
+
role: ListRole;
|
|
48
|
+
/** Shown as a tab or in the list picker. */
|
|
49
|
+
topLevel: boolean;
|
|
50
|
+
/** Built-in lists (Recent, Title, Author) have none; the reader names them. */
|
|
51
|
+
label?: string;
|
|
52
|
+
icon: number;
|
|
53
|
+
books: number[];
|
|
54
|
+
lists: number[];
|
|
55
|
+
}
|
|
56
|
+
export interface LibraryIndex {
|
|
57
|
+
/** Whether the reader read each book's metadata, rather than going by file names. */
|
|
58
|
+
metadataEnabled: boolean;
|
|
59
|
+
foldVersion: number;
|
|
60
|
+
books: IndexedBook[];
|
|
61
|
+
lists: IndexList[];
|
|
62
|
+
}
|
|
63
|
+
export declare class LibraryIndexError extends Error {
|
|
64
|
+
}
|
|
65
|
+
/** Reads a library index. Throws `LibraryIndexError` for a file that isn't one, or is another version. */
|
|
66
|
+
export declare function parseLibraryIndex(data: ArrayBuffer | Uint8Array): LibraryIndex;
|
|
67
|
+
/** The books on the reader's Recent list, newest first. */
|
|
68
|
+
export declare function recentBooks(index: LibraryIndex): IndexedBook[];
|
|
69
|
+
/** FNV-1a 64 of a path's UTF-8 bytes, as the index keys books (`clixPathHash` in the firmware). */
|
|
70
|
+
export declare function pathHash(path: string): bigint;
|
|
71
|
+
/** A book's stored path hash, for checking a path rebuilt from the index (tests use it). */
|
|
72
|
+
export declare function storedPathHash(data: ArrayBuffer | Uint8Array, book: number): bigint;
|
package/libraryIndex.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//#region crosspoint-tools/src/libraryIndex.ts
|
|
2
|
+
/**
|
|
3
|
+
* CrossPoint's library index, `/.crosspoint/library.idx` on the reader's SD card (format CLX1).
|
|
4
|
+
* The reader writes it describing every book on the card, with the metadata it read from each,
|
|
5
|
+
* so one download says what's on the card. A port of the Kaitai Struct definition in
|
|
6
|
+
* `crosspoint-reader/docs/files/library_idx.ksy` (the format is documented in its
|
|
7
|
+
* `docs/file-formats.md`); keep the two in step.
|
|
8
|
+
*
|
|
9
|
+
* Only format version 3 is read: the reader rebuilds an index of any other version when it opens
|
|
10
|
+
* it, so an older one is out of date anyway.
|
|
11
|
+
*/
|
|
12
|
+
const LIBRARY_INDEX_PATH = "/.crosspoint/library.idx";
|
|
13
|
+
const LIBRARY_INDEX_VERSION = 3;
|
|
14
|
+
const KINDS = [
|
|
15
|
+
"identity",
|
|
16
|
+
"books",
|
|
17
|
+
"groups",
|
|
18
|
+
"mixed"
|
|
19
|
+
];
|
|
20
|
+
const ROLES = [
|
|
21
|
+
"external",
|
|
22
|
+
"recent",
|
|
23
|
+
"title",
|
|
24
|
+
"author",
|
|
25
|
+
"series",
|
|
26
|
+
"tags",
|
|
27
|
+
"folders",
|
|
28
|
+
"generated",
|
|
29
|
+
"custom"
|
|
30
|
+
];
|
|
31
|
+
const STATUSES = [
|
|
32
|
+
"not-attempted",
|
|
33
|
+
"extracted",
|
|
34
|
+
"failed"
|
|
35
|
+
];
|
|
36
|
+
const RECORD_SIZE = 128;
|
|
37
|
+
const LIST_DESC_SIZE = 16;
|
|
38
|
+
var LibraryIndexError = class extends Error {};
|
|
39
|
+
/** Reads a library index. Throws `LibraryIndexError` for a file that isn't one, or is another version. */
|
|
40
|
+
function parseLibraryIndex(data) {
|
|
41
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
42
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
43
|
+
const fail = (message) => {
|
|
44
|
+
throw new LibraryIndexError(`Not a readable library index: ${message}.`);
|
|
45
|
+
};
|
|
46
|
+
if (bytes.length < 64 || text(bytes, 0, 4) !== "CLX1") fail("it doesn’t start with CLX1");
|
|
47
|
+
const version = bytes[4];
|
|
48
|
+
if (version !== 3) fail(`it’s version ${version}, not 3`);
|
|
49
|
+
const foldVersion = bytes[5];
|
|
50
|
+
const u16 = (offset) => view.getUint16(offset, true);
|
|
51
|
+
const u32 = (offset) => view.getUint32(offset, true);
|
|
52
|
+
const header = {
|
|
53
|
+
bookCount: u16(8),
|
|
54
|
+
folderCount: u16(10),
|
|
55
|
+
folderStart: u32(16),
|
|
56
|
+
recordStart: u32(24),
|
|
57
|
+
listStart: u32(28),
|
|
58
|
+
nameStart: u32(32),
|
|
59
|
+
nameLen: u32(36),
|
|
60
|
+
listLen: u32(44),
|
|
61
|
+
listCount: u16(48)
|
|
62
|
+
};
|
|
63
|
+
const within = (start, length, what) => {
|
|
64
|
+
if (start + length > bytes.length) fail(`${what} runs past the end`);
|
|
65
|
+
};
|
|
66
|
+
within(header.recordStart, header.bookCount * RECORD_SIZE, "the records");
|
|
67
|
+
within(header.nameStart, header.nameLen, "the names");
|
|
68
|
+
within(header.listStart, header.listLen, "the lists");
|
|
69
|
+
const folders = [];
|
|
70
|
+
let at = header.folderStart;
|
|
71
|
+
for (let i = 0; i < header.folderCount; i++) {
|
|
72
|
+
within(at, 1, "the folders");
|
|
73
|
+
const length = bytes[at];
|
|
74
|
+
within(at + 1, length, "the folders");
|
|
75
|
+
folders.push(text(bytes, at + 1, length));
|
|
76
|
+
at += 1 + length;
|
|
77
|
+
}
|
|
78
|
+
const books = [];
|
|
79
|
+
for (let i = 0; i < header.bookCount; i++) {
|
|
80
|
+
const record = header.recordStart + i * RECORD_SIZE;
|
|
81
|
+
const nameOffset = u32(record);
|
|
82
|
+
const nameLength = bytes[record + 12];
|
|
83
|
+
const folder = folders[u16(record + 10)] ?? fail(`book ${i} is in a folder that isn’t listed`);
|
|
84
|
+
let blob = header.nameStart + nameOffset;
|
|
85
|
+
within(blob, 8 + nameLength, "a book’s names");
|
|
86
|
+
blob += 8;
|
|
87
|
+
const name = text(bytes, blob, nameLength);
|
|
88
|
+
blob += nameLength;
|
|
89
|
+
const end = header.nameStart + header.nameLen;
|
|
90
|
+
const field = () => {
|
|
91
|
+
if (blob >= end) return "";
|
|
92
|
+
const length = bytes[blob];
|
|
93
|
+
within(blob + 1, length, "a book’s names");
|
|
94
|
+
const value = text(bytes, blob + 1, length);
|
|
95
|
+
blob += 1 + length;
|
|
96
|
+
return value;
|
|
97
|
+
};
|
|
98
|
+
const author = field();
|
|
99
|
+
const title = field();
|
|
100
|
+
const sourceAuthor = field();
|
|
101
|
+
const authorSort = foldVersion >= 5 ? field() : "";
|
|
102
|
+
const [series, seriesIndex, tags] = foldVersion >= 6 ? [
|
|
103
|
+
field(),
|
|
104
|
+
field(),
|
|
105
|
+
field()
|
|
106
|
+
] : [
|
|
107
|
+
"",
|
|
108
|
+
"",
|
|
109
|
+
""
|
|
110
|
+
];
|
|
111
|
+
const modified = fatTime(u32(record + 124));
|
|
112
|
+
books.push({
|
|
113
|
+
path: folder === "/" ? `/${name}` : `${folder}/${name}`,
|
|
114
|
+
folder,
|
|
115
|
+
name,
|
|
116
|
+
size: u32(record + 4),
|
|
117
|
+
title: title || name.replace(/\.[^.]+$/, ""),
|
|
118
|
+
author,
|
|
119
|
+
sourceAuthor,
|
|
120
|
+
authorSort,
|
|
121
|
+
series,
|
|
122
|
+
seriesIndex,
|
|
123
|
+
tags: tags ? tags.split("\n") : [],
|
|
124
|
+
metadata: STATUSES[bytes[record + 15]] ?? "not-attempted",
|
|
125
|
+
...modified && { modified },
|
|
126
|
+
firstSeen: u16(record + 8)
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const lists = [];
|
|
130
|
+
within(header.listStart, header.listCount * LIST_DESC_SIZE, "the list table");
|
|
131
|
+
for (let i = 0; i < header.listCount; i++) {
|
|
132
|
+
const desc = header.listStart + i * LIST_DESC_SIZE;
|
|
133
|
+
const kind = KINDS[bytes[desc]] ?? fail(`list ${i} is of an unknown kind`);
|
|
134
|
+
const labelLength = bytes[desc + 3];
|
|
135
|
+
const count = u16(desc + 4);
|
|
136
|
+
const entries = [];
|
|
137
|
+
if (kind !== "identity") {
|
|
138
|
+
const start = header.listStart + u32(desc + 8);
|
|
139
|
+
within(start, count * 2, `list ${i}`);
|
|
140
|
+
for (let n = 0; n < count; n++) entries.push(u16(start + n * 2));
|
|
141
|
+
}
|
|
142
|
+
const labelStart = header.listStart + u32(desc + 12);
|
|
143
|
+
if (labelLength) within(labelStart, labelLength, `list ${i}’s label`);
|
|
144
|
+
const books = kind === "identity" ? [...Array(header.bookCount).keys()] : kind === "groups" ? [] : entries.filter((e) => !(kind === "mixed" && e & 32768));
|
|
145
|
+
const contained = kind === "groups" ? entries : kind === "mixed" ? entries.filter((e) => e & 32768).map((e) => e & 32767) : [];
|
|
146
|
+
lists.push({
|
|
147
|
+
kind,
|
|
148
|
+
role: ROLES[bytes[desc + 1]] ?? "external",
|
|
149
|
+
topLevel: (bytes[desc + 2] & 1) === 1,
|
|
150
|
+
...labelLength && { label: text(bytes, labelStart, labelLength) },
|
|
151
|
+
icon: bytes[desc + 6],
|
|
152
|
+
books: books.filter((b) => b < header.bookCount),
|
|
153
|
+
lists: contained.filter((l) => l > i && l < header.listCount)
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
metadataEnabled: (bytes[7] & 1) === 1,
|
|
158
|
+
foldVersion,
|
|
159
|
+
books,
|
|
160
|
+
lists
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** The books on the reader's Recent list, newest first. */
|
|
164
|
+
function recentBooks(index) {
|
|
165
|
+
return ((index.lists.find((list) => list.role === "recent") ?? index.lists[0])?.books ?? []).map((n) => index.books[n]).reverse();
|
|
166
|
+
}
|
|
167
|
+
/** FNV-1a 64 of a path's UTF-8 bytes, as the index keys books (`clixPathHash` in the firmware). */
|
|
168
|
+
function pathHash(path) {
|
|
169
|
+
let hash = 14695981039346656037n;
|
|
170
|
+
for (const byte of new TextEncoder().encode(path)) hash = (hash ^ BigInt(byte)) * 1099511628211n & 18446744073709551615n;
|
|
171
|
+
return hash;
|
|
172
|
+
}
|
|
173
|
+
/** A book's stored path hash, for checking a path rebuilt from the index (tests use it). */
|
|
174
|
+
function storedPathHash(data, book) {
|
|
175
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
176
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
177
|
+
const record = view.getUint32(24, true) + book * RECORD_SIZE;
|
|
178
|
+
return view.getBigUint64(view.getUint32(32, true) + view.getUint32(record, true), true);
|
|
179
|
+
}
|
|
180
|
+
const decoder = new TextDecoder();
|
|
181
|
+
const text = (bytes, start, length) => decoder.decode(bytes.subarray(start, start + length));
|
|
182
|
+
/** A packed FAT date and time (date in the high 16 bits), as local time read as UTC; undefined for zero. */
|
|
183
|
+
function fatTime(packed) {
|
|
184
|
+
if (!packed) return void 0;
|
|
185
|
+
const date = packed >>> 16;
|
|
186
|
+
const time = packed & 65535;
|
|
187
|
+
const iso = new Date(Date.UTC(1980 + (date >>> 9), (date >>> 5 & 15) - 1, date & 31, time >>> 11, time >>> 5 & 63, (time & 31) * 2));
|
|
188
|
+
return Number.isNaN(iso.getTime()) ? void 0 : iso.toISOString();
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
export { LIBRARY_INDEX_PATH, LIBRARY_INDEX_VERSION, LibraryIndexError, parseLibraryIndex, pathHash, recentBooks, storedPathHash };
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexlibris/crosspoint-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Files for CrossPoint e-readers (Xteink X4 and X3): optimized EPUBs, sleep images, StarDict dictionaries, fonts and the reader's library index",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/digitaltembo/nexlibris.git",
|
|
9
|
+
"directory": "crosspoint-tools"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/digitaltembo/nexlibris/tree/main/crosspoint-tools#readme",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./*": {
|
|
15
|
+
"types": "./*.d.ts",
|
|
16
|
+
"default": "./*.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@nexlibris/tools": "0.1.1",
|
|
21
|
+
"jszip": "^3.10.2"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/sleepImage.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sleep images for CrossPoint (crosspoint-reader's USER_GUIDE, "Sleep Screen"): uncompressed BMPs
|
|
3
|
+
* at the screen's size, in `/.sleep` (one picked at random each time it sleeps), or a single
|
|
4
|
+
* `/sleep.bmp`, which wins over the folder. Pictures are made into them here, in grayscale (the
|
|
5
|
+
* screen is) so what's shown here is what the reader shows.
|
|
6
|
+
*/
|
|
7
|
+
export type Profile = 'X4' | 'X3';
|
|
8
|
+
/** Screen sizes, portrait, from the user guide. */
|
|
9
|
+
export declare const SCREEN: Record<Profile, {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}>;
|
|
13
|
+
/** Where the reader looks, in order: the single image, then the folder, then its visible fallback. */
|
|
14
|
+
export declare const SLEEP_FILE = "/sleep.bmp";
|
|
15
|
+
export declare const SLEEP_FOLDER = "/.sleep";
|
|
16
|
+
export declare const SLEEP_FALLBACK_FOLDER = "/sleep";
|
|
17
|
+
/** The reader's `sleepScreen` setting: positions in its options (CrossPointSettings' SLEEP_SCREEN_MODE). */
|
|
18
|
+
export declare const SLEEP_CUSTOM = 2;
|
|
19
|
+
export declare const SLEEP_COVER_CUSTOM = 4;
|
|
20
|
+
/** Fill: crop to cover the screen. Fit: the whole picture, on white. */
|
|
21
|
+
export type Placement = 'fill' | 'fit';
|
|
22
|
+
/** RGBA pixels as a 24-bit uncompressed BMP (bottom-up rows, each padded to 4 bytes). */
|
|
23
|
+
export declare function encodeBmp(rgba: Uint8ClampedArray, width: number, height: number): Blob;
|
|
24
|
+
/** Pixels in place as gray, by luminance; transparent ones as on white. */
|
|
25
|
+
export declare function toGray(rgba: Uint8ClampedArray): void;
|
|
26
|
+
/** Where a picture goes on the screen: its drawn rectangle. Exported for tests. */
|
|
27
|
+
export declare function placement(image: {
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}, screen: {
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
}, how: Placement): {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
/** A picture (any format the browser reads) as a sleep image for the reader's screen. */
|
|
40
|
+
export declare function makeSleepImage(picture: Blob, profile: Profile, how: Placement): Promise<Blob>;
|
|
41
|
+
/** A name for a sleep image made from a file: its name as a .bmp, kept to what the reader's file names allow. */
|
|
42
|
+
export declare function sleepImageName(name: string, taken: ReadonlySet<string>): string;
|
package/sleepImage.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//#region crosspoint-tools/src/sleepImage.ts
|
|
2
|
+
/** Screen sizes, portrait, from the user guide. */
|
|
3
|
+
const SCREEN = {
|
|
4
|
+
X4: {
|
|
5
|
+
width: 480,
|
|
6
|
+
height: 800
|
|
7
|
+
},
|
|
8
|
+
X3: {
|
|
9
|
+
width: 528,
|
|
10
|
+
height: 792
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/** Where the reader looks, in order: the single image, then the folder, then its visible fallback. */
|
|
14
|
+
const SLEEP_FILE = "/sleep.bmp";
|
|
15
|
+
const SLEEP_FOLDER = "/.sleep";
|
|
16
|
+
const SLEEP_FALLBACK_FOLDER = "/sleep";
|
|
17
|
+
/** The reader's `sleepScreen` setting: positions in its options (CrossPointSettings' SLEEP_SCREEN_MODE). */
|
|
18
|
+
const SLEEP_CUSTOM = 2;
|
|
19
|
+
const SLEEP_COVER_CUSTOM = 4;
|
|
20
|
+
/** RGBA pixels as a 24-bit uncompressed BMP (bottom-up rows, each padded to 4 bytes). */
|
|
21
|
+
function encodeBmp(rgba, width, height) {
|
|
22
|
+
const rowSize = Math.ceil(width * 3 / 4) * 4;
|
|
23
|
+
const dataSize = rowSize * height;
|
|
24
|
+
const buffer = new ArrayBuffer(54 + dataSize);
|
|
25
|
+
const view = new DataView(buffer);
|
|
26
|
+
view.setUint8(0, 66);
|
|
27
|
+
view.setUint8(1, 77);
|
|
28
|
+
view.setUint32(2, buffer.byteLength, true);
|
|
29
|
+
view.setUint32(10, 54, true);
|
|
30
|
+
view.setUint32(14, 40, true);
|
|
31
|
+
view.setInt32(18, width, true);
|
|
32
|
+
view.setInt32(22, height, true);
|
|
33
|
+
view.setUint16(26, 1, true);
|
|
34
|
+
view.setUint16(28, 24, true);
|
|
35
|
+
view.setUint32(34, dataSize, true);
|
|
36
|
+
view.setInt32(38, 2835, true);
|
|
37
|
+
view.setInt32(42, 2835, true);
|
|
38
|
+
const bytes = new Uint8Array(buffer);
|
|
39
|
+
for (let y = 0; y < height; y++) {
|
|
40
|
+
const row = 54 + (height - 1 - y) * rowSize;
|
|
41
|
+
for (let x = 0; x < width; x++) {
|
|
42
|
+
const from = (y * width + x) * 4;
|
|
43
|
+
const to = row + x * 3;
|
|
44
|
+
bytes[to] = rgba[from + 2];
|
|
45
|
+
bytes[to + 1] = rgba[from + 1];
|
|
46
|
+
bytes[to + 2] = rgba[from];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return new Blob([buffer], { type: "image/bmp" });
|
|
50
|
+
}
|
|
51
|
+
/** Pixels in place as gray, by luminance; transparent ones as on white. */
|
|
52
|
+
function toGray(rgba) {
|
|
53
|
+
for (let i = 0; i + 3 < rgba.length; i += 4) {
|
|
54
|
+
const alpha = rgba[i + 3] / 255;
|
|
55
|
+
const gray = .2126 * rgba[i] + .7152 * rgba[i + 1] + .0722 * rgba[i + 2];
|
|
56
|
+
rgba[i] = rgba[i + 1] = rgba[i + 2] = Math.round(gray * alpha + 255 * (1 - alpha));
|
|
57
|
+
rgba[i + 3] = 255;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Where a picture goes on the screen: its drawn rectangle. Exported for tests. */
|
|
61
|
+
function placement(image, screen, how) {
|
|
62
|
+
const scale = (how === "fill" ? Math.max : Math.min)(screen.width / image.width, screen.height / image.height);
|
|
63
|
+
const width = Math.round(image.width * scale);
|
|
64
|
+
const height = Math.round(image.height * scale);
|
|
65
|
+
return {
|
|
66
|
+
x: Math.round((screen.width - width) / 2),
|
|
67
|
+
y: Math.round((screen.height - height) / 2),
|
|
68
|
+
width,
|
|
69
|
+
height
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A picture (any format the browser reads) as a sleep image for the reader's screen. */
|
|
73
|
+
async function makeSleepImage(picture, profile, how) {
|
|
74
|
+
const screen = SCREEN[profile];
|
|
75
|
+
const bitmap = await createImageBitmap(picture).catch(() => {
|
|
76
|
+
throw new Error("This isn’t a picture nexlibris can read.");
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
const canvas = document.createElement("canvas");
|
|
80
|
+
canvas.width = screen.width;
|
|
81
|
+
canvas.height = screen.height;
|
|
82
|
+
const context = canvas.getContext("2d");
|
|
83
|
+
context.fillStyle = "#fff";
|
|
84
|
+
context.fillRect(0, 0, screen.width, screen.height);
|
|
85
|
+
const at = placement(bitmap, screen, how);
|
|
86
|
+
context.drawImage(bitmap, at.x, at.y, at.width, at.height);
|
|
87
|
+
const pixels = context.getImageData(0, 0, screen.width, screen.height).data;
|
|
88
|
+
toGray(pixels);
|
|
89
|
+
return encodeBmp(pixels, screen.width, screen.height);
|
|
90
|
+
} finally {
|
|
91
|
+
bitmap.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** A name for a sleep image made from a file: its name as a .bmp, kept to what the reader's file names allow. */
|
|
95
|
+
function sleepImageName(name, taken) {
|
|
96
|
+
const stem = name.replace(/\.[^.]+$/, "").replace(/[^\w\- ]+/g, "").trim().slice(0, 60) || "sleep";
|
|
97
|
+
let candidate = `${stem}.bmp`;
|
|
98
|
+
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${stem} ${n}.bmp`;
|
|
99
|
+
return candidate;
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { SCREEN, SLEEP_COVER_CUSTOM, SLEEP_CUSTOM, SLEEP_FALLBACK_FOLDER, SLEEP_FILE, SLEEP_FOLDER, encodeBmp, makeSleepImage, placement, sleepImageName, toGray };
|