flet-box 1.0.5 → 1.0.7
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/README.md +28 -1
- package/bin/cli.js +85 -18
- package/bin/commands/auditFix.js +682 -0
- package/bin/commands/checkApi.js +7 -5
- package/bin/commands/create.js +40 -10
- package/bin/commands/createBundle.js +54 -1
- package/bin/commands/images.js +349 -0
- package/bin/commands/pruneAssets.js +170 -0
- package/bin/commands/release.js +34 -1
- package/bin/commands/runCloudflare.js +47 -9
- package/bin/commands/runServer.js +81 -24
- package/bin/commands/toPng.js +346 -0
- package/bin/commands/unpack.js +1173 -0
- package/bin/ota/loader.js +90 -0
- package/bin/utils/appSources.js +101 -0
- package/bin/utils/bundleConfig.js +166 -0
- package/bin/utils/dtsProps.js +147 -0
- package/bin/utils/imageTools.js +271 -0
- package/bin/utils/images/bmp.js +99 -0
- package/bin/utils/images/gif.js +244 -0
- package/bin/utils/images/index.js +163 -0
- package/bin/utils/images/jpeg.js +643 -0
- package/bin/utils/images/png.js +486 -0
- package/bin/utils/images/unsupported.js +29 -0
- package/bin/utils/neutralise.js +109 -6
- package/bin/utils/templates.js +307 -64
- package/docs/cli/README.md +82 -0
- package/docs/guides/online-updates.md +57 -9
- package/docs/widget/Accordion.md +1 -0
- package/docs/widget/AdMob.md +1 -0
- package/docs/widget/AdSense.md +1 -0
- package/docs/widget/AnimatedBox.md +1 -0
- package/docs/widget/AnimatedText.md +1 -0
- package/docs/widget/Audio.md +2 -1
- package/docs/widget/Avatar.md +12 -3
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/Button.md +1 -0
- package/docs/widget/Card.md +1 -0
- package/docs/widget/Carousel.md +1 -0
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +1 -0
- package/docs/widget/Chip.md +1 -0
- package/docs/widget/CircularBar.md +1 -0
- package/docs/widget/CircularChart.md +1 -0
- package/docs/widget/CodeViewer.md +1 -0
- package/docs/widget/Column.md +1 -0
- package/docs/widget/Container.md +1 -0
- package/docs/widget/DataTable.md +1 -0
- package/docs/widget/Divider.md +1 -0
- package/docs/widget/DraggBox.md +3 -2
- package/docs/widget/Dropdown.md +3 -0
- package/docs/widget/DroppBox.md +2 -1
- package/docs/widget/FloatingActionButton.md +1 -0
- package/docs/widget/GridView.md +1 -0
- package/docs/widget/Icon.md +1 -0
- package/docs/widget/Image.md +11 -3
- package/docs/widget/Input.md +1 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/ListTile.md +1 -0
- package/docs/widget/ListView.md +1 -0
- package/docs/widget/Maps.md +188 -0
- package/docs/widget/Markdown.md +1 -0
- package/docs/widget/MatrixRain.md +1 -0
- package/docs/widget/Pagination.md +1 -0
- package/docs/widget/ParallaxBox.md +1 -0
- package/docs/widget/ProgressBar.md +1 -0
- package/docs/widget/QRCode.md +1 -0
- package/docs/widget/README.md +12 -10
- package/docs/widget/Radio.md +1 -0
- package/docs/widget/Rating.md +1 -0
- package/docs/widget/Row.md +1 -0
- package/docs/widget/Skeleton.md +1 -0
- package/docs/widget/Slider.md +1 -0
- package/docs/widget/Stack.md +1 -0
- package/docs/widget/Stepper.md +1 -0
- package/docs/widget/Switch.md +1 -0
- package/docs/widget/Text.md +1 -0
- package/docs/widget/Tooltip.md +11 -13
- package/docs/widget/TreeView.md +1 -0
- package/docs/widget/Video.md +3 -2
- package/package.json +2 -2
- package/src/core/hmr-client.js +129 -34
- package/src/index.d.ts +132 -0
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +15 -4
- package/src/navigations/Drawer.js +9 -1
- package/src/widget-factory/translateProps.js +32 -1
- package/src/widgets/Avatar.js +228 -131
- package/src/widgets/Container.js +13 -0
- package/src/widgets/Dropdown.js +33 -1
- package/src/widgets/Image.js +102 -3
- package/src/widgets/Maps.js +1495 -0
- package/src/widgets/Tooltip.js +12 -2
- package/src/widgets/index.js +1 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bin/utils/imageTools.js
|
|
3
|
+
* @description Finds the lossy image encoder this machine already has, and names
|
|
4
|
+
* the one command that installs it when it does not.
|
|
5
|
+
*
|
|
6
|
+
* `to-png` is the zero-dependency half of image work: it rewrites flat art as the
|
|
7
|
+
* smallest lossless PNG Node core can encode. A photograph is never small in a
|
|
8
|
+
* lossless format, and the only honest way to shrink one is a real encoder —
|
|
9
|
+
* WebP or AVIF. This module does not implement one; it delegates to the platform
|
|
10
|
+
* binary, exactly as `runCloudflare.js` delegates the tunnel to `cloudflared` and
|
|
11
|
+
* `buildAndroid.js` delegates compiling to the Android SDK.
|
|
12
|
+
*
|
|
13
|
+
* A binary is not an npm dependency, so this stays inside the package contract:
|
|
14
|
+
* `dependencies: {}`, and nothing here may import outside `node:*`
|
|
15
|
+
* (scripts/check-cli-install.mjs is the tripwire).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One encoder invocation spelled out, so the command that runs it stays generic.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {object} Encoder
|
|
27
|
+
* @property {string} bin Executable to probe and run.
|
|
28
|
+
* @property {string} format Output container it writes.
|
|
29
|
+
* @property {string} probe Args that only print a version.
|
|
30
|
+
* @property {(o: {width: number, quality: number, method: number, src: string, out: string}) => string[]} args
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** WebP via libwebp's own CLI: the smallest, most predictable surface. */
|
|
34
|
+
const CWEBP = {
|
|
35
|
+
bin: "cwebp",
|
|
36
|
+
format: "webp",
|
|
37
|
+
probe: ["-version"],
|
|
38
|
+
args: ({ width, quality, method, src, out }) => [
|
|
39
|
+
"-quiet",
|
|
40
|
+
"-resize",
|
|
41
|
+
String(width),
|
|
42
|
+
"0",
|
|
43
|
+
"-q",
|
|
44
|
+
String(quality),
|
|
45
|
+
"-m",
|
|
46
|
+
String(method),
|
|
47
|
+
src,
|
|
48
|
+
"-o",
|
|
49
|
+
out,
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** ImageMagick, v7 (`magick`) and v6 (`convert`) take the same arguments here. */
|
|
54
|
+
const imagemagick = (bin) => ({
|
|
55
|
+
bin,
|
|
56
|
+
format: "webp",
|
|
57
|
+
probe: ["-version"],
|
|
58
|
+
args: ({ width, quality, method, src, out }) => [
|
|
59
|
+
src,
|
|
60
|
+
"-resize",
|
|
61
|
+
`${width}x`,
|
|
62
|
+
"-quality",
|
|
63
|
+
String(quality),
|
|
64
|
+
"-define",
|
|
65
|
+
`webp:method=${method}`,
|
|
66
|
+
out,
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Order matters: `cwebp` is the reference encoder for the container, and the two
|
|
72
|
+
* ImageMagick spellings catch the machines where the distro shipped that instead.
|
|
73
|
+
*/
|
|
74
|
+
const ENCODERS = [CWEBP, imagemagick("magick"), imagemagick("convert")];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The package manager this machine is expected to have.
|
|
78
|
+
*
|
|
79
|
+
* macOS and Windows are read off `process.platform`. Linux needs the actual
|
|
80
|
+
* distribution: `apt`, `dnf` and `pacman` are different sentences for the same
|
|
81
|
+
* package, and guessing from the kernel would tell an Arch user to run apt.
|
|
82
|
+
*
|
|
83
|
+
* @returns {{id: string, name: string, install: string[]}}
|
|
84
|
+
*/
|
|
85
|
+
export const platformInstaller = () => {
|
|
86
|
+
if (process.platform === "darwin") {
|
|
87
|
+
return { id: "brew", name: "Homebrew", install: ["brew", "install", "webp"] };
|
|
88
|
+
}
|
|
89
|
+
if (process.platform === "win32") {
|
|
90
|
+
return {
|
|
91
|
+
id: "winget",
|
|
92
|
+
name: "winget",
|
|
93
|
+
install: ["winget", "install", "--id", "Google.WebP", "-e", "--accept-package-agreements", "--accept-source-agreements"],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (process.platform !== "linux") {
|
|
97
|
+
return { id: "unknown", name: process.platform, install: [] };
|
|
98
|
+
}
|
|
99
|
+
// /etc/os-release is the only ID every mainstream distro keeps current.
|
|
100
|
+
let lines = [];
|
|
101
|
+
try {
|
|
102
|
+
lines = fs.readFileSync("/etc/os-release", "utf8").split("\n");
|
|
103
|
+
} catch {
|
|
104
|
+
lines = [];
|
|
105
|
+
}
|
|
106
|
+
const read = (key) => {
|
|
107
|
+
const hit = lines.find((l) => l.startsWith(`${key}=`));
|
|
108
|
+
return hit ? hit.slice(key.length + 1).replace(/^"|"$/g, "") : "";
|
|
109
|
+
};
|
|
110
|
+
const family = [read("ID"), ...read("ID_LIKE").split(/\s+/)].filter(Boolean);
|
|
111
|
+
if (family.some((f) => /debian|ubuntu|linuxmint/.test(f))) {
|
|
112
|
+
return { id: "apt", name: "apt", install: ["sudo", "apt-get", "install", "-y", "webp"] };
|
|
113
|
+
}
|
|
114
|
+
if (family.some((f) => /fedora|rhel|centos|amzn/.test(f))) {
|
|
115
|
+
return { id: "dnf", name: "dnf", install: ["sudo", "dnf", "install", "-y", "libwebp-tools"] };
|
|
116
|
+
}
|
|
117
|
+
if (family.some((f) => /arch|manjaro|endeavouros/.test(f))) {
|
|
118
|
+
return { id: "pacman", name: "pacman", install: ["sudo", "pacman", "-S", "--noconfirm", "libwebp"] };
|
|
119
|
+
}
|
|
120
|
+
return { id: "apk", name: "apk", install: ["sudo", "apk", "add", "--no-cache", "webp"] };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* True when `bin` is runnable — probed with `--version`, never with `which`,
|
|
125
|
+
* so a shim that exists but is broken is not counted as an encoder.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} bin
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
const isRunnable = (bin) => {
|
|
131
|
+
const probe = spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
132
|
+
return !probe.error;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @returns {Encoder|null} The first usable encoder on PATH.
|
|
137
|
+
*/
|
|
138
|
+
export const findEncoder = () => {
|
|
139
|
+
for (const candidate of ENCODERS) {
|
|
140
|
+
if (isRunnable(candidate.bin)) return candidate;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Runs one encoder to a temp file and returns the bytes it produced.
|
|
147
|
+
*
|
|
148
|
+
* @param {Encoder} encoder
|
|
149
|
+
* @param {{src: string, width: number, quality: number, method?: number, out: string}} opts
|
|
150
|
+
* @returns {number|null} Written size, or null when the encoder failed.
|
|
151
|
+
*/
|
|
152
|
+
export const encode = (encoder, { src, width, quality, method = 6, out }) => {
|
|
153
|
+
const res = spawnSync(
|
|
154
|
+
encoder.bin,
|
|
155
|
+
encoder.args({ src, width, quality, method, out }),
|
|
156
|
+
{ stdio: "ignore", encoding: "utf8" },
|
|
157
|
+
);
|
|
158
|
+
if (res.error || res.status !== 0 || !fs.existsSync(out)) return null;
|
|
159
|
+
return fs.statSync(out).size;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Asks before touching the system. Installing a package is a machine-wide change
|
|
164
|
+
* with a hundred ways to be wrong (no brew, no sudo rights, a locked-down laptop),
|
|
165
|
+
* and a build command that silently runs `sudo` is not something a developer
|
|
166
|
+
* expects from a UI framework's CLI.
|
|
167
|
+
*
|
|
168
|
+
* @param {Encoder|null} encoder - What was already found, if anything.
|
|
169
|
+
* @param {{autoInstall: boolean, isTTY: boolean, quiet: boolean}} opts
|
|
170
|
+
* @returns {Promise<Encoder|null>} The encoder to use, or null when it is missing.
|
|
171
|
+
*/
|
|
172
|
+
export const ensureEncoder = async (encoder, { autoInstall = false, isTTY = process.stdout.isTTY, quiet = false } = {}) => {
|
|
173
|
+
if (encoder) return encoder;
|
|
174
|
+
|
|
175
|
+
const installer = platformInstaller();
|
|
176
|
+
const said = (line) => !quiet && console.log(line);
|
|
177
|
+
const found = findEncoder();
|
|
178
|
+
if (found) return found;
|
|
179
|
+
|
|
180
|
+
console.log("");
|
|
181
|
+
console.log(` No lossy WebP encoder is on PATH (tried: ${ENCODERS.map((e) => e.bin).join(", ")}).`);
|
|
182
|
+
if (!installer.install.length) {
|
|
183
|
+
said(` This platform (${process.platform}) has no known package manager here.`);
|
|
184
|
+
console.log(" Install libwebp's `cwebp` by hand, or keep the lossless `flet-box to-png`.");
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const command = installer.install.join(" ");
|
|
188
|
+
console.log(` Install it with ${installer.name}: ${command}`);
|
|
189
|
+
|
|
190
|
+
if (!autoInstall) {
|
|
191
|
+
said(" Re-run with --install and this CLI will do it for you.");
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (!isTTY) {
|
|
195
|
+
console.log(" --install needs a terminal (it may ask for a password). Run the command above and retry.");
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const res = spawnSync(command.split(" ")[0], command.split(" ").slice(1), { stdio: "inherit" });
|
|
199
|
+
if (res.error || res.status !== 0) {
|
|
200
|
+
console.log(` ${command} did not succeed — that machine decides, not this CLI.`);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const after = findEncoder();
|
|
204
|
+
if (after) console.log(` Installed: ${after.bin}`);
|
|
205
|
+
return after;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Where a scratch file can safely sit next to the asset being written.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} dir
|
|
212
|
+
* @param {string} name
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export const tempIn = (dir, name) => path.join(dir, `.${name}.fletbox-tmp.webp`);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* True when any pixel of a decoded frame is see-through.
|
|
219
|
+
*
|
|
220
|
+
* @param {Buffer} rgba
|
|
221
|
+
* @returns {boolean}
|
|
222
|
+
*/
|
|
223
|
+
export const hasTransparency = (rgba) => {
|
|
224
|
+
for (let i = 3; i < rgba.length; i += 4) if (rgba[i] !== 255) return true;
|
|
225
|
+
return false;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* True when the WebP bytes carry an alpha plane.
|
|
230
|
+
*
|
|
231
|
+
* There is no WebP decoder in this package — that is what `to-png` declines at
|
|
232
|
+
* `bin/utils/images/index.js` — so "read every pixel back" is not available here.
|
|
233
|
+
* The container is: a transparent WebP says so in its chunk layout, and a lossy
|
|
234
|
+
* encoder that dropped the plane produces a file that simply lacks it. Three
|
|
235
|
+
* spellings, because the encoder chooses between them:
|
|
236
|
+
*
|
|
237
|
+
* VP8X extended header, bit 4 of its first flag byte is the A flag
|
|
238
|
+
* ALPH the separate alpha chunk a lossy frame with transparency gets
|
|
239
|
+
* VP8L lossless: bit 28 of the packed size/version word says alpha is used
|
|
240
|
+
*
|
|
241
|
+
* A plain lossy `VP8 ` frame has no alpha at all, which is the case this catches:
|
|
242
|
+
* an icon or a sticker flattened onto white would otherwise ship as the picture.
|
|
243
|
+
*
|
|
244
|
+
* @param {Buffer} buf - WebP file bytes.
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
export const webpCarriesAlpha = (buf) => {
|
|
248
|
+
if (buf.length < 12) return false;
|
|
249
|
+
if (buf.toString("ascii", 0, 4) !== "RIFF") return false;
|
|
250
|
+
if (buf.toString("ascii", 8, 12) !== "WEBP") return false;
|
|
251
|
+
let at = 12;
|
|
252
|
+
while (at + 8 <= buf.length) {
|
|
253
|
+
const fourcc = buf.toString("ascii", at, at + 4);
|
|
254
|
+
const size = buf.readUInt32LE(at + 4);
|
|
255
|
+
const body = buf.subarray(at + 8, Math.min(at + 8 + size, buf.length));
|
|
256
|
+
if (fourcc === "ALPH") return true;
|
|
257
|
+
if (fourcc === "VP8X") return body.length > 0 && (body[0] & 0x10) !== 0;
|
|
258
|
+
if (fourcc === "VP8L") {
|
|
259
|
+
// byte 0 is the 0x2f lossless signature, then the packed 32-bit field.
|
|
260
|
+
if (body.length < 5) return false;
|
|
261
|
+
return ((body.readUInt32LE(1) >> 28) & 1) === 1;
|
|
262
|
+
}
|
|
263
|
+
// Chunks are padded to an even byte count.
|
|
264
|
+
at += 8 + size + (size % 2);
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Kept for callers that want to report the machine they probed. */
|
|
270
|
+
export const describeMachine = () => `${os.platform()}/${os.arch()} · ${platformInstaller().name}`;
|
|
271
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bin/utils/images/bmp.js
|
|
3
|
+
* @description Uncompressed Windows BMP reader.
|
|
4
|
+
*
|
|
5
|
+
* BMP is the one raster format whose pixels can be lifted straight out of the
|
|
6
|
+
* file, so this is mostly header arithmetic. Run-length variants (BI_RLE8,
|
|
7
|
+
* BI_RLE4) and OS/2 headers are refused rather than guessed at: they are
|
|
8
|
+
* decades old, rare in an assets folder, and a half-read RLE stream produces
|
|
9
|
+
* garbage that still looks like a picture.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import UnsupportedImage from "./unsupported.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Decodes a BMP into RGBA.
|
|
16
|
+
*
|
|
17
|
+
* @param {Buffer} buf - File bytes.
|
|
18
|
+
* @returns {{ width: number, height: number, rgba: Buffer }}
|
|
19
|
+
* @throws {Error} Naming the unsupported combination of header fields.
|
|
20
|
+
*/
|
|
21
|
+
export const decodeBmp = (buf) => {
|
|
22
|
+
if (buf.length < 54 || buf.toString("ascii", 0, 2) !== "BM") {
|
|
23
|
+
throw new Error("not a BMP: missing the BM signature");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const dataOffset = buf.readUInt32LE(10);
|
|
27
|
+
const headerSize = buf.readUInt32LE(14);
|
|
28
|
+
if (headerSize < 40) throw new UnsupportedImage("BMP", "OS/2 BITMAPCOREHEADER is not implemented");
|
|
29
|
+
|
|
30
|
+
const width = buf.readInt32LE(18);
|
|
31
|
+
const rawHeight = buf.readInt32LE(22);
|
|
32
|
+
const bitCount = buf.readUInt16LE(28);
|
|
33
|
+
const compression = buf.readUInt32LE(30);
|
|
34
|
+
|
|
35
|
+
if (compression !== 0) {
|
|
36
|
+
throw new UnsupportedImage("BMP", `compression mode ${compression} (only uncompressed BI_RGB is implemented)`);
|
|
37
|
+
}
|
|
38
|
+
if (![1, 4, 8, 24, 32].includes(bitCount)) {
|
|
39
|
+
throw new UnsupportedImage("BMP", `${bitCount}-bit depth is not implemented (1, 4, 8, 24 and 32 are)`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const topDown = rawHeight < 0;
|
|
43
|
+
const height = Math.abs(rawHeight);
|
|
44
|
+
const palette = [];
|
|
45
|
+
if (bitCount <= 8) {
|
|
46
|
+
// `biClrUsed` lives at 46 — reading it earlier returns the low bytes of the
|
|
47
|
+
// image size, and a file whose "palette" claims 67 million entries is only
|
|
48
|
+
// one bad offset away from exhausting the heap.
|
|
49
|
+
const paletteStart = 14 + headerSize;
|
|
50
|
+
const declared = buf.readUInt32LE(46);
|
|
51
|
+
const needed = 1 << bitCount;
|
|
52
|
+
const entries = declared > 0 ? Math.min(declared, needed) : needed;
|
|
53
|
+
const room = Math.floor((dataOffset - paletteStart) / 4);
|
|
54
|
+
if (room < entries) {
|
|
55
|
+
throw new Error(`BMP palette claims ${entries} entries but only ${Math.max(0, room)} fit before the pixels`);
|
|
56
|
+
}
|
|
57
|
+
for (let i = 0; i < entries; i++) {
|
|
58
|
+
const base = paletteStart + i * 4;
|
|
59
|
+
palette.push([buf[base + 2], buf[base + 1], buf[base], 255]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rgba = Buffer.alloc(width * height * 4);
|
|
64
|
+
const rowBytes = Math.ceil((width * bitCount) / 8);
|
|
65
|
+
const stride = Math.ceil(rowBytes / 4) * 4;
|
|
66
|
+
|
|
67
|
+
for (let y = 0; y < height; y++) {
|
|
68
|
+
const sourceRow = dataOffset + (topDown ? y : height - 1 - y) * stride;
|
|
69
|
+
for (let x = 0; x < width; x++) {
|
|
70
|
+
const dst = (y * width + x) * 4;
|
|
71
|
+
if (bitCount === 32) {
|
|
72
|
+
const base = sourceRow + x * 4;
|
|
73
|
+
rgba[dst] = buf[base + 2];
|
|
74
|
+
rgba[dst + 1] = buf[base + 1];
|
|
75
|
+
rgba[dst + 2] = buf[base];
|
|
76
|
+
rgba[dst + 3] = buf[base + 3];
|
|
77
|
+
} else if (bitCount === 24) {
|
|
78
|
+
const base = sourceRow + x * 3;
|
|
79
|
+
rgba[dst] = buf[base + 2];
|
|
80
|
+
rgba[dst + 1] = buf[base + 1];
|
|
81
|
+
rgba[dst + 2] = buf[base];
|
|
82
|
+
rgba[dst + 3] = 255;
|
|
83
|
+
} else {
|
|
84
|
+
const shift = bitCount === 1 ? 7 - (x % 8) : bitCount === 4 ? (x % 2 ? 0 : 4) : 0;
|
|
85
|
+
const mask = (1 << bitCount) - 1;
|
|
86
|
+
const index = (buf[sourceRow + Math.floor((x * bitCount) / 8)] >> shift) & mask;
|
|
87
|
+
const colour = palette[index] || [0, 0, 0, 255];
|
|
88
|
+
rgba[dst] = colour[0];
|
|
89
|
+
rgba[dst + 1] = colour[1];
|
|
90
|
+
rgba[dst + 2] = colour[2];
|
|
91
|
+
rgba[dst + 3] = colour[3];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { width, height, rgba };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default decodeBmp;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bin/utils/images/gif.js
|
|
3
|
+
* @description GIF reader: LZW, global or local palettes, transparency, interlace.
|
|
4
|
+
*
|
|
5
|
+
* A GIF is a canvas with one or more frames on it, and a PNG is one still
|
|
6
|
+
* image, so the conversion has to pick a frame. This takes the first one and
|
|
7
|
+
* places it at its own offset inside the logical screen, which is where a
|
|
8
|
+
* browser would show it. An animated file is converted with a note saying only
|
|
9
|
+
* the first frame survived — silently dropping the rest of a cartoon would be
|
|
10
|
+
* the worse half of that trade.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Decodes a GIF into RGBA.
|
|
15
|
+
*
|
|
16
|
+
* @param {Buffer} buf - File bytes.
|
|
17
|
+
* @returns {{ width: number, height: number, rgba: Buffer, note?: string }}
|
|
18
|
+
* @throws {Error} Naming the corruption or the feature this reader lacks.
|
|
19
|
+
*/
|
|
20
|
+
export const decodeGif = (buf) => {
|
|
21
|
+
const magic = buf.toString("ascii", 0, 6);
|
|
22
|
+
if (magic !== "GIF87a" && magic !== "GIF89a") throw new Error("not a GIF: bad signature");
|
|
23
|
+
|
|
24
|
+
const canvasWidth = buf.readUInt16LE(6);
|
|
25
|
+
const canvasHeight = buf.readUInt16LE(8);
|
|
26
|
+
const packed = buf[10];
|
|
27
|
+
let offset = 13;
|
|
28
|
+
|
|
29
|
+
let globalTable = null;
|
|
30
|
+
if (packed & 0x80) {
|
|
31
|
+
globalTable = readColorTable(buf, offset, 2 << (packed & 7));
|
|
32
|
+
offset += 3 * (2 << (packed & 7));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let transparentIndex = -1;
|
|
36
|
+
let frames = 0;
|
|
37
|
+
let width = canvasWidth;
|
|
38
|
+
let height = canvasHeight;
|
|
39
|
+
let rgba = null;
|
|
40
|
+
|
|
41
|
+
while (offset < buf.length) {
|
|
42
|
+
const introducer = buf[offset];
|
|
43
|
+
offset++;
|
|
44
|
+
|
|
45
|
+
if (introducer === 0x3b) break; // trailer
|
|
46
|
+
|
|
47
|
+
if (introducer === 0x21) {
|
|
48
|
+
const label = buf[offset];
|
|
49
|
+
const blocks = readBlocks(buf, offset + 1);
|
|
50
|
+
if (label === 0xf9 && blocks.data.length >= 4 && blocks.data[0] & 0x01) {
|
|
51
|
+
transparentIndex = blocks.data[3];
|
|
52
|
+
}
|
|
53
|
+
offset = blocks.next;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (introducer !== 0x2c) throw new Error(`unexpected GIF block 0x${introducer.toString(16)}`);
|
|
58
|
+
|
|
59
|
+
const x = buf.readUInt16LE(offset);
|
|
60
|
+
const y = buf.readUInt16LE(offset + 2);
|
|
61
|
+
const frameWidth = buf.readUInt16LE(offset + 4);
|
|
62
|
+
const frameHeight = buf.readUInt16LE(offset + 6);
|
|
63
|
+
const framePacked = buf.readUInt16LE(offset + 8) & 0xff;
|
|
64
|
+
offset += 9;
|
|
65
|
+
|
|
66
|
+
let table = globalTable;
|
|
67
|
+
if (framePacked & 0x80) {
|
|
68
|
+
const entries = 2 << (framePacked & 7);
|
|
69
|
+
table = readColorTable(buf, offset, entries);
|
|
70
|
+
offset += 3 * entries;
|
|
71
|
+
}
|
|
72
|
+
if (!table) throw new Error("GIF frame has no colour table");
|
|
73
|
+
|
|
74
|
+
frames++;
|
|
75
|
+
if (frames === 1) {
|
|
76
|
+
width = canvasWidth;
|
|
77
|
+
height = canvasHeight;
|
|
78
|
+
rgba = Buffer.alloc(canvasWidth * canvasHeight * 4);
|
|
79
|
+
const interlaced = (framePacked & 0x40) !== 0;
|
|
80
|
+
const pixels = decodeLzw(buf, offset, frameWidth * frameHeight);
|
|
81
|
+
const rows = interlaced ? deinterlace(frameHeight) : Array.from({ length: frameHeight }, (_, i) => i);
|
|
82
|
+
|
|
83
|
+
for (let row = 0; row < frameHeight; row++) {
|
|
84
|
+
const targetY = y + rows[row];
|
|
85
|
+
if (targetY < 0 || targetY >= canvasHeight) continue;
|
|
86
|
+
for (let col = 0; col < frameWidth; col++) {
|
|
87
|
+
const targetX = x + col;
|
|
88
|
+
if (targetX < 0 || targetX >= canvasWidth) continue;
|
|
89
|
+
const index = pixels[row * frameWidth + col];
|
|
90
|
+
const colour = table[index] || [0, 0, 0, 255];
|
|
91
|
+
const dst = (targetY * canvasWidth + targetX) * 4;
|
|
92
|
+
rgba[dst] = colour[0];
|
|
93
|
+
rgba[dst + 1] = colour[1];
|
|
94
|
+
rgba[dst + 2] = colour[2];
|
|
95
|
+
rgba[dst + 3] = index === transparentIndex ? 0 : colour[3];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
offset = skipImage(buf, offset);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!rgba) throw new Error("GIF has no image block");
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
width,
|
|
106
|
+
height,
|
|
107
|
+
rgba,
|
|
108
|
+
note: frames > 1 ? `${frames} frames: only the first was kept` : undefined,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {Buffer} buf
|
|
114
|
+
* @param {number} start - Offset of the first table entry.
|
|
115
|
+
* @param {number} entries
|
|
116
|
+
* @returns {number[][]} `[r, g, b, a]` per entry; GIF stores no alpha.
|
|
117
|
+
*/
|
|
118
|
+
const readColorTable = (buf, start, entries) => {
|
|
119
|
+
const table = [];
|
|
120
|
+
for (let i = 0; i < entries; i++) {
|
|
121
|
+
const base = start + i * 3;
|
|
122
|
+
table.push([buf[base], buf[base + 1], buf[base + 2], 255]);
|
|
123
|
+
}
|
|
124
|
+
return table;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Walks a GIF sub-block chain.
|
|
129
|
+
*
|
|
130
|
+
* @param {Buffer} buf
|
|
131
|
+
* @param {number} start - Offset of the first size byte.
|
|
132
|
+
* @returns {{ data: Buffer, next: number }}
|
|
133
|
+
*/
|
|
134
|
+
const readBlocks = (buf, start) => {
|
|
135
|
+
const parts = [];
|
|
136
|
+
let offset = start;
|
|
137
|
+
while (offset < buf.length) {
|
|
138
|
+
const size = buf[offset];
|
|
139
|
+
offset++;
|
|
140
|
+
if (size === 0) break;
|
|
141
|
+
parts.push(buf.subarray(offset, offset + size));
|
|
142
|
+
offset += size;
|
|
143
|
+
}
|
|
144
|
+
return { data: Buffer.concat(parts), next: offset };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reads an image's LZW sub-blocks and decodes them into palette indices.
|
|
149
|
+
*
|
|
150
|
+
* @param {Buffer} buf
|
|
151
|
+
* @param {number} offset - Where the LZW minimum code size byte sits.
|
|
152
|
+
* @param {number} count - Pixels in this frame.
|
|
153
|
+
* @returns {Uint8Array}
|
|
154
|
+
*/
|
|
155
|
+
const decodeLzw = (buf, offset, count) => {
|
|
156
|
+
const { data } = readBlocks(buf, offset + 1);
|
|
157
|
+
const minCodeSize = buf[offset];
|
|
158
|
+
const clearCode = 1 << minCodeSize;
|
|
159
|
+
const endCode = clearCode + 1;
|
|
160
|
+
|
|
161
|
+
const pixels = new Uint8Array(count);
|
|
162
|
+
let dict = [];
|
|
163
|
+
let codeSize = minCodeSize + 1;
|
|
164
|
+
let next = endCode + 1;
|
|
165
|
+
let bitPos = 0;
|
|
166
|
+
let written = 0;
|
|
167
|
+
let previous = null;
|
|
168
|
+
|
|
169
|
+
const resetDict = () => {
|
|
170
|
+
dict = [];
|
|
171
|
+
for (let i = 0; i < clearCode; i++) dict[i] = [i];
|
|
172
|
+
dict[clearCode] = [];
|
|
173
|
+
dict[endCode] = [];
|
|
174
|
+
codeSize = minCodeSize + 1;
|
|
175
|
+
next = endCode + 1;
|
|
176
|
+
previous = null;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const readCode = () => {
|
|
180
|
+
let code = 0;
|
|
181
|
+
for (let i = 0; i < codeSize; i++) {
|
|
182
|
+
const byteIndex = (bitPos + i) >> 3;
|
|
183
|
+
if (byteIndex >= data.length) return -1;
|
|
184
|
+
code |= ((data[byteIndex] >> ((bitPos + i) & 7)) & 1) << i;
|
|
185
|
+
}
|
|
186
|
+
bitPos += codeSize;
|
|
187
|
+
return code;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
resetDict();
|
|
191
|
+
while (written < count) {
|
|
192
|
+
const code = readCode();
|
|
193
|
+
if (code === -1 || code === endCode) break;
|
|
194
|
+
if (code === clearCode) {
|
|
195
|
+
resetDict();
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let entry = dict[code];
|
|
200
|
+
if (!entry) {
|
|
201
|
+
// The classic KwKwK case: the code being asked for is the one this
|
|
202
|
+
// iteration is about to define, so the new entry is `previous` plus its
|
|
203
|
+
// own first index.
|
|
204
|
+
if (!previous) throw new Error("GIF LZW stream references an undefined code");
|
|
205
|
+
entry = [...previous, previous[0]];
|
|
206
|
+
dict[next++] = entry;
|
|
207
|
+
} else if (previous) {
|
|
208
|
+
dict[next++] = [...previous, entry[0]];
|
|
209
|
+
}
|
|
210
|
+
previous = entry;
|
|
211
|
+
|
|
212
|
+
for (const value of entry) {
|
|
213
|
+
if (written < count) pixels[written++] = value;
|
|
214
|
+
}
|
|
215
|
+
if (next === (1 << codeSize) && codeSize < 12) codeSize++;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return pixels;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Row order of an interlaced GIF: 4/8, then 2/8, then 1/4, then 1/2 passes.
|
|
223
|
+
*
|
|
224
|
+
* @param {number} height
|
|
225
|
+
* @returns {number[]} Destination row for each stored row, in order.
|
|
226
|
+
*/
|
|
227
|
+
const deinterlace = (height) => {
|
|
228
|
+
const rows = [];
|
|
229
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
230
|
+
const start = [0, 4, 2, 1][pass];
|
|
231
|
+
const step = [8, 8, 4, 2][pass];
|
|
232
|
+
for (let y = start; y < height; y += step) rows.push(y);
|
|
233
|
+
}
|
|
234
|
+
return rows;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {Buffer} buf
|
|
239
|
+
* @param {number} offset - Where the LZW minimum code size byte sits.
|
|
240
|
+
* @returns {number} Offset after this image's sub-blocks.
|
|
241
|
+
*/
|
|
242
|
+
const skipImage = (buf, offset) => readBlocks(buf, offset + 1).next;
|
|
243
|
+
|
|
244
|
+
export default decodeGif;
|