287 lines
8.4 KiB
JavaScript
287 lines
8.4 KiB
JavaScript
/* Copyright 2026 Mozilla Foundation
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { Dict, Name } from "../primitives.js";
|
|
import { FeatureTest } from "../../shared/util.js";
|
|
import { Stream } from "../stream.js";
|
|
|
|
// Below this many distinct RGB triples, Flate+Predictor 15 (PNG-style) is
|
|
// generally smaller than JPEG at visually equivalent quality, since the data
|
|
// is dominated by flat regions and sharp edges where JPEG performs poorly.
|
|
const FLATE_COLOR_COUNT_THRESHOLD = 16384;
|
|
|
|
function createImageDict(xref, width, height, colorSpace) {
|
|
const image = new Dict(xref);
|
|
image.set("Type", Name.get("XObject"));
|
|
image.set("Subtype", Name.get("Image"));
|
|
image.set("BitsPerComponent", 8);
|
|
image.setIfName("ColorSpace", colorSpace);
|
|
image.set("Width", width);
|
|
image.set("Height", height);
|
|
|
|
return image;
|
|
}
|
|
|
|
function createRawImage(buffer, dict) {
|
|
return new Stream(buffer, 0, buffer.length, dict);
|
|
}
|
|
|
|
function paethPredictor(left, above, upperLeft) {
|
|
const p = left + above - upperLeft;
|
|
const pa = Math.abs(p - left);
|
|
const pb = Math.abs(p - above);
|
|
const pc = Math.abs(p - upperLeft);
|
|
if (pa <= pb && pa <= pc) {
|
|
return left;
|
|
}
|
|
return pb <= pc ? above : upperLeft;
|
|
}
|
|
|
|
function applyPNGOptimumFilter(data, width, height, bytesPerPixel) {
|
|
const rowSize = width * bytesPerPixel;
|
|
const out = new Uint8Array(height * (rowSize + 1));
|
|
const candidates = [
|
|
new Uint8Array(rowSize), // 0: None
|
|
new Uint8Array(rowSize), // 1: Sub
|
|
new Uint8Array(rowSize), // 2: Up
|
|
new Uint8Array(rowSize), // 3: Average
|
|
new Uint8Array(rowSize), // 4: Paeth
|
|
];
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
const rowOffset = y * rowSize;
|
|
const prevRowOffset = rowOffset - rowSize;
|
|
const scores = [0, 0, 0, 0, 0];
|
|
for (let x = 0; x < rowSize; x++) {
|
|
const offset = rowOffset + x;
|
|
const cur = data[offset];
|
|
const left = x >= bytesPerPixel ? data[offset - bytesPerPixel] : 0;
|
|
const above = y > 0 ? data[prevRowOffset + x] : 0;
|
|
const upperLeft =
|
|
y > 0 && x >= bytesPerPixel
|
|
? data[prevRowOffset + x - bytesPerPixel]
|
|
: 0;
|
|
candidates[0][x] = cur;
|
|
candidates[1][x] = (cur - left) & 0xff;
|
|
candidates[2][x] = (cur - above) & 0xff;
|
|
candidates[3][x] = (cur - ((left + above) >> 1)) & 0xff;
|
|
candidates[4][x] = (cur - paethPredictor(left, above, upperLeft)) & 0xff;
|
|
// Sum of absolute signed-byte values: the standard "minimum sum"
|
|
// heuristic for picking the best filter per row.
|
|
for (let f = 0; f < 5; f++) {
|
|
const v = candidates[f][x];
|
|
scores[f] += v < 128 ? v : 256 - v;
|
|
}
|
|
}
|
|
|
|
let bestFilter = 0;
|
|
for (let f = 1; f < 5; f++) {
|
|
if (scores[f] < scores[bestFilter]) {
|
|
bestFilter = f;
|
|
}
|
|
}
|
|
|
|
const outOffset = y * (rowSize + 1);
|
|
out[outOffset] = bestFilter;
|
|
out.set(candidates[bestFilter], outOffset + 1);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
async function deflate(bytes) {
|
|
const cs = new CompressionStream("deflate");
|
|
const writer = cs.writable.getWriter();
|
|
const writePromise = (async () => {
|
|
try {
|
|
await writer.ready;
|
|
await writer.write(bytes);
|
|
await writer.ready;
|
|
await writer.close();
|
|
} catch (reason) {
|
|
await writer.abort(reason).catch(() => {});
|
|
throw reason;
|
|
}
|
|
})();
|
|
const [compressed] = await Promise.all([
|
|
new Response(cs.readable).bytes(),
|
|
writePromise.then(() => null),
|
|
]);
|
|
return compressed;
|
|
}
|
|
|
|
async function createPNGLikeImage(buffer, width, height, dict) {
|
|
const bytesPerPixel = buffer.length / (width * height);
|
|
let compressed;
|
|
if (typeof CompressionStream === "function") {
|
|
try {
|
|
const filtered = applyPNGOptimumFilter(
|
|
buffer,
|
|
width,
|
|
height,
|
|
bytesPerPixel
|
|
);
|
|
compressed = await deflate(filtered);
|
|
} catch {}
|
|
}
|
|
|
|
if (!compressed) {
|
|
return createRawImage(buffer, dict);
|
|
}
|
|
|
|
dict.setIfName("Filter", "FlateDecode");
|
|
const decodeParms = new Dict(dict.xref);
|
|
decodeParms.set("Predictor", 15);
|
|
decodeParms.set("Columns", width);
|
|
decodeParms.set("Colors", bytesPerPixel);
|
|
decodeParms.set("BitsPerComponent", 8);
|
|
dict.set("DecodeParms", decodeParms);
|
|
|
|
return createRawImage(compressed, dict);
|
|
}
|
|
|
|
async function createImage(bitmap, xref, { closeBitmap = false } = {}) {
|
|
// TODO: when printing, we could have a specific internal colorspace
|
|
// (e.g. something like DeviceRGBA) in order avoid any conversion (i.e. no
|
|
// jpeg, no rgba to rgb conversion, etc...)
|
|
|
|
const { width, height } = bitmap;
|
|
if (
|
|
!Number.isInteger(width) ||
|
|
!Number.isInteger(height) ||
|
|
width <= 0 ||
|
|
height <= 0
|
|
) {
|
|
if (closeBitmap) {
|
|
bitmap.close?.();
|
|
}
|
|
throw new Error(
|
|
`createImage: invalid bitmap dimensions ${width}x${height}`
|
|
);
|
|
}
|
|
const canvas = new OffscreenCanvas(width, height);
|
|
const ctx = canvas.getContext("2d", {
|
|
alpha: true,
|
|
willReadFrequently: true,
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
ctx.drawImage(bitmap, 0, 0);
|
|
data = ctx.getImageData(0, 0, width, height).data;
|
|
} finally {
|
|
if (closeBitmap) {
|
|
bitmap.close?.();
|
|
}
|
|
}
|
|
const buf32 = new Uint32Array(
|
|
data.buffer,
|
|
data.byteOffset,
|
|
data.byteLength >> 2
|
|
);
|
|
|
|
// Bitwise masks are signed in JS, so extracting alpha via `(v & 0xff000000)`
|
|
// would misclassify every opaque pixel as transparent on little-endian
|
|
// platforms — use the byte-level shift/mask instead.
|
|
const isLE = FeatureTest.isLittleEndian;
|
|
const rgbMask = isLE ? 0x00ffffff : 0xffffff00;
|
|
const colorCounter = new Set();
|
|
let hasAlpha = false;
|
|
let useFlate = true;
|
|
for (let i = 0, ii = buf32.length; i < ii; i++) {
|
|
const v = buf32[i];
|
|
if ((isLE ? v >>> 24 : v & 0xff) !== 0xff) {
|
|
hasAlpha = true;
|
|
break;
|
|
}
|
|
if (useFlate) {
|
|
colorCounter.add((v & rgbMask) >>> 0);
|
|
if (colorCounter.size > FLATE_COLOR_COUNT_THRESHOLD) {
|
|
useFlate = false;
|
|
colorCounter.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasAlpha) {
|
|
// JPEG can bleed hidden/edge RGB into semi-transparent pixels. Keep alpha
|
|
// images lossless instead.
|
|
useFlate = true;
|
|
}
|
|
|
|
const image = createImageDict(xref, width, height, "DeviceRGB");
|
|
|
|
let imageStreamPromise;
|
|
let imageRenderStream = null;
|
|
if (useFlate) {
|
|
// Pack RGB triples without compositing over white: the SMask carries the
|
|
// original alpha and the lossless RGB stream stays exact.
|
|
const rgbBuffer = new Uint8Array(width * height * 3);
|
|
for (let i = 0, j = 0, ii = data.length; i < ii; i += 4, j += 3) {
|
|
rgbBuffer[j] = data[i];
|
|
rgbBuffer[j + 1] = data[i + 1];
|
|
rgbBuffer[j + 2] = data[i + 2];
|
|
}
|
|
imageStreamPromise = createPNGLikeImage(rgbBuffer, width, height, image);
|
|
imageRenderStream = createRawImage(
|
|
rgbBuffer,
|
|
createImageDict(xref, width, height, "DeviceRGB")
|
|
);
|
|
} else {
|
|
image.setIfName("Filter", "DCTDecode");
|
|
imageStreamPromise = canvas
|
|
.convertToBlob({ type: "image/jpeg", quality: 1 })
|
|
.then(blob => blob.bytes())
|
|
.then(bytes => createRawImage(bytes, image));
|
|
}
|
|
|
|
let smaskStreamPromise = Promise.resolve(null);
|
|
let smaskRenderStream = null;
|
|
if (hasAlpha) {
|
|
const alphaBuffer = new Uint8Array(buf32.length);
|
|
if (isLE) {
|
|
for (let i = 0, ii = buf32.length; i < ii; i++) {
|
|
alphaBuffer[i] = buf32[i] >>> 24;
|
|
}
|
|
} else {
|
|
for (let i = 0, ii = buf32.length; i < ii; i++) {
|
|
alphaBuffer[i] = buf32[i] & 0xff;
|
|
}
|
|
}
|
|
|
|
const smask = createImageDict(xref, width, height, "DeviceGray");
|
|
const smaskRenderDict = createImageDict(xref, width, height, "DeviceGray");
|
|
|
|
smaskStreamPromise = createPNGLikeImage(alphaBuffer, width, height, smask);
|
|
smaskRenderStream = createRawImage(alphaBuffer, smaskRenderDict);
|
|
}
|
|
|
|
const [imageStream, smaskStream] = await Promise.all([
|
|
imageStreamPromise,
|
|
smaskStreamPromise,
|
|
]);
|
|
|
|
return {
|
|
imageStream,
|
|
imageRenderStream,
|
|
smaskStream,
|
|
smaskRenderStream,
|
|
width,
|
|
height,
|
|
};
|
|
}
|
|
|
|
export { createImage };
|