From 52ad1be17896b395dd82a5c2353c8472c4fe7696 Mon Sep 17 00:00:00 2001 From: ConnorTippets Date: Mon, 16 Mar 2026 16:54:02 -0500 Subject: [PATCH 1/4] feat: add piskel to json rename handler --- src/handlers/rename.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/handlers/rename.ts b/src/handlers/rename.ts index f463ff46..fb6e2664 100644 --- a/src/handlers/rename.ts +++ b/src/handlers/rename.ts @@ -10,7 +10,7 @@ function renameHandler(name: string, formats: FileFormat[]): FormatHandler { async init() { this.ready = true }, - async doConvert ( + async doConvert( inputFiles: FileData[], inputFormat: FileFormat, outputFormat: FileFormat @@ -139,5 +139,16 @@ export const renameJsonHandler = renameHandler("renamejson", [ to: false, category: "archive", internal: "har" + }, + { + name: "Piskel Sprite Save File", + format: "piskel", + extension: "piskel", + mime: "image/png+json", + from: true, + to: false, + category: "image", + internal: "piskel", + lossless: true } ]); From a4039c453a0af6e13de529fc022d2a2a3bec6651 Mon Sep 17 00:00:00 2001 From: ConnorTippets Date: Mon, 16 Mar 2026 18:34:04 -0500 Subject: [PATCH 2/4] feat: piskel -> spritesheet conversion --- src/handlers/index.ts | 2 + src/handlers/piskel.ts | 124 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/handlers/piskel.ts diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 38ee48e4..a4669bdc 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -65,6 +65,7 @@ import { tarGzHandler, tarZstdHandler, tarXzHandler } from "./tarCompressed.ts"; import mclangHandler from "./minecraftLangfileHandler.ts"; import cybergrindHandler from "./cybergrindHandler.ts"; import textToSourceHandler from "./textToSource.ts"; +import piskelHandler from "./piskel.ts"; const handlers: FormatHandler[] = []; try { handlers.push(new svgTraceHandler()) } catch (_) { }; @@ -138,5 +139,6 @@ try { handlers.push(tarXzHandler) } catch (_) { }; try { handlers.push(new mclangHandler()) } catch (_) { }; try { handlers.push(new cybergrindHandler()) } catch (_) { }; try { handlers.push(new textToSourceHandler()) } catch (_) { }; +try { handlers.push(new piskelHandler()) } catch (_) { }; export default handlers; diff --git a/src/handlers/piskel.ts b/src/handlers/piskel.ts new file mode 100644 index 00000000..602f991a --- /dev/null +++ b/src/handlers/piskel.ts @@ -0,0 +1,124 @@ +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import CommonFormats from "src/CommonFormats.ts"; + +class piskelHandler implements FormatHandler { + + public name: string = "piskel"; + public supportedFormats?: FileFormat[]; + public ready: boolean = false; + + #canvas?: HTMLCanvasElement; + #ctx?: CanvasRenderingContext2D; + + async init() { + this.supportedFormats = [ + CommonFormats.PNG.builder("png") + .markLossless() + .allowFrom(true) + .allowTo(true), + { + name: "Piskel Sprite Save File", + format: "piskel", + extension: "piskel", + mime: "image/png+json", + from: true, + to: true, + category: "image", + internal: "piskel", + lossless: true + } + ]; + + this.#canvas = document.createElement("canvas"); + const ctx = this.#canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to create 2D rendering context."); + } + this.#ctx = ctx; + + this.ready = true; + } + + async doConvert( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat + ): Promise { + if (!this.ready || !this.#canvas || !this.#ctx) { + throw new Error("Handler not initialized!"); + } + + const outputFiles: FileData[] = []; + + for (const inputFile of inputFiles) { + + if (inputFormat.internal === "piskel") { + const file_raw = new TextDecoder().decode(inputFile.bytes); + const contents = JSON.parse(file_raw); + + const version: number = contents.modelVersion; + if (version !== 2) { + throw Error("Only version 2 piskel files are supported."); + } + + const layers: string[] = contents.piskel.layers; + if (layers.length === 0) { + throw Error("No layers to convert."); + } + + const spriteWidth: number = contents.piskel.width; + const spriteHeight: number = contents.piskel.height; + + // If you're wondering why we're parsing the first layer, + // it's because they decided to duplicate the frame count + // for each layer instead of keeping it global, despite + // the fact that each layer has the same frame count. + const temp = JSON.parse(layers[0]); + this.#canvas.width = spriteWidth * temp.frameCount; + this.#canvas.height = spriteHeight; + + // We're clearing here because each layer needs to + // superimpose itself onto the previous. + this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height); + + for (const layer_raw of layers) { + const layer = JSON.parse(layer_raw); + + const opacity: number = layer.opacity; + + // I'm not entirely sure, but I think only the first chunk is used? + const layer_b64: string = layer.chunks[0].base64PNG; + + const image = new Image(); + await new Promise((resolve, reject) => { + image.addEventListener("load", resolve); + image.addEventListener("error", reject); + image.src = layer_b64; + }); + + this.#ctx.globalAlpha = opacity; + this.#ctx.drawImage(image, 0, 0); + } + + const bytes: Uint8Array = await new Promise((resolve, reject) => { + this.#canvas!.toBlob(blob => { + if (!blob) { + return reject("Canvas output failed"); + } + blob.arrayBuffer().then(buffer => resolve(new Uint8Array(buffer))); + }, "image/png"); + }); + + const name = inputFile.name.split(".").slice(0, -1).join(".") + "." + outputFormat.extension; + outputFiles.push({ bytes, name }); + } else { + throw Error("Other conversions are unsupported for now."); + } + } + + return outputFiles; + } + +} + +export default piskelHandler; \ No newline at end of file From 8fe0a47ba84acbd11b91fa01cc46f84b3b9504b6 Mon Sep 17 00:00:00 2001 From: ConnorTippets Date: Mon, 16 Mar 2026 21:08:27 -0500 Subject: [PATCH 3/4] feat: piskel -> zip with frames --- src/handlers/piskel.ts | 122 ++++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 39 deletions(-) diff --git a/src/handlers/piskel.ts b/src/handlers/piskel.ts index 602f991a..b133250f 100644 --- a/src/handlers/piskel.ts +++ b/src/handlers/piskel.ts @@ -1,5 +1,6 @@ import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; import CommonFormats from "src/CommonFormats.ts"; +import JSZip from "jszip"; class piskelHandler implements FormatHandler { @@ -16,6 +17,10 @@ class piskelHandler implements FormatHandler { .markLossless() .allowFrom(true) .allowTo(true), + CommonFormats.ZIP.builder("zip") + .markLossless() + .allowFrom(true) + .allowTo(true), { name: "Piskel Sprite Save File", format: "piskel", @@ -48,58 +53,64 @@ class piskelHandler implements FormatHandler { throw new Error("Handler not initialized!"); } + if (!(inputFormat.internal === "piskel" && ["png", "zip"].includes(outputFormat.internal))) { + throw Error("Invalid input/output format."); + } + const outputFiles: FileData[] = []; for (const inputFile of inputFiles) { - if (inputFormat.internal === "piskel") { - const file_raw = new TextDecoder().decode(inputFile.bytes); - const contents = JSON.parse(file_raw); + const fileRaw = new TextDecoder().decode(inputFile.bytes); + const contents = JSON.parse(fileRaw); - const version: number = contents.modelVersion; - if (version !== 2) { - throw Error("Only version 2 piskel files are supported."); - } + const version: number = contents.modelVersion; + if (version !== 2) { + throw Error("Only version 2 piskel files are supported."); + } - const layers: string[] = contents.piskel.layers; - if (layers.length === 0) { - throw Error("No layers to convert."); - } + const layers: string[] = contents.piskel.layers; + if (layers.length === 0) { + throw Error("No layers to convert."); + } - const spriteWidth: number = contents.piskel.width; - const spriteHeight: number = contents.piskel.height; + const spriteWidth: number = contents.piskel.width; + const spriteHeight: number = contents.piskel.height; - // If you're wondering why we're parsing the first layer, - // it's because they decided to duplicate the frame count - // for each layer instead of keeping it global, despite - // the fact that each layer has the same frame count. - const temp = JSON.parse(layers[0]); - this.#canvas.width = spriteWidth * temp.frameCount; - this.#canvas.height = spriteHeight; + // We're parsing the first layer, because they decided to + // duplicate the frame count for each layer instead of + // keeping it global, despite the fact that each layer + // has the same frame count. + const temp = JSON.parse(layers[0]); + const frameCount: number = temp.frameCount - // We're clearing here because each layer needs to - // superimpose itself onto the previous. - this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height); + this.#canvas.width = spriteWidth * frameCount; + this.#canvas.height = spriteHeight; - for (const layer_raw of layers) { - const layer = JSON.parse(layer_raw); + // We're clearing here because each layer needs to + // superimpose itself onto the previous. + this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height); - const opacity: number = layer.opacity; + for (const layerRaw of layers) { + const layer = JSON.parse(layerRaw); - // I'm not entirely sure, but I think only the first chunk is used? - const layer_b64: string = layer.chunks[0].base64PNG; + const opacity: number = layer.opacity; - const image = new Image(); - await new Promise((resolve, reject) => { - image.addEventListener("load", resolve); - image.addEventListener("error", reject); - image.src = layer_b64; - }); + // I'm not entirely sure, but I think only the first chunk is used? + const layerB64: string = layer.chunks[0].base64PNG; - this.#ctx.globalAlpha = opacity; - this.#ctx.drawImage(image, 0, 0); - } + const image = new Image(); + await new Promise((resolve, reject) => { + image.addEventListener("load", resolve); + image.addEventListener("error", reject); + image.src = layerB64; + }); + this.#ctx.globalAlpha = opacity; + this.#ctx.drawImage(image, 0, 0); + } + + if (outputFormat.internal === "png") { const bytes: Uint8Array = await new Promise((resolve, reject) => { this.#canvas!.toBlob(blob => { if (!blob) { @@ -111,8 +122,41 @@ class piskelHandler implements FormatHandler { const name = inputFile.name.split(".").slice(0, -1).join(".") + "." + outputFormat.extension; outputFiles.push({ bytes, name }); - } else { - throw Error("Other conversions are unsupported for now."); + } else if (outputFormat.internal === "zip") { + const zip = new JSZip(); + + const fullUri = this.#canvas.toDataURL("image/png"); + + const image = new Image(); + await new Promise((resolve, reject) => { + image.addEventListener("load", resolve); + image.addEventListener("error", reject); + image.src = fullUri; + }); + + this.#canvas.width = spriteWidth; + this.#canvas.height = spriteHeight; + + const baseName = inputFile.name.split(".").slice(0, -1).join("."); + for (let x = 0; x > -spriteWidth * frameCount; x -= spriteWidth) { + this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height); + this.#ctx.drawImage(image, x, 0); + + const bytes: Uint8Array = await new Promise((resolve, reject) => { + this.#canvas!.toBlob(blob => { + if (!blob) { + return reject("Canvas output failed"); + } + blob.arrayBuffer().then(buffer => resolve(new Uint8Array(buffer))); + }, "image/png"); + }); + const name = `${baseName}_Frame${-Number(x / spriteWidth)}.png`; + zip.file(name, bytes); + } + + const bytes = await zip.generateAsync({ type: "uint8array" }); + const name = baseName + "." + outputFormat.extension; + outputFiles.push({ bytes, name }); } } From fd31be9d9b90bca23b50c4611fa805af1c22aa41 Mon Sep 17 00:00:00 2001 From: ConnorTippets Date: Tue, 17 Mar 2026 11:44:58 -0500 Subject: [PATCH 4/4] fix: correctly mark input/output on supported piskel formats --- src/handlers/piskel.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/handlers/piskel.ts b/src/handlers/piskel.ts index b133250f..4f07cfcb 100644 --- a/src/handlers/piskel.ts +++ b/src/handlers/piskel.ts @@ -15,11 +15,11 @@ class piskelHandler implements FormatHandler { this.supportedFormats = [ CommonFormats.PNG.builder("png") .markLossless() - .allowFrom(true) + .allowFrom(false) .allowTo(true), CommonFormats.ZIP.builder("zip") .markLossless() - .allowFrom(true) + .allowFrom(false) .allowTo(true), { name: "Piskel Sprite Save File", @@ -27,7 +27,7 @@ class piskelHandler implements FormatHandler { extension: "piskel", mime: "image/png+json", from: true, - to: true, + to: false, category: "image", internal: "piskel", lossless: true