/* Pure page and image-metadata helpers. This file intentionally has no DOM
dependencies so the conversion rules can be exercised with node --test. */
(function (root, factory) {
if (typeof module === "object" && module.exports) module.exports = factory();
else root.Pages = factory();
})(typeof self !== "undefined" ? self : globalThis, function () {
"use strict";
const A4_PORTRAIT = [595.28, 841.89];
const MARGIN_PT = 24;
const RENDER_DPI = 150;
const DEFAULT_IMAGE_DPI = 96;
const MAX_PAGE_PT = 14400; // 200 inches, the widely-supported PDF limit.
function pageSize(imageWidth, imageHeight) {
const landscape = imageWidth > imageHeight;
const [short, long] = A4_PORTRAIT;
return {
width: landscape ? long : short,
height: landscape ? short : long,
orientation: landscape ? "l" : "p",
};
}
function placeImage(imageWidth, imageHeight, pageWidth, pageHeight, margin) {
const inset = margin === undefined ? MARGIN_PT : margin;
const boxWidth = Math.max(1, pageWidth - inset * 2);
const boxHeight = Math.max(1, pageHeight - inset * 2);
const scale = Math.min(boxWidth / imageWidth, boxHeight / imageHeight);
const width = imageWidth * scale;
const height = imageHeight * scale;
return {
x: (pageWidth - width) / 2,
y: (pageHeight - height) / 2,
width,
height,
};
}
function rasterSize(placement, dpi) {
const density = (dpi === undefined ? RENDER_DPI : dpi) / 72;
return {
width: Math.max(1, Math.round(placement.width * density)),
height: Math.max(1, Math.round(placement.height * density)),
};
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
function isTiff(bytes) {
if (!bytes || bytes.length < 4) return false;
return (
(bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a)
);
}
const ascii = (bytes, start, length) =>
String.fromCharCode.apply(null, Array.from(bytes.slice(start, start + length)));
function tiffDirectory(bytes, start) {
if (start + 8 > bytes.length) return null;
const little = bytes[start] === 0x49 && bytes[start + 1] === 0x49;
if (!little && !(bytes[start] === 0x4d && bytes[start + 1] === 0x4d)) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const u16 = (offset) => view.getUint16(offset, little);
const u32 = (offset) => view.getUint32(offset, little);
if (u16(start + 2) !== 42) return null;
const ifd = start + u32(start + 4);
if (ifd + 2 > bytes.length) return null;
const out = {};
const count = u16(ifd);
const typeBytes = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 9: 4, 10: 8 };
function valueAt(entry, type, amount) {
const size = (typeBytes[type] || 0) * amount;
if (!size) return undefined;
const offset = size <= 4 ? entry + 8 : start + u32(entry + 8);
if (offset < 0 || offset + size > bytes.length) return undefined;
if (type === 3) return u16(offset);
if (type === 4) return u32(offset);
if (type === 5) {
const denominator = u32(offset + 4);
return denominator ? u32(offset) / denominator : undefined;
}
return bytes[offset];
}
for (let index = 0; index < count; index += 1) {
const entry = ifd + 2 + index * 12;
if (entry + 12 > bytes.length) break;
const tag = u16(entry);
const type = u16(entry + 2);
const amount = u32(entry + 4);
if ([256, 257, 274, 282, 283, 296].includes(tag)) {
out[tag] = valueAt(entry, type, amount);
}
}
return out;
}
function parseJpeg(bytes) {
let offset = 2;
let jfif = null;
let exif = null;
let width;
let height;
while (offset + 4 <= bytes.length && bytes[offset] === 0xff) {
while (bytes[offset] === 0xff) offset += 1;
const marker = bytes[offset++];
if (marker === 0xd9 || marker === 0xda) break;
if (offset + 2 > bytes.length) break;
const length = (bytes[offset] << 8) | bytes[offset + 1];
const data = offset + 2;
if (length < 2 || data + length - 2 > bytes.length) break;
if (marker === 0xe0 && ascii(bytes, data, 5) === "JFIF\0" && length >= 14) {
const unit = bytes[data + 7];
let dpiX = (bytes[data + 8] << 8) | bytes[data + 9];
let dpiY = (bytes[data + 10] << 8) | bytes[data + 11];
if (unit === 2) {
dpiX *= 2.54;
dpiY *= 2.54;
}
if (unit === 1 || unit === 2) jfif = { dpiX, dpiY };
}
if (marker === 0xe1 && ascii(bytes, data, 6) === "Exif\0\0") {
const tags = tiffDirectory(bytes, data + 6);
if (tags) {
let dpiX = tags[282];
let dpiY = tags[283];
if (tags[296] === 3) {
dpiX *= 2.54;
dpiY *= 2.54;
}
exif = { dpiX, dpiY, orientation: tags[274] };
}
}
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
height = (bytes[data + 1] << 8) | bytes[data + 2];
width = (bytes[data + 3] << 8) | bytes[data + 4];
}
offset += length;
}
const result = { width, height, ...(jfif || {}) };
if (exif) {
if (Number.isFinite(exif.dpiX) && exif.dpiX > 0) result.dpiX = exif.dpiX;
if (Number.isFinite(exif.dpiY) && exif.dpiY > 0) result.dpiY = exif.dpiY;
if (exif.orientation) result.orientation = exif.orientation;
}
return result;
}
function parsePng(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const out = {};
if (bytes.length >= 24) {
out.width = view.getUint32(16, false);
out.height = view.getUint32(20, false);
}
let offset = 8;
while (offset + 12 <= bytes.length) {
const length = view.getUint32(offset, false);
const type = ascii(bytes, offset + 4, 4);
if (offset + 12 + length > bytes.length) break;
if (type === "pHYs" && length >= 9 && bytes[offset + 16] === 1) {
out.dpiX = view.getUint32(offset + 8, false) * 0.0254;
out.dpiY = view.getUint32(offset + 12, false) * 0.0254;
}
if (type === "IEND") break;
offset += length + 12;
}
return out;
}
function parseBmp(bytes) {
if (bytes.length < 46) return {};
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return {
width: Math.abs(view.getInt32(18, true)),
height: Math.abs(view.getInt32(22, true)),
dpiX: Math.abs(view.getInt32(38, true)) * 0.0254,
dpiY: Math.abs(view.getInt32(42, true)) * 0.0254,
};
}
function parseTiff(bytes) {
const tags = tiffDirectory(bytes, 0) || {};
let dpiX = tags[282];
let dpiY = tags[283];
if (tags[296] === 3) {
dpiX *= 2.54;
dpiY *= 2.54;
}
return {
width: tags[256],
height: tags[257],
orientation: tags[274],
dpiX,
dpiY,
};
}
function parseRasterMetadata(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input || 0);
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) return parseJpeg(bytes);
if (bytes.length >= 8 && ascii(bytes, 1, 3) === "PNG") return parsePng(bytes);
if (bytes.length >= 2 && ascii(bytes, 0, 2) === "BM") return parseBmp(bytes);
if (isTiff(bytes)) return parseTiff(bytes);
return {};
}
function validDpi(value) {
return Number.isFinite(value) && value > 0 ? value : DEFAULT_IMAGE_DPI;
}
function imagePage(pixelWidth, pixelHeight, dpiX, dpiY, orientation, maxPagePt) {
const turn = orientation >= 5 && orientation <= 8;
const sourceWidth = Math.max(1, Number(pixelWidth) || 1);
const sourceHeight = Math.max(1, Number(pixelHeight) || 1);
const resolvedX = validDpi(dpiX);
const resolvedY = validDpi(dpiY);
const widthPixels = turn ? sourceHeight : sourceWidth;
const heightPixels = turn ? sourceWidth : sourceHeight;
const widthDpi = turn ? resolvedY : resolvedX;
const heightDpi = turn ? resolvedX : resolvedY;
const naturalWidth = (widthPixels / widthDpi) * 72;
const naturalHeight = (heightPixels / heightDpi) * 72;
const cap = maxPagePt === undefined ? MAX_PAGE_PT : maxPagePt;
const scale = Math.min(1, cap / naturalWidth, cap / naturalHeight);
return {
width: naturalWidth * scale,
height: naturalHeight * scale,
naturalWidth,
naturalHeight,
pixelWidth: widthPixels,
pixelHeight: heightPixels,
dpiX: widthDpi,
dpiY: heightDpi,
scale,
capped: scale < 1,
};
}
return {
A4_PORTRAIT,
MARGIN_PT,
RENDER_DPI,
DEFAULT_IMAGE_DPI,
MAX_PAGE_PT,
pageSize,
placeImage,
rasterSize,
formatBytes,
isTiff,
parseRasterMetadata,
imagePage,
};
});