patx/makeitpdf

/* 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,
  };
});