first commit
This commit is contained in:
commit
63c0901393
10 changed files with 966 additions and 0 deletions
215
lib/image2json.js
Normal file
215
lib/image2json.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Sol Toder
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Copy available at https://github.com/AjaxGb/mc-text-image/blob/master/LICENSE
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
|
||||
const BLOCK_CHAR = "\u2587";
|
||||
const SPACE_CHAR = "\u2007";
|
||||
const TRAILING_SPACE = new RegExp(SPACE_CHAR + "+$");
|
||||
const FONT_RATIO = 1.8;
|
||||
|
||||
function parseSize(text) {
|
||||
const parsed = parseInt(text);
|
||||
return parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function calcSize(origW, origH, width, height, keepRatio, pixelShape) {
|
||||
let w = width || origW;
|
||||
let h = height || origH;
|
||||
|
||||
if (keepRatio) {
|
||||
const originRatio = origW / origH;
|
||||
const currRatio = w / h;
|
||||
|
||||
if (currRatio > originRatio || (origH && !origW)) {
|
||||
w = (h * origW) / origH;
|
||||
} else {
|
||||
h = (w * origH) / origW;
|
||||
}
|
||||
}
|
||||
|
||||
const pixelRatio = pixelShape === "font" ? FONT_RATIO : 1.0;
|
||||
|
||||
return {
|
||||
w: Math.round(w),
|
||||
h: Math.round(h / pixelRatio),
|
||||
unscaledH: Math.round(h),
|
||||
origW,
|
||||
origH,
|
||||
};
|
||||
}
|
||||
|
||||
function hexNibble(value) {
|
||||
return value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
function makeHexColor(pixels, offset, cutoff) {
|
||||
const a = pixels[offset + 3];
|
||||
if (a < cutoff) {
|
||||
pixels[offset + 3] = 0;
|
||||
return null;
|
||||
} else {
|
||||
pixels[offset + 3] = 255;
|
||||
const r = pixels[offset + 0];
|
||||
const g = pixels[offset + 1];
|
||||
const b = pixels[offset + 2];
|
||||
return "#" + hexNibble(r) + hexNibble(g) + hexNibble(b);
|
||||
}
|
||||
}
|
||||
export async function imageToJson(imageBuffer, options = {}) {
|
||||
const {
|
||||
width = null,
|
||||
height = null,
|
||||
keepRatio = true,
|
||||
pixelShape = "square",
|
||||
smoothing = null,
|
||||
transparencyCutoff = 0,
|
||||
stripSpace = false,
|
||||
} = options;
|
||||
|
||||
if (!imageBuffer || !(imageBuffer instanceof Uint8Array)) {
|
||||
console.error("imageToJson: Invalid imageBuffer provided.");
|
||||
throw new Error(`Input must be a Buffer containing image data`);
|
||||
}
|
||||
|
||||
let image;
|
||||
try {
|
||||
image = await loadImage(imageBuffer);
|
||||
} catch (err) {
|
||||
console.error("imageToJson: Failed to load image from buffer.", err);
|
||||
throw err;
|
||||
}
|
||||
if (!image || !image.width || !image.height) {
|
||||
console.error(
|
||||
"imageToJson: Loaded image is invalid or missing dimensions.",
|
||||
);
|
||||
throw new Error("Loaded image is invalid or missing dimensions.");
|
||||
}
|
||||
const origW = image.width;
|
||||
const origH = image.height;
|
||||
|
||||
let sizeObj;
|
||||
try {
|
||||
sizeObj = calcSize(origW, origH, width, height, keepRatio, pixelShape);
|
||||
} catch (err) {
|
||||
console.error("imageToJson: Failed to calculate target size.", err);
|
||||
throw err;
|
||||
}
|
||||
const { w, h } = sizeObj;
|
||||
|
||||
let canvas, ctx;
|
||||
try {
|
||||
canvas = createCanvas(w, h);
|
||||
ctx = canvas.getContext("2d");
|
||||
} catch (err) {
|
||||
console.error("imageToJson: Failed to create canvas or context.", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
if (smoothing) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = smoothing;
|
||||
} else {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("imageToJson: Failed to set image smoothing.", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(image, 0, 0, w, h);
|
||||
} catch (err) {
|
||||
console.error("imageToJson: Failed to draw image on canvas.", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
let cutoff, imageData, pixels;
|
||||
try {
|
||||
cutoff = parseInt(transparencyCutoff) || 0;
|
||||
imageData = ctx.getImageData(0, 0, w, h);
|
||||
pixels = imageData.data;
|
||||
} catch (err) {
|
||||
console.error("imageToJson: Failed to get image data from canvas.", err);
|
||||
throw err;
|
||||
}
|
||||
const json = [];
|
||||
let currColor = null;
|
||||
let currText = "";
|
||||
|
||||
const totalPixels = pixels.length / 4;
|
||||
|
||||
for (let x = 0, i = 0; i < pixels.length; x++, i += 4) {
|
||||
if (x >= w) {
|
||||
if (stripSpace) {
|
||||
currText = currText.replace(TRAILING_SPACE, "");
|
||||
}
|
||||
currText += "\n";
|
||||
x = 0;
|
||||
}
|
||||
|
||||
let newColor;
|
||||
try {
|
||||
newColor = makeHexColor(pixels, i, cutoff);
|
||||
} catch (err) {
|
||||
console.error(`imageToJson: Error processing pixel at index ${i}.`, err);
|
||||
throw err;
|
||||
}
|
||||
if (currColor && newColor && currColor != newColor) {
|
||||
json.push({ text: currText, color: currColor });
|
||||
currText = "";
|
||||
}
|
||||
|
||||
if (newColor) {
|
||||
currColor = newColor;
|
||||
currText += BLOCK_CHAR;
|
||||
} else {
|
||||
currText += SPACE_CHAR;
|
||||
}
|
||||
}
|
||||
|
||||
if (stripSpace) {
|
||||
currText = currText.replace(TRAILING_SPACE, "");
|
||||
}
|
||||
|
||||
if (currText && currColor) {
|
||||
json.push({ text: currText, color: currColor });
|
||||
}
|
||||
|
||||
return {
|
||||
json: json,
|
||||
dimensions: {
|
||||
width: w,
|
||||
height: h,
|
||||
originalWidth: origW,
|
||||
originalHeight: origH,
|
||||
},
|
||||
};
|
||||
}
|
||||
174
lib/pack.js
Normal file
174
lib/pack.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
function hexToRgb(hex) {
|
||||
hex = hex.replace(/^#/, "");
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split("")
|
||||
.map(function (x) {
|
||||
return x + x;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
var num = parseInt(hex, 16);
|
||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||
}
|
||||
|
||||
function colorDistance(rgb1, rgb2) {
|
||||
return Math.sqrt(
|
||||
Math.pow(rgb1[0] - rgb2[0], 2) +
|
||||
Math.pow(rgb1[1] - rgb2[1], 2) +
|
||||
Math.pow(rgb1[2] - rgb2[2], 2),
|
||||
);
|
||||
}
|
||||
|
||||
function closestColor(targetHex, colorList) {
|
||||
var targetRgb = hexToRgb(targetHex);
|
||||
var minDist = Infinity;
|
||||
var closest = null;
|
||||
for (var i = 0; i < colorList.length; i++) {
|
||||
var rgb = hexToRgb(colorList[i]);
|
||||
var dist = colorDistance(targetRgb, rgb);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closest = colorList[i];
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}
|
||||
|
||||
function middleColorsAndApproximations(colorList, count) {
|
||||
if (!Array.isArray(colorList) || colorList.length === 0 || count < 1) {
|
||||
return {
|
||||
middleColors: [],
|
||||
approximations: {},
|
||||
};
|
||||
}
|
||||
|
||||
var rgbs = colorList.map(hexToRgb);
|
||||
|
||||
var avg = [0, 0, 0];
|
||||
for (var i = 0; i < rgbs.length; i++) {
|
||||
avg[0] += rgbs[i][0];
|
||||
avg[1] += rgbs[i][1];
|
||||
avg[2] += rgbs[i][2];
|
||||
}
|
||||
avg[0] = Math.round(avg[0] / rgbs.length);
|
||||
avg[1] = Math.round(avg[1] / rgbs.length);
|
||||
avg[2] = Math.round(avg[2] / rgbs.length);
|
||||
|
||||
if (count >= colorList.length) {
|
||||
return {
|
||||
middleColors: colorList.slice(),
|
||||
approximations: Object.fromEntries(colorList.map((c) => [c, c])),
|
||||
};
|
||||
}
|
||||
|
||||
var colorDistances = colorList.map(function (hex, idx) {
|
||||
return {
|
||||
hex: hex,
|
||||
dist: colorDistance(hexToRgb(hex), avg),
|
||||
};
|
||||
});
|
||||
|
||||
colorDistances.sort(function (a, b) {
|
||||
return a.dist - b.dist;
|
||||
});
|
||||
|
||||
var middleColors = colorDistances.slice(0, count).map(function (obj) {
|
||||
return obj.hex;
|
||||
});
|
||||
|
||||
var approximations = {};
|
||||
for (var i = 0; i < colorList.length; i++) {
|
||||
var hex = colorList[i];
|
||||
if (middleColors.indexOf(hex) !== -1) {
|
||||
approximations[hex] = hex;
|
||||
} else {
|
||||
approximations[hex] = closestColor(hex, middleColors);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
middleColors: middleColors,
|
||||
approximations: approximations,
|
||||
};
|
||||
}
|
||||
|
||||
import * as fs from "fs/promises";
|
||||
import { compress } from "./tiny_lz4.js";
|
||||
|
||||
export function pack_image(blocks) {
|
||||
const colors = middleColorsAndApproximations(
|
||||
blocks.map((z) => z.color),
|
||||
300,
|
||||
);
|
||||
const header = new Uint8Array(300 * 3 + 1 + 2);
|
||||
const dv = new DataView(header.buffer);
|
||||
dv.setUint16(0, blocks.length);
|
||||
let idx = 2;
|
||||
|
||||
colors.middleColors.forEach((z, i) => {
|
||||
const [r, g, b] = hexToRgb(z);
|
||||
dv.setUint8(idx + 1, r);
|
||||
dv.setUint8(idx + 2, g);
|
||||
dv.setUint8(idx + 3, b);
|
||||
|
||||
idx += 3;
|
||||
});
|
||||
|
||||
function pack_block(text, paletteIdx) {
|
||||
let raw_bytes = [];
|
||||
|
||||
text
|
||||
.replaceAll("\n", "?")
|
||||
.split("")
|
||||
.forEach((z, i) => {
|
||||
let id = i % 8;
|
||||
if (id == 0) {
|
||||
raw_bytes.push([]);
|
||||
}
|
||||
|
||||
raw_bytes.at(-1).push(z == "▇" ? 0 : 1);
|
||||
});
|
||||
|
||||
let bytes = raw_bytes.map((z) =>
|
||||
z.reduce((acc, bit, i) => acc | (bit << (7 - i)), 0),
|
||||
);
|
||||
|
||||
const final = new Uint8Array(3 + bytes.length);
|
||||
const dv = new DataView(final.buffer);
|
||||
|
||||
dv.setUint16(0, paletteIdx);
|
||||
dv.setUint8(2, (bytes.length << 4) | ((8 - raw_bytes.at(-1).length) & 0xf));
|
||||
bytes.forEach((z, i) => {
|
||||
dv.setUint8(i + 3, z);
|
||||
});
|
||||
return final;
|
||||
}
|
||||
|
||||
const byte_blocks = [];
|
||||
|
||||
blocks.forEach((z) => {
|
||||
byte_blocks.push(
|
||||
pack_block(
|
||||
z.text,
|
||||
colors.middleColors.indexOf(colors.approximations[z.color]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const totalBlockBytes = byte_blocks
|
||||
.map((z) => z.length)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const final = new Uint8Array(header.length + totalBlockBytes);
|
||||
|
||||
final.set(header, 0);
|
||||
|
||||
let offset = header.length;
|
||||
byte_blocks.forEach((block) => {
|
||||
final.set(block, offset);
|
||||
offset += block.length;
|
||||
});
|
||||
|
||||
const compressed = compress(final);
|
||||
return compressed;
|
||||
}
|
||||
145
lib/tiny_lz4.js
Normal file
145
lib/tiny_lz4.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
const MIN_MATCH = 3;
|
||||
const MAX_OFFSET = 0xffff;
|
||||
|
||||
function writeExtLen(out, n) {
|
||||
while (n >= 255) {
|
||||
out.push(255);
|
||||
n -= 255;
|
||||
}
|
||||
out.push(n);
|
||||
}
|
||||
|
||||
function readExtLen(input, pos, base) {
|
||||
let add = 0;
|
||||
while (true) {
|
||||
let b = input[pos++];
|
||||
add += b;
|
||||
if (b < 255) break;
|
||||
}
|
||||
return [base + add, pos];
|
||||
}
|
||||
|
||||
function findLongestMatch(data, pos, window) {
|
||||
let n = data.length;
|
||||
let max_off = Math.min(window, pos);
|
||||
let best_len = 0;
|
||||
let best_off = 0;
|
||||
let max_match_len = n - pos;
|
||||
if (max_match_len < MIN_MATCH) return [0, 0];
|
||||
|
||||
let start = pos - max_off;
|
||||
for (let j = start; j < pos; j++) {
|
||||
if (data[j] === data[pos]) {
|
||||
let k = 0;
|
||||
while (k < max_match_len && data[j + k] === data[pos + k]) {
|
||||
k++;
|
||||
}
|
||||
if (k >= MIN_MATCH && k > best_len) {
|
||||
best_len = k;
|
||||
best_off = pos - j;
|
||||
if (best_len >= 255 + MIN_MATCH) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [best_off, best_len];
|
||||
}
|
||||
|
||||
export function compress(input) {
|
||||
if (!(input instanceof Uint8Array))
|
||||
throw new Error("compress expects Uint8Array");
|
||||
|
||||
let n = input.length;
|
||||
let i = 0;
|
||||
let anchor = 0;
|
||||
let out = [];
|
||||
let window = MAX_OFFSET;
|
||||
|
||||
while (i < n) {
|
||||
let [off, match_len] = findLongestMatch(input, i, window);
|
||||
|
||||
if (match_len >= MIN_MATCH) {
|
||||
let lit_len = i - anchor;
|
||||
let lit_nibble = Math.min(lit_len, 15);
|
||||
let match_nibble = Math.min(match_len - MIN_MATCH, 15);
|
||||
let token = lit_nibble * 16 + match_nibble;
|
||||
out.push(token);
|
||||
|
||||
if (lit_len >= 15) {
|
||||
writeExtLen(out, lit_len - 15);
|
||||
}
|
||||
|
||||
for (let k = anchor; k < i; k++) out.push(input[k]);
|
||||
|
||||
out.push(off & 0xff, (off >> 8) & 0xff);
|
||||
|
||||
let rem = match_len - MIN_MATCH;
|
||||
if (rem >= 15) {
|
||||
writeExtLen(out, rem - 15);
|
||||
}
|
||||
|
||||
i += match_len;
|
||||
anchor = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
let final_lit = n - anchor;
|
||||
let tok_lit = Math.min(final_lit, 15);
|
||||
let token = tok_lit * 16;
|
||||
out.push(token);
|
||||
if (final_lit >= 15) {
|
||||
writeExtLen(out, final_lit - 15);
|
||||
}
|
||||
for (let k = anchor; k < n; k++) out.push(input[k]);
|
||||
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
export function decompress(input) {
|
||||
if (!(input instanceof Uint8Array))
|
||||
throw new Error("decompress expects Uint8Array");
|
||||
|
||||
let pos = 0;
|
||||
let n = input.length;
|
||||
let out = [];
|
||||
|
||||
while (pos < n) {
|
||||
let token = input[pos++];
|
||||
if (token === undefined) break;
|
||||
|
||||
let lit_nibble = token >> 4;
|
||||
let match_nibble = token & 0xf;
|
||||
|
||||
let lit_len = lit_nibble;
|
||||
if (lit_len === 15) {
|
||||
[lit_len, pos] = readExtLen(input, pos, 15);
|
||||
}
|
||||
|
||||
for (let k = 0; k < lit_len; k++) {
|
||||
out.push(input[pos++]);
|
||||
}
|
||||
|
||||
if (pos >= n) break;
|
||||
|
||||
let off = input[pos] | (input[pos + 1] << 8);
|
||||
pos += 2;
|
||||
|
||||
let match_len = match_nibble + MIN_MATCH;
|
||||
if (match_nibble === 15) {
|
||||
let extra;
|
||||
[extra, pos] = readExtLen(input, pos, 0);
|
||||
match_len += extra;
|
||||
}
|
||||
|
||||
let sofarLen = out.length;
|
||||
let match_start = sofarLen - off;
|
||||
if (match_start < 0) throw new Error("Invalid offset");
|
||||
|
||||
for (let k = 0; k < match_len; k++) {
|
||||
out.push(out[match_start + k]);
|
||||
}
|
||||
}
|
||||
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue