Files
dd-pdf.js/src/core/editor/pdf_images.js

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 };