File size: 6,158 Bytes
de944bf
 
 
fe4e4c5
 
 
 
de944bf
 
 
 
 
 
 
 
 
fe4e4c5
 
 
 
 
 
 
 
 
 
 
 
de944bf
fe4e4c5
 
 
de944bf
 
fe4e4c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de944bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe4e4c5
de944bf
 
 
fe4e4c5
 
de944bf
 
fe4e4c5
 
 
 
 
 
 
 
 
 
 
de944bf
 
fe4e4c5
 
 
 
de944bf
 
 
 
 
 
 
fe4e4c5
de944bf
 
 
 
 
 
 
fe4e4c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de944bf
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// Uploaded bytes -> upright 8-bit RGB, exactly as preprocess.py's load_rgb produces it, in
// every browser: the page decodes images itself instead of asking the browser, whose
// decoders differ (Safari's JPEG decoder, colour management, premultiplied alpha).
// - JPEG and WebP: libjpeg-turbo 3 and libwebp compiled to WebAssembly, the libraries
//   Pillow uses; PNG: png.js. `codecs` supplies the two WebAssembly decoders.
// - Transparency is flattened onto white with Pillow's arithmetic and the EXIF orientation
//   applied like ImageOps.exif_transpose, in one pass.
// - Refused before decoding: unknown formats, out-of-limit sizes (as the API) and
//   incomplete files. Refused although Python accepts them (a known, documented
//   difference): CMYK JPEGs and animated WebP, which the WebAssembly decoders do not read.
import { ImageError, readHeader } from "./image-header.js";
import { decodePng } from "./png.js";
import { checkSize } from "./preprocess.js";

const damaged = () => new ImageError("That file could not be read as an image: it may be damaged.");

// Source pixel index for output pixel (x, y) under each orientation (2-8), like Pillow's
// transpose methods; w, h are the stored (unrotated) width and height.
const SOURCE = {
  1: (x, y, w) => y * w + x,
  2: (x, y, w) => y * w + (w - 1 - x), // FLIP_LEFT_RIGHT
  3: (x, y, w, h) => (h - 1 - y) * w + (w - 1 - x), // ROTATE_180
  4: (x, y, w, h) => (h - 1 - y) * w + x, // FLIP_TOP_BOTTOM
  5: (x, y, w) => x * w + y, // TRANSPOSE
  6: (x, y, w, h) => (h - 1 - x) * w + y, // ROTATE_270
  7: (x, y, w, h) => (h - 1 - x) * w + (w - 1 - y), // TRANSVERSE
  8: (x, y, w) => x * w + (w - 1 - y), // ROTATE_90
};

// Decoded pixels (1, 3 or 4 channels) -> upright RGB. Alpha is flattened onto white with
// Pillow's alpha_composite arithmetic (checked against Pillow for every colour and alpha).
function toUprightRgb({ width: w, height: h, channels, data }, orientation) {
  const swap = orientation >= 5;
  const W = swap ? h : w, H = swap ? w : h;
  const source = SOURCE[orientation];
  const out = new Uint8Array(W * H * 3);
  for (let y = 0, o = 0; y < H; y++) {
    for (let x = 0; x < W; x++, o += 3) {
      const i = source(x, y, w, h) * channels;
      if (channels === 1) {
        out[o] = out[o + 1] = out[o + 2] = data[i];
      } else if (channels === 3) {
        out[o] = data[i];
        out[o + 1] = data[i + 1];
        out[o + 2] = data[i + 2];
      } else {
        const a = data[i + 3], back = 255 * (255 - a) + 127;
        out[o] = Math.floor((data[i] * a + back) / 255);
        out[o + 1] = Math.floor((data[i + 1] * a + back) / 255);
        out[o + 2] = Math.floor((data[i + 2] * a + back) / 255);
      }
    }
  }
  return { rgb: out, width: W, height: H };
}

// bytes (Uint8Array) -> { rgb, width, height }; throws ImageError (or checkSize's Error)
// with a message for the user.
export async function decodeImage(bytes, codecs) {
  const header = await readHeader(bytes);
  if (!header.format) throw new ImageError("Use a JPEG, PNG or WebP photo.");
  if (!header.complete) {
    throw new ImageError("That file is incomplete: it may have been cut off while downloading.");
  }
  checkSize(header.width, header.height); // before anything is decoded
  if (header.format === "jpeg" && header.components === 4) {
    throw new ImageError("CMYK JPEGs (from print workflows) are not supported here: save the photo as RGB.");
  }
  if (header.format === "webp" && header.animated) {
    throw new ImageError("Animated images are not supported here.");
  }
  let image;
  if (header.format === "png") {
    image = await decodePng(bytes);
  } else {
    try {
      image = await codecs[header.format](header.decodeBytes ?? bytes);
    } catch {
      throw damaged();
    }
    // WebP without an alpha channel in Pillow's eyes: its colours are used as they are.
    if (header.format === "webp" && !header.alpha) image = dropAlpha(image);
  }
  if (image.width !== header.width || image.height !== header.height) throw damaged();
  return toUprightRgb(image, header.orientation);
}

function dropAlpha({ width, height, data }) {
  const rgb = new Uint8Array(width * height * 3);
  for (let i = 0, j = 0; j < rgb.length; i += 4, j += 3) {
    rgb[j] = data[i];
    rgb[j + 1] = data[i + 1];
    rgb[j + 2] = data[i + 2];
  }
  return { width, height, channels: 3, data: rgb };
}

// The two WebAssembly decoders from their Emscripten factories (the vendored
// libjpegturbowasm_decode.js and webp_dec.js) and their already verified .wasm bytes.
// Each returns { width, height, channels, data }. A decoder that fails is rebuilt for the
// next file.
export function makeCodecs({ jpegFactory, jpegWasm, webpFactory, webpWasm }) {
  const options = (wasmBinary) => ({ noInitialRun: true, wasmBinary, print: () => {}, printErr: () => {} });
  const wrap = (factory, wasm, call) => {
    let module = null;
    return async (bytes) => {
      module ??= await factory(options(wasm));
      try {
        return call(module, bytes);
      } catch (err) {
        module = null;
        throw err;
      }
    };
  };
  return {
    // libjpeg-turbo: RGB or greyscale output, no EXIF rotation.
    jpeg: wrap(jpegFactory, jpegWasm, (m, bytes) => {
      const decoder = new m.JPEGDecoder();
      try {
        decoder.getEncodedBuffer(bytes.length).set(bytes);
        decoder.decode();
        const info = decoder.getFrameInfo();
        if (info.componentCount !== 1 && info.componentCount !== 3) throw new Error("unsupported");
        return { width: info.width, height: info.height, channels: info.componentCount,
                 data: new Uint8Array(decoder.getDecodedBuffer()) }; // copied out of the WebAssembly heap
      } finally {
        decoder.delete();
      }
    }),
    // libwebp: RGBA output.
    webp: wrap(webpFactory, webpWasm, (m, bytes) => {
      const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
      const out = m.decode(buffer);
      if (!out) throw new Error("decoding failed");
      return { width: out.width, height: out.height, channels: 4, data: out.data };
    }),
  };
}