PNG: basic skeleton

This commit is contained in:
Evan Hahn 2023-08-01 09:20:57 -05:00
parent 8eed5b60da
commit 79498239c4
13 changed files with 242 additions and 8 deletions

View File

@ -4,7 +4,8 @@
"check-all": "deno fmt --check && deno lint && deno task typecheck && deno test --allow-read"
},
"imports": {
"assert": "https://deno.land/std@0.196.0/testing/asserts.ts"
"assert": "https://deno.land/std@0.196.0/testing/asserts.ts",
"mock": "https://deno.land/std@0.196.0/testing/mock.ts"
},
"compilerOptions": {
"lib": ["deno.ns", "dom"]

View File

@ -29,6 +29,7 @@
"https://deno.land/std@0.196.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a",
"https://deno.land/std@0.196.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536",
"https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc",
"https://deno.land/std@0.196.0/testing/asserts.ts": "b4e4b1359393aeff09e853e27901a982c685cb630df30426ed75496961931946"
"https://deno.land/std@0.196.0/testing/asserts.ts": "b4e4b1359393aeff09e853e27901a982c685cb630df30426ed75496961931946",
"https://deno.land/std@0.196.0/testing/mock.ts": "4c52b8312d159179fdd9d9a1b35e342ee4e1a1248f29e5c7f57fb4011c3f55ed"
}
}

View File

@ -0,0 +1,38 @@
// @ts-check
/**
* Convert a `Uint8Array` to a URL-safe base64 string.
* @param {Uint8Array} bytes
* @returns {string}
*/
export const stringify = (bytes) => {
const string = String.fromCharCode(...bytes);
const base64 = btoa(string);
return base64
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
};
/**
* Convert a URL-safe base64 string to a `Uint8Array`.
* Returns `null` if the string is invalid.
* @param {string} text
* @returns {null | Uint8Array}
*/
export const parse = (text) => {
const normalizedText = text
.replaceAll("-", "+")
.replaceAll("_", "/")
.replaceAll(" ", "+");
try {
const string = atob(normalizedText);
const bytes = new Uint8Array(string.length);
for (let i = 0; i < string.length; i++) {
bytes[i] = string.charCodeAt(i);
}
return bytes;
} catch (_err) {
return null;
}
};

View File

@ -0,0 +1,14 @@
// @ts-check
/**
* Like `JSON.parse`, but returns `null` instead of throwing an error.
* @param {string} text
* @returns {unknown}
*/
export default (text) => {
try {
return JSON.parse(text);
} catch (_err) {
return null;
}
};

View File

