dropflow
Version:
A small CSS2 document renderer built from specifications
206 lines (205 loc) • 6.65 kB
JavaScript
import { environment } from './environment.js';
import { objectStore } from './api.js';
// JPEG markers always start with 0xFF
const JPEG_SOI = 0xffd8; // Start of Image
const JPEG_SOF0 = 0xffc0; // Baseline
const JPEG_SOF2 = 0xffc2; // Progressive
// PNG signature and IHDR chunk
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const PNG_IHDR = 0x49484452;
// GIF signature variants
const GIF87a = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]);
const GIF89a = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
// BMP signature and header size
const BMP_SIGNATURE = 0x4d42; // 'BM'
function compareArrays(a, b, length) {
for (let i = 0; i < length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
}
function parseJpegDimensions(buffer) {
const view = new DataView(buffer);
// Check for JPEG signature
if (view.getUint16(0) !== JPEG_SOI) {
throw new Error('Not a valid JPEG file');
}
let offset = 2;
while (offset < buffer.byteLength) {
// All JPEG markers start with 0xff
if (view.getUint8(offset) !== 0xff) {
throw new Error('Invalid JPEG marker');
}
const marker = view.getUint16(offset);
offset += 2;
// Check for SOF markers that contain dimensions
if (marker === JPEG_SOF0 || marker === JPEG_SOF2) {
// Skip segment length and precision bytes
offset += 3;
const height = view.getUint16(offset);
const width = view.getUint16(offset + 2);
return { width, height };
}
// Skip to next marker using segment length
const length = view.getUint16(offset);
offset += length;
}
throw new Error('No JPEG dimensions found');
}
function parsePngDimensions(buffer) {
const view = new DataView(buffer);
const signature = new Uint8Array(buffer, 0, 8);
if (!compareArrays(signature, PNG_SIGNATURE, 8)) {
throw new Error('Not a valid PNG file');
}
// IHDR chunk is always first and contains dimensions
// Skip signature (8) and chunk length (4)
const chunkType = view.getUint32(12);
if (chunkType !== PNG_IHDR) {
throw new Error('Invalid PNG: Missing IHDR chunk');
}
const width = view.getUint32(16);
const height = view.getUint32(20);
return { width, height };
}
function parseGifDimensions(buffer) {
const view = new DataView(buffer);
const signature = new Uint8Array(buffer, 0, 6);
if (!compareArrays(signature, GIF87a, 6) && !compareArrays(signature, GIF89a, 6)) {
throw new Error('Not a valid GIF file');
}
// Dimensions are stored right after signature
const width = view.getUint16(6, true); // GIF uses little-endian
const height = view.getUint16(8, true);
return { width, height };
}
function parseBmpDimensions(buffer) {
const view = new DataView(buffer);
if (view.getUint16(0) !== BMP_SIGNATURE) {
throw new Error('Not a valid BMP file');
}
// BMP dimensions are at offset 18 and 22
const width = Math.abs(view.getInt32(18, true)); // Can be negative for top-down images
const height = Math.abs(view.getInt32(22, true));
return { width, height };
}
function parseImageDimensions(buffer) {
// Try to detect format from first bytes
const view = new DataView(buffer);
const firstBytes = view.getUint16(0);
try {
if (firstBytes === JPEG_SOI) {
return parseJpegDimensions(buffer);
}
else if (firstBytes === BMP_SIGNATURE) {
return parseBmpDimensions(buffer);
}
else {
const signature = new Uint8Array(buffer, 0, 8);
if (compareArrays(signature, PNG_SIGNATURE, 8)) {
return parsePngDimensions(buffer);
}
else if (compareArrays(signature, GIF87a, 6) || compareArrays(signature, GIF89a, 6)) {
return parseGifDimensions(buffer);
}
}
}
catch (e) {
if (e instanceof Error) {
throw new Error(`Failed to parse image dimensions: ${e.message}`);
}
throw new Error('Failed to parse image dimensions: Unknown error');
}
throw new Error('Unsupported image format');
}
export class Image {
src;
buffer;
width;
height;
status;
reason;
decoded;
constructor(src, buffer) {
this.src = src;
this.buffer = buffer;
this.width = 0;
this.height = 0;
this.status = 'unloaded';
this.reason = undefined;
this.decoded = null;
}
/** @internal */
_destroy() {
environment.destroyDecodedImage(this.decoded);
}
#onBuffer(buffer) {
const { width, height } = parseImageDimensions(buffer);
this.width = width;
this.height = height;
}
onLoaded() {
this.status = 'loaded';
}
onError(error) {
this.status = 'error';
this.reason = error;
}
tryObjectUrl(url) {
if (url.protocol === 'blob:')
return objectStore.get(url.href);
}
async load() {
if (this.status === 'unloaded') {
try {
const url = new URL(this.src);
this.buffer = this.tryObjectUrl(url) || await environment.resolveUrl(url);
this.#onBuffer(this.buffer);
this.onLoaded();
this.decoded = await environment.createDecodedImage(this);
}
catch (e) {
this.onError(e);
}
}
}
loadSync() {
if (this.status === 'unloaded') {
try {
const url = new URL(this.src);
this.buffer = this.tryObjectUrl(url) || environment.resolveUrlSync(url);
this.#onBuffer(this.buffer);
this.onLoaded();
}
catch (e) {
this.onError(e);
}
}
}
}
const cache = new Map();
export function clearImageCache() {
for (const image of cache.values()) {
image._destroy();
cache.delete(image.src);
}
}
export function getImage(src) {
return cache.get(src);
}
export function checkCache() {
if (cache.size > 1_000)
clearImageCache();
}
function ensureImage(src) {
let image = cache.get(src);
if (!image)
cache.set(src, image = new Image(src));
return image;
}
export function onLoadWalkerElementForImage(ctx, el) {
if (el.tagName === 'img' && el.attrs.src) {
ctx.onLoadableResource(ensureImage(el.attrs.src));
}
}