@nexlibris/crosspoint-tools 0.0.0-stage → 0.1.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.
- 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/stardict.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StarDict dictionaries for CrossPoint (crosspoint-reader's docs/dictionary.md): one folder per
|
|
3
|
+
* dictionary under `/dictionaries`, holding an uncompressed `.idx`, a `.dict` or `.dict.dz`, and
|
|
4
|
+
* optionally a `.syn` and `.ifo`. Picked files are sorted into dictionaries here: archives (zip,
|
|
5
|
+
* tar, tar.gz) are unpacked and a gzipped `.idx` decompressed, which the reader can't do.
|
|
6
|
+
*/
|
|
7
|
+
/** Where dictionaries go, and the hidden root the reader also reads. */
|
|
8
|
+
export declare const DICTIONARY_ROOT = "/dictionaries";
|
|
9
|
+
export declare const HIDDEN_DICTIONARY_ROOT = "/.dictionaries";
|
|
10
|
+
export interface DictionaryFile {
|
|
11
|
+
name: string;
|
|
12
|
+
data: Blob;
|
|
13
|
+
}
|
|
14
|
+
export interface PreparedDictionary {
|
|
15
|
+
/** The folder's name, which the reader shows on its tabs. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** The `.ifo`'s name for it, if it has one. */
|
|
18
|
+
title?: string;
|
|
19
|
+
words?: number;
|
|
20
|
+
files: DictionaryFile[];
|
|
21
|
+
}
|
|
22
|
+
export interface Prepared {
|
|
23
|
+
dictionaries: PreparedDictionary[];
|
|
24
|
+
/** Files or groups that couldn't be used, and why. */
|
|
25
|
+
problems: string[];
|
|
26
|
+
}
|
|
27
|
+
/** A picked file, with the folders it was in (`webkitRelativePath`) when a folder was picked. */
|
|
28
|
+
export interface PickedFile {
|
|
29
|
+
path: string;
|
|
30
|
+
data: Blob;
|
|
31
|
+
}
|
|
32
|
+
/** A name the reader takes for a folder: no slashes or odd characters. */
|
|
33
|
+
export declare const folderName: (text: string) => string;
|
|
34
|
+
/** An `.ifo`'s `key=value` lines. */
|
|
35
|
+
export declare function parseIfo(text: string): Record<string, string>;
|
|
36
|
+
/** A tar archive's files (ustar, with GNU long names). Exported for tests. */
|
|
37
|
+
export declare function untar(bytes: Uint8Array<ArrayBuffer>): PickedFile[];
|
|
38
|
+
/** Sorts picked files (loose, in folders, or in archives) into dictionaries ready to copy to the reader. */
|
|
39
|
+
export declare function prepareDictionaries(picked: PickedFile[]): Promise<Prepared>;
|
|
40
|
+
/** Files the reader makes beside a dictionary's, and rebuilds when they're gone. */
|
|
41
|
+
export declare const isSidecar: (name: string) => boolean;
|
package/stardict.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
//#region crosspoint-tools/src/stardict.ts
|
|
3
|
+
/**
|
|
4
|
+
* StarDict dictionaries for CrossPoint (crosspoint-reader's docs/dictionary.md): one folder per
|
|
5
|
+
* dictionary under `/dictionaries`, holding an uncompressed `.idx`, a `.dict` or `.dict.dz`, and
|
|
6
|
+
* optionally a `.syn` and `.ifo`. Picked files are sorted into dictionaries here: archives (zip,
|
|
7
|
+
* tar, tar.gz) are unpacked and a gzipped `.idx` decompressed, which the reader can't do.
|
|
8
|
+
*/
|
|
9
|
+
/** Where dictionaries go, and the hidden root the reader also reads. */
|
|
10
|
+
const DICTIONARY_ROOT = "/dictionaries";
|
|
11
|
+
const HIDDEN_DICTIONARY_ROOT = "/.dictionaries";
|
|
12
|
+
/** A dictionary file's role, by the end of its name. */
|
|
13
|
+
const PARTS = [
|
|
14
|
+
[/\.idx\.gz$/i, "idx.gz"],
|
|
15
|
+
[/\.idx$/i, "idx"],
|
|
16
|
+
[/\.dict\.dz$/i, "dict.dz"],
|
|
17
|
+
[/\.dict$/i, "dict"],
|
|
18
|
+
[/\.syn$/i, "syn"],
|
|
19
|
+
[/\.ifo$/i, "ifo"]
|
|
20
|
+
];
|
|
21
|
+
function part(name) {
|
|
22
|
+
for (const [pattern, kind] of PARTS) if (pattern.test(name)) return {
|
|
23
|
+
stem: name.replace(pattern, ""),
|
|
24
|
+
kind
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** A name the reader takes for a folder: no slashes or odd characters. */
|
|
28
|
+
const folderName = (text) => text.replace(/[^\w\-. ]+/g, "").replace(/^\.+/, "").trim().slice(0, 48) || "dictionary";
|
|
29
|
+
/** An `.ifo`'s `key=value` lines. */
|
|
30
|
+
function parseIfo(text) {
|
|
31
|
+
const fields = {};
|
|
32
|
+
for (const line of text.split(/\r?\n/)) {
|
|
33
|
+
const at = line.indexOf("=");
|
|
34
|
+
if (at > 0) fields[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
35
|
+
}
|
|
36
|
+
return fields;
|
|
37
|
+
}
|
|
38
|
+
async function gunzip(data) {
|
|
39
|
+
return new Response(data.stream().pipeThrough(new DecompressionStream("gzip"))).blob();
|
|
40
|
+
}
|
|
41
|
+
const text = (bytes, start, length) => {
|
|
42
|
+
const slice = bytes.subarray(start, start + length);
|
|
43
|
+
const end = slice.indexOf(0);
|
|
44
|
+
return new TextDecoder().decode(end < 0 ? slice : slice.subarray(0, end));
|
|
45
|
+
};
|
|
46
|
+
/** A tar archive's files (ustar, with GNU long names). Exported for tests. */
|
|
47
|
+
function untar(bytes) {
|
|
48
|
+
const files = [];
|
|
49
|
+
let longName;
|
|
50
|
+
for (let at = 0; at + 512 <= bytes.length;) {
|
|
51
|
+
const name = text(bytes, at, 100);
|
|
52
|
+
if (!name) break;
|
|
53
|
+
const size = parseInt(text(bytes, at + 124, 12).trim() || "0", 8);
|
|
54
|
+
const type = String.fromCharCode(bytes[at + 156]);
|
|
55
|
+
const prefix = text(bytes, at + 345, 155);
|
|
56
|
+
const body = bytes.slice(at + 512, at + 512 + size);
|
|
57
|
+
if (type === "L") longName = text(body, 0, size);
|
|
58
|
+
else {
|
|
59
|
+
if (type === "0" || type === "\0") files.push({
|
|
60
|
+
path: longName ?? (prefix ? `${prefix}/${name}` : name),
|
|
61
|
+
data: new Blob([body])
|
|
62
|
+
});
|
|
63
|
+
longName = void 0;
|
|
64
|
+
}
|
|
65
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
/** Picked files with any archives among them unpacked. */
|
|
70
|
+
async function unpack(picked, problems) {
|
|
71
|
+
const files = [];
|
|
72
|
+
for (const file of picked) {
|
|
73
|
+
const lower = file.path.toLowerCase();
|
|
74
|
+
try {
|
|
75
|
+
if (lower.endsWith(".zip")) {
|
|
76
|
+
const zip = await JSZip.loadAsync(await file.data.arrayBuffer());
|
|
77
|
+
for (const entry of Object.values(zip.files)) if (!entry.dir) files.push({
|
|
78
|
+
path: entry.name,
|
|
79
|
+
data: await entry.async("blob")
|
|
80
|
+
});
|
|
81
|
+
} else if (/\.(tar\.gz|tgz)$/.test(lower)) files.push(...untar(new Uint8Array(await (await gunzip(file.data)).arrayBuffer())));
|
|
82
|
+
else if (lower.endsWith(".tar")) files.push(...untar(new Uint8Array(await file.data.arrayBuffer())));
|
|
83
|
+
else if (/\.(tar\.bz2|tbz2?|tar\.xz|7z|rar)$/.test(lower)) problems.push(`${baseName(file.path)}: unpack this on your computer first; nexlibris reads zip, tar and tar.gz.`);
|
|
84
|
+
else files.push(file);
|
|
85
|
+
} catch {
|
|
86
|
+
problems.push(`${baseName(file.path)}: couldn’t be unpacked.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return files;
|
|
90
|
+
}
|
|
91
|
+
const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
92
|
+
const folderOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf("/")));
|
|
93
|
+
/** Sorts picked files (loose, in folders, or in archives) into dictionaries ready to copy to the reader. */
|
|
94
|
+
async function prepareDictionaries(picked) {
|
|
95
|
+
const problems = [];
|
|
96
|
+
const files = await unpack(picked, problems);
|
|
97
|
+
const groups = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const name = baseName(file.path);
|
|
100
|
+
const found = part(name);
|
|
101
|
+
if (!found || name.startsWith(".")) continue;
|
|
102
|
+
const folder = folderOf(file.path);
|
|
103
|
+
const key = `${folder}\n${found.stem}`;
|
|
104
|
+
const group = groups.get(key) ?? {
|
|
105
|
+
folder,
|
|
106
|
+
stem: found.stem,
|
|
107
|
+
parts: /* @__PURE__ */ new Map()
|
|
108
|
+
};
|
|
109
|
+
group.parts.set(found.kind, file);
|
|
110
|
+
groups.set(key, group);
|
|
111
|
+
}
|
|
112
|
+
const dictionaries = [];
|
|
113
|
+
const stemsIn = (folder) => [...groups.values()].filter((g) => g.folder === folder).length;
|
|
114
|
+
for (const { folder, stem, parts } of groups.values()) {
|
|
115
|
+
const label = stem || baseName(folder);
|
|
116
|
+
const idx = parts.get("idx") ?? parts.get("idx.gz");
|
|
117
|
+
const dict = parts.get("dict.dz") ?? parts.get("dict");
|
|
118
|
+
if (!idx || !dict) {
|
|
119
|
+
problems.push(`${label}: a dictionary needs its ${idx ? ".dict or .dict.dz" : ".idx"} file too.`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const ifoFile = parts.get("ifo");
|
|
123
|
+
const ifo = ifoFile ? parseIfo(await ifoFile.data.text()) : {};
|
|
124
|
+
if (ifo.idxoffsetbits === "64") {
|
|
125
|
+
problems.push(`${label}: uses 64-bit offsets, which CrossPoint can’t read.`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let index;
|
|
129
|
+
try {
|
|
130
|
+
index = parts.has("idx") ? {
|
|
131
|
+
name: `${stem}.idx`,
|
|
132
|
+
data: idx.data
|
|
133
|
+
} : {
|
|
134
|
+
name: `${stem}.idx`,
|
|
135
|
+
data: await gunzip(idx.data)
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
problems.push(`${label}: its .idx.gz couldn’t be decompressed.`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const kept = [index, {
|
|
142
|
+
name: baseName(dict.path),
|
|
143
|
+
data: dict.data
|
|
144
|
+
}];
|
|
145
|
+
const syn = parts.get("syn");
|
|
146
|
+
if (syn) kept.push({
|
|
147
|
+
name: baseName(syn.path),
|
|
148
|
+
data: syn.data
|
|
149
|
+
});
|
|
150
|
+
if (ifoFile) kept.push({
|
|
151
|
+
name: baseName(ifoFile.path),
|
|
152
|
+
data: ifoFile.data
|
|
153
|
+
});
|
|
154
|
+
const name = folderName(folder && stemsIn(folder) === 1 ? baseName(folder) : stem);
|
|
155
|
+
const words = Number(ifo.wordcount);
|
|
156
|
+
dictionaries.push({
|
|
157
|
+
name,
|
|
158
|
+
...ifo.bookname && { title: ifo.bookname },
|
|
159
|
+
...Number.isFinite(words) && words > 0 && { words },
|
|
160
|
+
files: kept
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (!dictionaries.length && !problems.length) problems.push("No StarDict dictionary (.idx and .dict files) was found in what was picked.");
|
|
164
|
+
return {
|
|
165
|
+
dictionaries,
|
|
166
|
+
problems
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Files the reader makes beside a dictionary's, and rebuilds when they're gone. */
|
|
170
|
+
const isSidecar = (name) => /\.(qidx|sidx)$/i.test(name);
|
|
171
|
+
//#endregion
|
|
172
|
+
export { DICTIONARY_ROOT, HIDDEN_DICTIONARY_ROOT, folderName, isSidecar, parseIfo, prepareDictionaries, untar };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ImageOptions } from './options';
|
|
2
|
+
export { DEFAULT_IMAGE_OPTIONS, type ImageOptions } from './options';
|
|
3
|
+
/**
|
|
4
|
+
* Image optimization for CrossPoint readers, ported from `process_image` in the
|
|
5
|
+
* CrossPoint Calibre plugins' `optimizer.py` (MIT), itself a port of the
|
|
6
|
+
* device web UI's canvas pipeline: optional auto-crop of uniform margins, scale
|
|
7
|
+
* to fit the screen, flatten transparency onto white, grayscale, baseline JPEG.
|
|
8
|
+
*/
|
|
9
|
+
export interface Profile {
|
|
10
|
+
label: string;
|
|
11
|
+
/** Short and long edge, portrait. */
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const PROFILES: {
|
|
16
|
+
X4: {
|
|
17
|
+
label: string;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
};
|
|
21
|
+
X3: {
|
|
22
|
+
label: string;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/** RGBA pixels, as from `getImageData`. */
|
|
28
|
+
export interface Pixels {
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
data: Uint8ClampedArray;
|
|
32
|
+
}
|
|
33
|
+
export interface Box {
|
|
34
|
+
left: number;
|
|
35
|
+
top: number;
|
|
36
|
+
/** Exclusive. */
|
|
37
|
+
right: number;
|
|
38
|
+
bottom: number;
|
|
39
|
+
}
|
|
40
|
+
/** The box trimming an image's uniform margins, or undefined when cropping isn't worth it. */
|
|
41
|
+
export declare function findCropBox(pixels: Pixels): Box | undefined;
|
|
42
|
+
export declare function shouldSkipAutoCrop(path: string, width: number, height: number): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The size to draw at: fitted to the screen, keeping the aspect ratio. An image that already fits is kept as it
|
|
45
|
+
* is, unless it was cropped, which scales it up to fill the screen.
|
|
46
|
+
*/
|
|
47
|
+
export declare function fitSize(width: number, height: number, profile: Profile, cropped: boolean): {
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
};
|
|
51
|
+
/** Flattens onto white and, optionally, converts to grayscale with ITU-R 601 luma (as the device's web UI does). */
|
|
52
|
+
export declare function flattenPixels({ data }: Pixels, grayscale: boolean): void;
|
|
53
|
+
/** Optimizes one image with the browser's decoder and canvas. Throws if the image can't be decoded. */
|
|
54
|
+
export declare function processImage(data: Uint8Array, path: string, profile: Profile, options: ImageOptions): Promise<Uint8Array>;
|
package/xteink/image.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { DEFAULT_IMAGE_OPTIONS } from "./options.js";
|
|
2
|
+
//#region crosspoint-tools/src/xteink/image.ts
|
|
3
|
+
const PROFILES = {
|
|
4
|
+
X4: {
|
|
5
|
+
label: "X4",
|
|
6
|
+
width: 480,
|
|
7
|
+
height: 800
|
|
8
|
+
},
|
|
9
|
+
X3: {
|
|
10
|
+
label: "X3",
|
|
11
|
+
width: 528,
|
|
12
|
+
height: 792
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const CROP_WHITE_THRESHOLD = 245;
|
|
16
|
+
const CROP_BACKGROUND_TOLERANCE = 28;
|
|
17
|
+
const CROP_BACKGROUND_MAX_SPREAD = 24;
|
|
18
|
+
const CROP_EDGE_SAMPLE_SIZE = 12;
|
|
19
|
+
const CROP_PADDING_PX = 8;
|
|
20
|
+
const MIN_CROP_SAVINGS_RATIO = .08;
|
|
21
|
+
const MIN_COLOR_CROP_SAVINGS_RATIO = .2;
|
|
22
|
+
const MIN_CROP_DIMENSION = 240;
|
|
23
|
+
const COVER_NAME_RE = /(^|\/)(cover|thumbnail|thumb|icon)[^/]*\.(jpe?g|png|gif|webp|bmp)$/i;
|
|
24
|
+
/** A pixel composited onto white. */
|
|
25
|
+
function flattened(data, i) {
|
|
26
|
+
const a = data[i + 3] / 255;
|
|
27
|
+
return [
|
|
28
|
+
0,
|
|
29
|
+
1,
|
|
30
|
+
2
|
|
31
|
+
].map((c) => data[i + c] * a + 255 * (1 - a));
|
|
32
|
+
}
|
|
33
|
+
/** The mean colour of the edges, sampled in eight places, or undefined if they differ (no uniform margin). */
|
|
34
|
+
function estimateCropBackground({ width, height, data }) {
|
|
35
|
+
const sample = Math.min(CROP_EDGE_SAMPLE_SIZE, Math.floor(width / 8), Math.floor(height / 8));
|
|
36
|
+
if (sample < 2) return void 0;
|
|
37
|
+
const midX = Math.floor((width - sample) / 2);
|
|
38
|
+
const midY = Math.floor((height - sample) / 2);
|
|
39
|
+
const means = [
|
|
40
|
+
[0, 0],
|
|
41
|
+
[width - sample, 0],
|
|
42
|
+
[0, height - sample],
|
|
43
|
+
[width - sample, height - sample],
|
|
44
|
+
[midX, 0],
|
|
45
|
+
[midX, height - sample],
|
|
46
|
+
[0, midY],
|
|
47
|
+
[width - sample, midY]
|
|
48
|
+
].map(([x0, y0]) => {
|
|
49
|
+
const sum = [
|
|
50
|
+
0,
|
|
51
|
+
0,
|
|
52
|
+
0
|
|
53
|
+
];
|
|
54
|
+
for (let y = y0; y < y0 + sample; y++) for (let x = x0; x < x0 + sample; x++) {
|
|
55
|
+
const px = flattened(data, (y * width + x) * 4);
|
|
56
|
+
for (let c = 0; c < 3; c++) sum[c] += px[c];
|
|
57
|
+
}
|
|
58
|
+
return sum.map((s) => s / (sample * sample));
|
|
59
|
+
});
|
|
60
|
+
const average = [
|
|
61
|
+
0,
|
|
62
|
+
1,
|
|
63
|
+
2
|
|
64
|
+
].map((c) => means.reduce((s, m) => s + m[c], 0) / means.length);
|
|
65
|
+
return Math.max(...means.flatMap((m) => m.map((v, c) => Math.abs(v - average[c])))) > CROP_BACKGROUND_MAX_SPREAD ? void 0 : average;
|
|
66
|
+
}
|
|
67
|
+
/** The box trimming an image's uniform margins, or undefined when cropping isn't worth it. */
|
|
68
|
+
function findCropBox(pixels) {
|
|
69
|
+
const { width, height, data } = pixels;
|
|
70
|
+
const background = estimateCropBackground(pixels);
|
|
71
|
+
const [r0, g0, b0] = background?.map(Math.round) ?? [];
|
|
72
|
+
const isContent = (i) => {
|
|
73
|
+
const a = data[i + 3] / 255;
|
|
74
|
+
const r = Math.round(data[i] * a + 255 * (1 - a));
|
|
75
|
+
const g = Math.round(data[i + 1] * a + 255 * (1 - a));
|
|
76
|
+
const b = Math.round(data[i + 2] * a + 255 * (1 - a));
|
|
77
|
+
if (r0 === void 0) return r < CROP_WHITE_THRESHOLD || g < CROP_WHITE_THRESHOLD || b < CROP_WHITE_THRESHOLD;
|
|
78
|
+
const t = CROP_BACKGROUND_TOLERANCE;
|
|
79
|
+
return Math.abs(r - r0) > t || Math.abs(g - g0) > t || Math.abs(b - b0) > t;
|
|
80
|
+
};
|
|
81
|
+
let left = width;
|
|
82
|
+
let top = height;
|
|
83
|
+
let right = 0;
|
|
84
|
+
let bottom = 0;
|
|
85
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
86
|
+
if (!isContent((y * width + x) * 4)) continue;
|
|
87
|
+
left = Math.min(left, x);
|
|
88
|
+
top = Math.min(top, y);
|
|
89
|
+
right = Math.max(right, x + 1);
|
|
90
|
+
bottom = Math.max(bottom, y + 1);
|
|
91
|
+
}
|
|
92
|
+
if (right === 0) return void 0;
|
|
93
|
+
const box = {
|
|
94
|
+
left: Math.max(0, left - CROP_PADDING_PX),
|
|
95
|
+
top: Math.max(0, top - CROP_PADDING_PX),
|
|
96
|
+
right: Math.min(width, right + CROP_PADDING_PX),
|
|
97
|
+
bottom: Math.min(height, bottom + CROP_PADDING_PX)
|
|
98
|
+
};
|
|
99
|
+
const saved = 1 - (box.right - box.left) * (box.bottom - box.top) / (width * height);
|
|
100
|
+
const nearWhite = background?.every((c) => c >= CROP_WHITE_THRESHOLD);
|
|
101
|
+
return saved < (!background || nearWhite ? MIN_CROP_SAVINGS_RATIO : MIN_COLOR_CROP_SAVINGS_RATIO) ? void 0 : box;
|
|
102
|
+
}
|
|
103
|
+
function shouldSkipAutoCrop(path, width, height) {
|
|
104
|
+
return width < MIN_CROP_DIMENSION || height < MIN_CROP_DIMENSION || COVER_NAME_RE.test(path);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The size to draw at: fitted to the screen, keeping the aspect ratio. An image that already fits is kept as it
|
|
108
|
+
* is, unless it was cropped, which scales it up to fill the screen.
|
|
109
|
+
*/
|
|
110
|
+
function fitSize(width, height, profile, cropped) {
|
|
111
|
+
if (width <= profile.width && height <= profile.height && !cropped) return {
|
|
112
|
+
width,
|
|
113
|
+
height
|
|
114
|
+
};
|
|
115
|
+
const scale = Math.min(profile.width / width, profile.height / height);
|
|
116
|
+
return {
|
|
117
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
118
|
+
height: Math.max(1, Math.round(height * scale))
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Flattens onto white and, optionally, converts to grayscale with ITU-R 601 luma (as the device's web UI does). */
|
|
122
|
+
function flattenPixels({ data }, grayscale) {
|
|
123
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
124
|
+
const [r, g, b] = flattened(data, i);
|
|
125
|
+
if (grayscale) {
|
|
126
|
+
const luma = .299 * r + .587 * g + .114 * b;
|
|
127
|
+
data[i] = data[i + 1] = data[i + 2] = luma;
|
|
128
|
+
} else {
|
|
129
|
+
data[i] = r;
|
|
130
|
+
data[i + 1] = g;
|
|
131
|
+
data[i + 2] = b;
|
|
132
|
+
}
|
|
133
|
+
data[i + 3] = 255;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Optimizes one image with the browser's decoder and canvas. Throws if the image can't be decoded. */
|
|
137
|
+
async function processImage(data, path, profile, options) {
|
|
138
|
+
const source = await createImageBitmap(new Blob([data]));
|
|
139
|
+
try {
|
|
140
|
+
let crop = {
|
|
141
|
+
left: 0,
|
|
142
|
+
top: 0,
|
|
143
|
+
right: source.width,
|
|
144
|
+
bottom: source.height
|
|
145
|
+
};
|
|
146
|
+
let cropped = false;
|
|
147
|
+
if (options.autoCrop && !shouldSkipAutoCrop(path, source.width, source.height)) {
|
|
148
|
+
const full = canvas(source.width, source.height);
|
|
149
|
+
full.context.drawImage(source, 0, 0);
|
|
150
|
+
const box = findCropBox(full.context.getImageData(0, 0, source.width, source.height));
|
|
151
|
+
if (box) {
|
|
152
|
+
crop = box;
|
|
153
|
+
cropped = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const size = fitSize(crop.right - crop.left, crop.bottom - crop.top, profile, cropped);
|
|
157
|
+
const out = canvas(size.width, size.height);
|
|
158
|
+
out.context.imageSmoothingQuality = "high";
|
|
159
|
+
out.context.drawImage(source, crop.left, crop.top, crop.right - crop.left, crop.bottom - crop.top, 0, 0, size.width, size.height);
|
|
160
|
+
const pixels = out.context.getImageData(0, 0, size.width, size.height);
|
|
161
|
+
flattenPixels(pixels, options.grayscale);
|
|
162
|
+
out.context.putImageData(pixels, 0, 0);
|
|
163
|
+
const jpeg = await out.toBlob("image/jpeg", options.quality / 100);
|
|
164
|
+
return new Uint8Array(await jpeg.arrayBuffer());
|
|
165
|
+
} finally {
|
|
166
|
+
source.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* A canvas to draw on and encode from. On a page it's a `<canvas>`, encoded with the synchronous `toDataURL`:
|
|
171
|
+
* Chrome defers async encoding (`toBlob`, `convertToBlob`) in background tabs to about one a second, which made
|
|
172
|
+
* a sync left running in another tab crawl. In a worker, where there's no DOM, it's an OffscreenCanvas.
|
|
173
|
+
*/
|
|
174
|
+
function canvas(width, height) {
|
|
175
|
+
if (typeof document === "undefined") {
|
|
176
|
+
const c = new OffscreenCanvas(width, height);
|
|
177
|
+
const context = c.getContext("2d", { willReadFrequently: true });
|
|
178
|
+
if (!context) throw new Error("No 2D canvas");
|
|
179
|
+
return {
|
|
180
|
+
context,
|
|
181
|
+
toBlob: (type, quality) => c.convertToBlob({
|
|
182
|
+
type,
|
|
183
|
+
quality
|
|
184
|
+
})
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const c = document.createElement("canvas");
|
|
188
|
+
c.width = width;
|
|
189
|
+
c.height = height;
|
|
190
|
+
const context = c.getContext("2d", { willReadFrequently: true });
|
|
191
|
+
if (!context) throw new Error("No 2D canvas");
|
|
192
|
+
return {
|
|
193
|
+
context,
|
|
194
|
+
toBlob: async (type, quality) => {
|
|
195
|
+
const url = c.toDataURL(type, quality);
|
|
196
|
+
const binary = atob(url.slice(url.indexOf(",") + 1));
|
|
197
|
+
return new Blob([Uint8Array.from(binary, (c) => c.charCodeAt(0))], { type });
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
export { DEFAULT_IMAGE_OPTIONS, PROFILES, findCropBox, fitSize, flattenPixels, processImage, shouldSkipAutoCrop };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Profile } from './image';
|
|
2
|
+
import { type OptimizeOptions } from './options';
|
|
3
|
+
/**
|
|
4
|
+
* Optimizes an EPUB for a CrossPoint reader (Xteink X4/X3), ported from
|
|
5
|
+
* `crosspoint_reader/optimizer.py` in the CrossPoint Calibre plugins (MIT),
|
|
6
|
+
* which mirrors the device web UI's optimizer:
|
|
7
|
+
*
|
|
8
|
+
* - every raster image is re-encoded for the screen (see `image.ts`) and renamed `.jpg`;
|
|
9
|
+
* - markup is fixed up: references renamed, stale `<img>` sizes stripped,
|
|
10
|
+
* SVG-wrapped images unwrapped, a small defensive stylesheet injected;
|
|
11
|
+
* - the OPF gets JPEG media types, no `svg` properties and a cover meta; the NCX's
|
|
12
|
+
* identifier is synced with the OPF's;
|
|
13
|
+
* - then the text passes (`textsplit.ts`) split what the firmware can't hold in RAM.
|
|
14
|
+
*
|
|
15
|
+
* Markup is rewritten with string operations (the Python's fallback path), so
|
|
16
|
+
* anything not being fixed stays byte-for-byte as it was.
|
|
17
|
+
*/
|
|
18
|
+
export { DEFAULT_OPTIMIZE_OPTIONS, type OptimizeOptions } from './options';
|
|
19
|
+
export type ImageProcessor = (data: Uint8Array, path: string) => Promise<Uint8Array>;
|
|
20
|
+
export declare const DEFENSIVE_STYLE: string;
|
|
21
|
+
export declare function optimizeEpub(data: Blob, profile: Profile, options?: OptimizeOptions, image?: ImageProcessor, log?: (message: string) => void): Promise<Blob>;
|
|
22
|
+
/** UTF-8 (without a BOM), or else the encoding the XML declaration or meta charset names. */
|
|
23
|
+
export declare function decodeText(raw: Uint8Array): string;
|
|
24
|
+
export declare const renameRefs: (text: string) => string;
|
|
25
|
+
/** Removes width and height (attributes and inline styles) from every `<img>`. */
|
|
26
|
+
export declare function stripImgDimensions(text: string): string;
|
|
27
|
+
/** Replaces SVG-wrapped images (common for covers), which the firmware can't render, with plain `<img>`s. */
|
|
28
|
+
export declare function unwrapSvgImages(text: string): string;
|
|
29
|
+
export declare function fixXhtml(text: string): string;
|
|
30
|
+
/** JPEG media types for renamed images, no `svg` properties, and a cover meta pointing at the cover image. */
|
|
31
|
+
export declare function fixOpf(text: string): string;
|
|
32
|
+
export declare function extractIdentifier(opf: string): string | undefined;
|
|
33
|
+
export declare function syncNcxIdentifier(ncx: string, identifier: string | undefined): string;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { DEFAULT_OPTIMIZE_OPTIONS } from "./options.js";
|
|
2
|
+
import { processImage } from "./image.js";
|
|
3
|
+
import { basename, splitEpubText } from "./textsplit.js";
|
|
4
|
+
import JSZip from "jszip";
|
|
5
|
+
import { parseXml } from "@nexlibris/tools/formats/xml";
|
|
6
|
+
//#region crosspoint-tools/src/xteink/optimize.ts
|
|
7
|
+
const RASTER_RE = /\.(png|gif|webp|bmp|jpe?g)$/i;
|
|
8
|
+
/** A whole path or attribute value to rename. */
|
|
9
|
+
const RENAME_RE = /\.(png|gif|webp|bmp|jpeg)$/i;
|
|
10
|
+
/** References inside markup and CSS (href, src, url(…)). */
|
|
11
|
+
const REF_RE = /\.(?:png|gif|webp|bmp|jpeg)(?=["')\s#?>])/gi;
|
|
12
|
+
const XHTML_RE = /\.(xhtml|html|htm)$/i;
|
|
13
|
+
const DEFENSIVE_STYLE = "<style type=\"text/css\">img,svg{max-width:100%;height:auto}body{overflow-wrap:break-word}table{max-width:100%;table-layout:fixed}pre,code{white-space:pre-wrap;word-wrap:break-word}*{box-sizing:border-box}</style>";
|
|
14
|
+
async function optimizeEpub(data, profile, options = DEFAULT_OPTIMIZE_OPTIONS, image = (bytes, path) => processImage(bytes, path, profile, options), log = () => void 0) {
|
|
15
|
+
const zip = await JSZip.loadAsync(await data.arrayBuffer());
|
|
16
|
+
const order = [];
|
|
17
|
+
zip.forEach((path, file) => {
|
|
18
|
+
if (!file.dir && path !== "mimetype") order.push(path);
|
|
19
|
+
});
|
|
20
|
+
const opfPath = await rootfilePath(zip) ?? order.find((p) => p.toLowerCase().endsWith(".opf"));
|
|
21
|
+
if (!opfPath) throw new Error("The EPUB has no package document.");
|
|
22
|
+
const identifier = extractIdentifier(decodeText(await zip.file(opfPath).async("uint8array")));
|
|
23
|
+
const entries = {
|
|
24
|
+
order: [],
|
|
25
|
+
data: /* @__PURE__ */ new Map(),
|
|
26
|
+
opfPath
|
|
27
|
+
};
|
|
28
|
+
const add = (path, value) => {
|
|
29
|
+
if (!entries.data.has(path)) entries.order.push(path);
|
|
30
|
+
entries.data.set(path, value);
|
|
31
|
+
};
|
|
32
|
+
for (const path of order) {
|
|
33
|
+
const raw = await zip.file(path).async("uint8array");
|
|
34
|
+
const lower = path.toLowerCase();
|
|
35
|
+
if (RASTER_RE.test(lower)) {
|
|
36
|
+
const renamed = path.replace(RENAME_RE, ".jpg");
|
|
37
|
+
try {
|
|
38
|
+
add(renamed, await image(raw, path));
|
|
39
|
+
} catch (e) {
|
|
40
|
+
log(`${basename(path)}: kept as it was (${e instanceof Error ? e.message : e})`);
|
|
41
|
+
add(renamed, raw);
|
|
42
|
+
}
|
|
43
|
+
} else if (XHTML_RE.test(lower)) add(path, fixXhtml(decodeText(raw)));
|
|
44
|
+
else if (path === opfPath) add(path, fixOpf(renameRefs(decodeText(raw))));
|
|
45
|
+
else if (lower.endsWith(".ncx")) add(path, syncNcxIdentifier(renameRefs(decodeText(raw)), identifier));
|
|
46
|
+
else if (lower.endsWith(".css")) add(path, renameRefs(decodeText(raw)));
|
|
47
|
+
else add(path, raw);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const split = {
|
|
51
|
+
paragraphs: options.splitParagraphs,
|
|
52
|
+
chapters: options.splitChapters
|
|
53
|
+
};
|
|
54
|
+
await splitEpubText(entries, (bytes, name) => image(bytes, name), log, split);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
log(`text splitting skipped: ${e instanceof Error ? e.message : e}`);
|
|
57
|
+
}
|
|
58
|
+
const out = new JSZip();
|
|
59
|
+
out.file("mimetype", "application/epub+zip", { compression: "STORE" });
|
|
60
|
+
for (const path of entries.order) {
|
|
61
|
+
const stored = /\.jpe?g$/i.test(path);
|
|
62
|
+
out.file(path, entries.data.get(path), {
|
|
63
|
+
compression: stored ? "STORE" : "DEFLATE",
|
|
64
|
+
createFolders: false
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out.generateAsync({
|
|
68
|
+
type: "blob",
|
|
69
|
+
mimeType: "application/epub+zip"
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async function rootfilePath(zip) {
|
|
73
|
+
const container = Object.keys(zip.files).find((p) => p.toLowerCase() === "meta-inf/container.xml");
|
|
74
|
+
const xml = container && await zip.file(container).async("string");
|
|
75
|
+
const path = xml ? /<rootfile[^>]+full-path=["']([^"']+)["']/i.exec(xml)?.[1] : void 0;
|
|
76
|
+
return path && zip.file(path) ? path : void 0;
|
|
77
|
+
}
|
|
78
|
+
/** UTF-8 (without a BOM), or else the encoding the XML declaration or meta charset names. */
|
|
79
|
+
function decodeText(raw) {
|
|
80
|
+
const bytes = raw[0] === 239 && raw[1] === 187 && raw[2] === 191 ? raw.subarray(3) : raw;
|
|
81
|
+
try {
|
|
82
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
83
|
+
} catch {
|
|
84
|
+
const head = new TextDecoder("ascii").decode(bytes.subarray(0, 512));
|
|
85
|
+
const encoding = (/encoding=["']([^"']+)["']/i.exec(head) ?? /charset=["']?([^"'\s;]+)/i.exec(head))?.[1];
|
|
86
|
+
try {
|
|
87
|
+
return new TextDecoder(encoding ?? "windows-1252").decode(bytes);
|
|
88
|
+
} catch {
|
|
89
|
+
return new TextDecoder("iso-8859-1").decode(bytes);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const renameRefs = (text) => text.replace(REF_RE, ".jpg");
|
|
94
|
+
const STYLE_DECLARATION_RE = /(?:^|(?<=;))(\s*)([-_a-zA-Z][-_a-zA-Z0-9]*)\s*:((?:[^;"'()]|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\((?:\\.|[^()\\])*\))*)(\s*)(;?)/g;
|
|
95
|
+
/** Drops width and height from an inline style, which were set for the image before it was resized. */
|
|
96
|
+
function stripSizeDeclarations(style) {
|
|
97
|
+
return style.replace(STYLE_DECLARATION_RE, (m, _lead, property) => ["width", "height"].includes(property.trim().toLowerCase()) ? "" : m);
|
|
98
|
+
}
|
|
99
|
+
/** Removes width and height (attributes and inline styles) from every `<img>`. */
|
|
100
|
+
function stripImgDimensions(text) {
|
|
101
|
+
return text.replace(/<img\b[^>]*>/gi, (tag) => tag.replace(/\s+(?:width|height)\s*=\s*"[^"]*"/gi, "").replace(/\s+(?:width|height)\s*=\s*'[^']*'/gi, "").replace(/\s+style=(["'])([\s\S]*?)\1/gi, (m, quote, style) => {
|
|
102
|
+
const fixed = stripSizeDeclarations(style);
|
|
103
|
+
if (fixed === style) return m;
|
|
104
|
+
return fixed.trim() ? ` style=${quote}${fixed}${quote}` : "";
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
const SVG_IMAGE_RE = /<(?:svg:)?svg\b[^>]*>[\s\S]*?<(?:svg:)?image\b[^>]*?(?:xlink:href|href)=["']([^"']+)["'][\s\S]*?<\/(?:svg:)?svg>/gi;
|
|
108
|
+
/** Replaces SVG-wrapped images (common for covers), which the firmware can't render, with plain `<img>`s. */
|
|
109
|
+
function unwrapSvgImages(text) {
|
|
110
|
+
if (!text.includes("<svg") || !text.includes("href")) return text;
|
|
111
|
+
return text.replace(SVG_IMAGE_RE, (_m, href) => `<img style="max-width:100%;height:auto" src="${href.replace(RENAME_RE, ".jpg")}" alt="" />`);
|
|
112
|
+
}
|
|
113
|
+
function fixXhtml(text) {
|
|
114
|
+
text = stripImgDimensions(renameRefs(unwrapSvgImages(text)));
|
|
115
|
+
if (text.includes("</head>") && !text.includes("<style type=\"text/css\">img,svg{max-width:100%;height:auto}body{overflow-wrap:break-word}table{max-width:100%;table-layout:fixed}pre,code{white-space:pre-wrap;word-wrap:break-word}*{box-sizing:border-box}</style>")) text = text.replace("</head>", `${DEFENSIVE_STYLE}</head>`);
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
/** JPEG media types for renamed images, no `svg` properties, and a cover meta pointing at the cover image. */
|
|
119
|
+
function fixOpf(text) {
|
|
120
|
+
let out = text.replace(/<(?:\w+:)?item\b[^>]*>/g, (tag) => {
|
|
121
|
+
const href = /\bhref="([^"]*)"/.exec(tag)?.[1] ?? "";
|
|
122
|
+
if (/\.jpg$/i.test(href)) tag = tag.replace(/media-type="image\/(?:png|gif|webp|bmp)"/, "media-type=\"image/jpeg\"");
|
|
123
|
+
return tag.replace(/\sproperties="([^"]*)"/, (_m, properties) => {
|
|
124
|
+
const kept = properties.split(/\s+/).filter((p) => p && p !== "svg");
|
|
125
|
+
return kept.length ? ` properties="${kept.join(" ")}"` : "";
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
const coverId = findCoverId(out);
|
|
129
|
+
if (!coverId) return out;
|
|
130
|
+
const coverMeta = /<(?:\w+:)?meta\b[^>]*\bname="cover"[^>]*>/.exec(out);
|
|
131
|
+
if (coverMeta) {
|
|
132
|
+
if (/\bcontent="([^"]*)"/.exec(coverMeta[0])?.[1] !== coverId) out = out.replace(coverMeta[0], coverMeta[0].replace(/\bcontent="[^"]*"/, `content="${coverId}"`));
|
|
133
|
+
} else out = out.replace(/<\/((?:\w+:)?)metadata>/, (close, prefix) => `<${prefix}meta name="cover" content="${coverId}"/>${close}`);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** The manifest id of the cover image: the `cover-image` item, or else an image with "cover" in its id or href. */
|
|
137
|
+
function findCoverId(opf) {
|
|
138
|
+
const doc = parseXml(opf);
|
|
139
|
+
const images = (doc ? Array.from(doc.getElementsByTagName("*")).filter((el) => el.localName === "item") : []).filter((el) => (el.getAttribute("media-type") ?? "").startsWith("image/"));
|
|
140
|
+
return (images.find((el) => (el.getAttribute("properties") ?? "").split(/\s+/).includes("cover-image")) ?? images.find((el) => `${el.getAttribute("id")} ${el.getAttribute("href")}`.toLowerCase().includes("cover")))?.getAttribute("id") ?? void 0;
|
|
141
|
+
}
|
|
142
|
+
function extractIdentifier(opf) {
|
|
143
|
+
const uid = /<(?:\w+:)?package[^>]*unique-identifier=["']([^"']+)["']/i.exec(opf)?.[1];
|
|
144
|
+
if (uid) {
|
|
145
|
+
const escaped = uid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
146
|
+
const value = new RegExp(`<dc:identifier[^>]*id=["']${escaped}["'][^>]*>([^<]+)</dc:identifier>`, "i").exec(opf)?.[1];
|
|
147
|
+
if (value) return value.trim();
|
|
148
|
+
}
|
|
149
|
+
return /<dc:identifier[^>]*>([^<]+)</i.exec(opf)?.[1]?.trim();
|
|
150
|
+
}
|
|
151
|
+
function syncNcxIdentifier(ncx, identifier) {
|
|
152
|
+
if (!identifier) return ncx;
|
|
153
|
+
const escaped = identifier.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
154
|
+
return ncx.replace(/(<meta\s+name=["']dtb:uid["']\s+content=)["'][^"']*["']/i, (_m, pre) => `${pre}"${escaped}"`);
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
export { DEFAULT_OPTIMIZE_OPTIONS, DEFENSIVE_STYLE, decodeText, extractIdentifier, fixOpf, fixXhtml, optimizeEpub, renameRefs, stripImgDimensions, syncNcxIdentifier, unwrapSvgImages };
|