@ -2,6 +2,7 @@
import { SUPPORTED_FILE_TYPES } from "./constants.js";
import crel from "../common/crel.js";
import * as base64url from "../common/base64url.js";
import { routeFile } from "./fileRouter.js";
const accept = SUPPORTED_FILE_TYPES
@ -45,12 +46,7 @@ const disclaimerParagraphEl = crel(
const formatBytes = async (blob) => {
const arrayBuffer = await blob.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
const string = String.fromCharCode(...bytes);
const base64 = btoa(string);
return base64
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
return base64url.stringify(bytes);
};
const main = () => {

21
public/png/index.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>formats.exposed/png</title>
<noscript>
<meta http-equiv="refresh" content="0; url=.." />
</noscript>
<link rel="stylesheet" href="../common/global.css">
<link rel="stylesheet" href="png.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="error" hidden></div>
<div id="explorer" hidden>
<div id="bytesEl"></div>
<div id="treeEl"></div>
</div>
<script src="png.js" type="module"></script>
</body>
</html>

41
public/png/parseHash.js Normal file
View File

@ -0,0 +1,41 @@
// @ts-check
import maybeJsonParse from "../common/maybeJsonParse.js";
import * as base64url from "../common/base64url.js";
/**
* Parse a location hash into `name` and `bytes`.
* @param {string} hash
* @returns {null | { name: string, bytes: Uint8Array }} The parsed data, or `null` if the hash can't be parsed.
*/
export default (hash) => {
const normalizedHash = decodeURI(hash.replace(/^#/, ""));
const parsed = maybeJsonParse(normalizedHash);
if (!parsed || typeof parsed !== "object") {
console.warn("Couldn't parse hash as JSON object");
return null;
}
if (
!("name" in parsed) || (typeof parsed.name !== "string") ||
!("bytes" in parsed) || (typeof parsed.bytes !== "string")
) {
console.warn("Hash fields missing or invalid type");
return null;
}
const { name } = parsed;
if (!name) {
console.warn("Name is empty");
return null;
}
const bytes = base64url.parse(parsed.bytes);
if (!bytes || !bytes.byteLength) {
console.warn("Bytes is empty");
return null;
}
return { name, bytes };
};

0
public/png/png.css Normal file
View File

21
public/png/png.js Normal file
View File

@ -0,0 +1,21 @@
// @ts-check
import parseHash from "./parseHash.js";
const errorEl = document.getElementById("error");
const explorerEl = document.getElementById("explorer");
if (!errorEl || !explorerEl) throw new Error("HTML is not set up correctly");
const main = () => {
// TODO: We may want a better UI here.
const parsedHash = parseHash(location.hash);
if (!parsedHash) {
location.href = "..";
return;
}
// TODO: Actually do something!
console.log(parsedHash);
};
main();

4
public/png/vendor/pako_inflate.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,47 @@
import { assertEquals } from "assert";
import * as base64url from "../../public/common/base64url.js";
/**
* A shorthand for `new Uint8Array()`.
*/
const b = (...bytes: number[]) => new Uint8Array(bytes);
Deno.test('encodes no bytes as ""', () => {
assertEquals(base64url.stringify(b()), "");
});
Deno.test("encodes in a URL-safe way", () => {
const input = b(105, 183, 62, 249, 215, 191, 254);
assertEquals(
base64url.stringify(input),
"abc--de__g",
);
});
Deno.test("decodes the empty string", () => {
assertEquals(base64url.parse(""), b());
});
Deno.test("decodes URL-safe strings", () => {
assertEquals(base64url.parse("_-o"), b(255, 234));
});
Deno.test("decodes URL-unsafe strings (as a bonus)", () => {
assertEquals(base64url.parse("/+o="), b(255, 234));
});
Deno.test("round-trips", () => {
const testCases = [
b(),
b(1),
b(1, 2, 3),
b(255, 234),
b(105, 183, 62, 249, 215, 191, 254),
];
for (const original of testCases) {
const string = base64url.stringify(original);
const parsed = base64url.parse(string);
assertEquals(parsed, original, `Round-trip failed for ${original}`);
}
});

View File

@ -0,0 +1,12 @@
import { assertEquals } from "assert";
import maybeJsonParse from "../../public/common/maybeJsonParse.js";
Deno.test("returns null if string can't be parsed", () => {
assertEquals(maybeJsonParse(""), null);
assertEquals(maybeJsonParse('{"hi":'), null);
});
Deno.test("parses valid JSON", () => {
assertEquals(maybeJsonParse("123"), 123);
assertEquals(maybeJsonParse('{"hi": 5}'), { hi: 5 });
});

View File

@ -0,0 +1,38 @@
import { assertEquals } from "assert";
import { stub } from "mock";
import parseHash from "../../public/png/parseHash.js";
Deno.test("returns null if hash cannot be parsed", () => {
const testCases = [
// Missing fields
"#null",
"#{}",
"#{%22name%22:%22image.png%22}",
"#{%22bytes%22:%22AQID%22}",
// Invalid JSON
"",
"#{%22name%22:%22small.png%22,%22bytes%22:%22iAQID",
];
const warnStub = stub(console, "warn");
try {
for (const testCase of testCases) {
assertEquals(
parseHash(testCase),
null,
`Parsing ${testCase} should fail`,
);
}
} finally {
warnStub.restore();
}
});
Deno.test("parses hashes", () => {
const hash = "#{%22name%22:%22small.png%22,%22bytes%22:%22AQID%22}";
assertEquals(parseHash(hash), {
name: "small.png",
bytes: new Uint8Array([1, 2, 3]),
});
});