Skip to content

Commit 2e028cd

Browse files
committed
implement qoi image format handler
Fixes #1
1 parent 199bb07 commit 2e028cd

File tree

5 files changed

+208
-0
lines changed

5 files changed

+208
-0
lines changed

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "src/handlers/envelope"]
22
path = src/handlers/envelope
33
url = https://github.com/p2r3/envelope
4+
[submodule "src/handlers/qoi-fu"]
5+
path = src/handlers/qoi-fu
6+
url = https://github.com/pfusik/qoi-fu

src/handlers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ImageMagickHandler from "./ImageMagick.ts";
77
import renameHandler from "./rename.ts";
88
import envelopeHandler from "./envelope.ts";
99
import svgForeignObjectHandler from "./svgForeignObject.ts";
10+
import qoiFuHandler from "./qoi-fu.ts";
1011

1112
const handlers: FormatHandler[] = [
1213
new canvasToBlobHandler(),
@@ -16,5 +17,6 @@ const handlers: FormatHandler[] = [
1617
new renameHandler(),
1718
new envelopeHandler(),
1819
new svgForeignObjectHandler(),
20+
new qoiFuHandler(),
1921
];
2022
export default handlers;

src/handlers/qoi-fu

Submodule qoi-fu added at d4e5af8

src/handlers/qoi-fu.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts";
2+
3+
import { QOIDecoder, QOIEncoder } from "./qoi-fu/transpiled/QOI.ts";
4+
5+
class qoiFuHandler implements FormatHandler {
6+
7+
public name: string = "qoi-fu";
8+
public supportedFormats: FileFormat[] = [
9+
{
10+
name: "Portable Network Graphics",
11+
format: "png",
12+
extension: "png",
13+
mime: "image/png",
14+
from: true,
15+
to: true,
16+
internal: "png"
17+
},
18+
{
19+
name: "Joint Photographic Experts Group JFIF",
20+
format: "jpeg",
21+
extension: "jpg",
22+
mime: "image/jpeg",
23+
from: true,
24+
to: true,
25+
internal: "jpeg"
26+
},
27+
{
28+
name: "WebP",
29+
format: "webp",
30+
extension: "webp",
31+
mime: "image/webp",
32+
from: true,
33+
to: true,
34+
internal: "webp"
35+
},
36+
{
37+
name: "CompuServe Graphics Interchange Format (GIF)",
38+
format: "gif",
39+
extension: "gif",
40+
mime: "image/gif",
41+
from: true,
42+
to: false,
43+
internal: "gif"
44+
},
45+
{
46+
name: "Scalable Vector Graphics",
47+
format: "svg",
48+
extension: "svg",
49+
mime: "image/svg+xml",
50+
from: true,
51+
to: false,
52+
internal: "svg"
53+
},
54+
{
55+
name: "Quite OK Image",
56+
format: "qoi",
57+
extension: "qoi",
58+
mime: "image/x-qoi",
59+
from: true,
60+
to: true,
61+
internal: "qoi"
62+
}
63+
];
64+
public ready: boolean = false;
65+
66+
#canvas?: HTMLCanvasElement;
67+
#ctx?: CanvasRenderingContext2D;
68+
69+
async init () {
70+
this.#canvas = document.createElement("canvas");
71+
const ctx = this.#canvas.getContext("2d");
72+
if (!ctx) throw "Failed to create 2D rendering context.";
73+
this.#ctx = ctx;
74+
this.ready = true;
75+
}
76+
77+
static rgbaToArgb (rgba: Uint8ClampedArray): Int32Array {
78+
const length = rgba.length / 4;
79+
const argb = new Int32Array(length);
80+
81+
for (let i = 0; i < length; i++) {
82+
const offset = i * 4;
83+
const r = rgba[offset];
84+
const g = rgba[offset + 1];
85+
const b = rgba[offset + 2];
86+
const a = rgba[offset + 3];
87+
88+
argb[i] = (a << 24) | (r << 16) | (g << 8) | b;
89+
}
90+
91+
return argb;
92+
}
93+
static argbToRgba (argb: Int32Array): Uint8ClampedArray {
94+
const rgba = new Uint8ClampedArray(argb.length * 4);
95+
96+
for (let i = 0; i < argb.length; i++) {
97+
const pixel = argb[i];
98+
const offset = i * 4;
99+
100+
rgba[offset] = (pixel >> 16) & 0xFF; // R
101+
rgba[offset + 1] = (pixel >> 8) & 0xFF; // G
102+
rgba[offset + 2] = pixel & 0xFF; // B
103+
rgba[offset + 3] = (pixel >> 24) & 0xFF; // A
104+
}
105+
106+
return rgba;
107+
}
108+
109+
async doConvert (
110+
inputFiles: FileData[],
111+
inputFormat: FileFormat,
112+
outputFormat: FileFormat
113+
): Promise<FileData[]> {
114+
115+
if (!this.#canvas || !this.#ctx) {
116+
throw "Handler not initialized.";
117+
}
118+
119+
const outputFiles: FileData[] = [];
120+
121+
const inputIsQOI = (inputFormat.internal === "qoi");
122+
const outputIsQOI = (outputFormat.internal === "qoi");
123+
124+
if (inputIsQOI === outputIsQOI) {
125+
throw "Invalid input/output format.";
126+
}
127+
128+
if (outputIsQOI) {
129+
for (const inputFile of inputFiles) {
130+
131+
this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.width);
132+
133+
const blob = new Blob([inputFile.bytes as BlobPart], { type: inputFormat.mime });
134+
const url = URL.createObjectURL(blob);
135+
136+
const image = new Image();
137+
await new Promise((resolve, reject) => {
138+
image.addEventListener("load", resolve);
139+
image.addEventListener("error", reject);
140+
image.src = url;
141+
});
142+
143+
const width = image.naturalWidth;
144+
const height = image.naturalHeight;
145+
146+
this.#canvas.width = width;
147+
this.#canvas.height = height;
148+
this.#ctx.drawImage(image, 0, 0);
149+
150+
const imageData = this.#ctx.getImageData(0, 0, width, height);
151+
const pixelBuffer = qoiFuHandler.rgbaToArgb(imageData.data);
152+
153+
const qoiEncoder = new QOIEncoder();
154+
const success = qoiEncoder.encode(width, height, pixelBuffer, true, false);
155+
if (!success) throw `Failed to encode QOI image "${inputFile.name}".`;
156+
157+
const bytesSize = qoiEncoder.getEncodedSize();
158+
const bytes = new Uint8Array(qoiEncoder.getEncoded().slice(0, bytesSize));
159+
160+
const name = inputFile.name.split(".")[0] + "." + outputFormat.extension;
161+
outputFiles.push({ bytes, name });
162+
163+
}
164+
} else {
165+
for (const inputFile of inputFiles) {
166+
167+
const qoiDecoder = new QOIDecoder();
168+
const success = qoiDecoder.decode(inputFile.bytes, inputFile.bytes.length);
169+
if (!success) throw `Failed to decode QOI image "${inputFile.name}".`;
170+
171+
const width = qoiDecoder.getWidth();
172+
const height = qoiDecoder.getHeight();
173+
const colorSpace = qoiDecoder.isLinearColorspace() ? "display-p3" : "srgb";
174+
const pixelBuffer = qoiFuHandler.argbToRgba(qoiDecoder.getPixels());
175+
176+
const imageData = new ImageData(pixelBuffer as ImageDataArray, width, height, {
177+
colorSpace: colorSpace
178+
});
179+
180+
this.#canvas.width = width;
181+
this.#canvas.height = height;
182+
this.#ctx.putImageData(imageData, 0, 0);
183+
184+
const bytes: Uint8Array = await new Promise((resolve, reject) => {
185+
this.#canvas!.toBlob((blob) => {
186+
if (!blob) return reject("Canvas output failed.");
187+
blob.arrayBuffer().then(buf => resolve(new Uint8Array(buf)));
188+
}, outputFormat.mime);
189+
});
190+
const name = inputFile.name.split(".")[0] + "." + outputFormat.extension;
191+
outputFiles.push({ bytes, name });
192+
193+
}
194+
}
195+
196+
return outputFiles;
197+
}
198+
199+
}
200+
201+
export default qoiFuHandler;

src/normalizeMimeType.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ function normalizeMimeType (mime: string) {
33
case "audio/x-wav": return "audio/wav";
44
case "audio/vnd.wave": return "audio/wav";
55
case "image/x-icon": return "image/vnd.microsoft.icon";
6+
case "image/qoi": return "image/x-qoi";
67
}
78
return mime;
89
}

0 commit comments

Comments
 (0)