← Files Google DriveARCHIVED FILE

skills/google-slides/runtime/media-geometry.mjs

9.83 KB · Oct 3, 2026 · 06:02 UTC

↓ Download file

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { assert, isRecord } from "./common.mjs";
import { dimensionToPoints } from "./normalize.mjs";

const VERSION = "1.18";
const RATIO_TOLERANCE = 0.01;

function finitePositive(value) {
  const number = Number(value);
  return Number.isFinite(number) && number > 0 ? number : null;
}

export function aspectRatio(width, height) {
  const safeWidth = finitePositive(width);
  const safeHeight = finitePositive(height);
  return safeWidth && safeHeight ? safeWidth / safeHeight : null;
}

export function ratioDrift(left, right) {
  const a = finitePositive(left);
  const b = finitePositive(right);
  return a && b ? Math.abs(a / b - 1) : null;
}

export function panelGeometry(bounds, pageSize) {
  if (!bounds || !Number.isFinite(pageSize?.width) || !Number.isFinite(pageSize?.height)) return null;
  const centerX = bounds.x + bounds.width / 2;
  const centerY = bounds.y + bounds.height / 2;
  const areaRatio = bounds.width * bounds.height / (pageSize.width * pageSize.height);
  const horizontal = centerX < pageSize.width * 0.4 ? "left" : centerX > pageSize.width * 0.6 ? "right" : "center";
  const vertical = centerY < pageSize.height * 0.4 ? "top" : centerY > pageSize.height * 0.6 ? "bottom" : "middle";
  const fullWidth = bounds.width >= pageSize.width * 0.9;
  const fullHeight = bounds.height >= pageSize.height * 0.9;
  return {
    horizontal,
    vertical,
    classification: fullWidth && fullHeight ? "full-canvas" : fullWidth ? "horizontal-band" : fullHeight ? "vertical-band" : `${horizontal}-${vertical}`,
    areaRatio,
  };
}

export function cropGeometry(element) {
  const crop = element?.image?.properties?.cropProperties ?? element?.image?.imageProperties?.cropProperties ?? {};
  const left = Math.max(0, Number(crop.leftOffset ?? 0));
  const right = Math.max(0, Number(crop.rightOffset ?? 0));
  const top = Math.max(0, Number(crop.topOffset ?? 0));
  const bottom = Math.max(0, Number(crop.bottomOffset ?? 0));
  const visibleWidthFraction = Math.max(0, 1 - left - right);
  const visibleHeightFraction = Math.max(0, 1 - top - bottom);
  return {
    left,
    right,
    top,
    bottom,
    visibleWidthFraction,
    visibleHeightFraction,
    visibleAreaFraction: visibleWidthFraction * visibleHeightFraction,
  };
}

function transformedEdgeLengths(element) {
  const width = dimensionToPoints(element?.size?.width);
  const height = dimensionToPoints(element?.size?.height);
  const transform = element?.absoluteTransform ?? element?.transform ?? {};
  if (!finitePositive(width) || !finitePositive(height)) return null;
  const scaleX = Number(transform.scaleX ?? 1);
  const scaleY = Number(transform.scaleY ?? 1);
  const shearX = Number(transform.shearX ?? 0);
  const shearY = Number(transform.shearY ?? 0);
  if (![scaleX, scaleY, shearX, shearY].every(Number.isFinite)) return null;
  return {
    width: width * Math.hypot(scaleX, shearY),
    height: height * Math.hypot(shearX, scaleY),
  };
}

export function frameAspectRatio(element) {
  const edges = transformedEdgeLengths(element);
  return edges ? aspectRatio(edges.width, edges.height) : aspectRatio(element?.boundsPt?.width, element?.boundsPt?.height);
}

export function estimatedContentAspectRatio(element) {
  const frameRatio = frameAspectRatio(element);
  if (!frameRatio) return null;
  if (!element?.image) return frameRatio;
  const crop = cropGeometry(element);
  if (!(crop.visibleWidthFraction > 0) || !(crop.visibleHeightFraction > 0)) return null;
  return frameRatio * crop.visibleHeightFraction / crop.visibleWidthFraction;
}

export function mediaGeometry(element, pageSize) {
  const bounds = element?.boundsPt ?? null;
  const crop = element?.image ? cropGeometry(element) : null;
  return {
    mediaType: element?.kind ?? null,
    boundsPt: bounds,
    frameAspectRatio: frameAspectRatio(element),
    contentAspectRatioEstimate: estimatedContentAspectRatio(element),
    occupiedAreaRatio: bounds && Number.isFinite(pageSize?.width) && Number.isFinite(pageSize?.height)
      ? bounds.width * bounds.height / (pageSize.width * pageSize.height)
      : null,
    panel: panelGeometry(bounds, pageSize),
    crop,
  };
}

export function fitRect({ sourceAspectRatio, targetBounds, fit = "contain", alignX = 0.5, alignY = 0.5 } = {}) {
  const ratio = finitePositive(sourceAspectRatio);
  assert(ratio, "sourceAspectRatio must be positive", "sourceAspectRatio");
  assert(targetBounds && finitePositive(targetBounds.width) && finitePositive(targetBounds.height), "targetBounds must have positive width and height", "targetBounds");
  assert(["contain", "cover"].includes(fit), "fit must be contain or cover", "fit");
  assert(Number.isFinite(alignX) && alignX >= 0 && alignX <= 1, "alignX must be between 0 and 1", "alignX");
  assert(Number.isFinite(alignY) && alignY >= 0 && alignY <= 1, "alignY must be between 0 and 1", "alignY");
  const targetRatio = targetBounds.width / targetBounds.height;
  let width;
  let height;
  if ((fit === "contain" && ratio >= targetRatio) || (fit === "cover" && ratio < targetRatio)) {
    width = targetBounds.width;
    height = width / ratio;
  } else {
    height = targetBounds.height;
    width = height * ratio;
  }
  return {
    x: targetBounds.x + (targetBounds.width - width) * alignX,
    y: targetBounds.y + (targetBounds.height - height) * alignY,
    width,
    height,
    sourceAspectRatio: ratio,
    targetAspectRatio: targetRatio,
    fit,
  };
}

export function buildAspectSafeImageRequests({
  imageObjectId,
  url,
  targetElement,
  sourceAspectRatio,
  targetBounds = targetElement?.boundsPt,
  fit = "contain",
  alignX = 0.5,
  alignY = 0.5,
} = {}) {
  assert(typeof imageObjectId === "string" && imageObjectId.length > 0, "imageObjectId is required", "imageObjectId");
  assert(typeof url === "string" && url.length > 0, "url is required", "url");
  assert(targetElement?.kind === "image", "targetElement must be an image", "targetElement");
  const sourceRatio = finitePositive(sourceAspectRatio);
  assert(sourceRatio, "sourceAspectRatio must be positive", "sourceAspectRatio");
  const targetRatio = aspectRatio(targetBounds?.width, targetBounds?.height);
  assert(targetRatio, "targetBounds must have positive width and height", "targetBounds");
  assert(["contain", "cover"].includes(fit), "fit must be contain or cover", "fit");
  if (fit === "cover") {
    const visibleAreaFraction = Math.min(sourceRatio / targetRatio, targetRatio / sourceRatio);
    return {
      requests: [{ replaceImage: { imageObjectId, url, imageReplaceMethod: "CENTER_CROP" } }],
      analysis: { fit, sourceAspectRatio: sourceRatio, targetAspectRatio: targetRatio, visibleAreaFraction, expectedCropFraction: 1 - visibleAreaFraction },
    };
  }
  const rect = fitRect({ sourceAspectRatio: sourceRatio, targetBounds, fit: "contain", alignX, alignY });
  return {
    requests: [{ replaceImage: { imageObjectId, url, imageReplaceMethod: "CENTER_INSIDE" } }],
    analysis: { ...rect, expectedCropFraction: 0 },
  };
}

export function buildAspectSafeVideoRequest({ objectId, source, id, pageObjectId, sourceAspectRatio, targetBounds, alignX = 0.5, alignY = 0.5 } = {}) {
  const rect = fitRect({ sourceAspectRatio, targetBounds, fit: "contain", alignX, alignY });
  return {
    createVideo: {
      objectId,
      source,
      id,
      elementProperties: {
        pageObjectId,
        size: {
          width: { magnitude: rect.width, unit: "PT" },
          height: { magnitude: rect.height, unit: "PT" },
        },
        transform: { scaleX: 1, scaleY: 1, translateX: rect.x, translateY: rect.y, unit: "PT" },
      },
    },
  };
}

function pngDimensions(bytes) {
  if (bytes.length < 24 || bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") return null;
  return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), mimeType: "image/png", animated: false };
}

function gifDimensions(bytes) {
  if (bytes.length < 10 || !["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) return null;
  return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8), mimeType: "image/gif", animated: true };
}

function jpegDimensions(bytes) {
  if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
  let offset = 2;
  while (offset + 9 < bytes.length) {
    if (bytes[offset] !== 0xff) { offset += 1; continue; }
    const marker = bytes[offset + 1];
    if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
      return { width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5), mimeType: "image/jpeg", animated: false };
    }
    const length = bytes.readUInt16BE(offset + 2);
    if (!Number.isInteger(length) || length < 2) break;
    offset += 2 + length;
  }
  return null;
}

export function imageMetadataFromBytes(bytes) {
  assert(Buffer.isBuffer(bytes), "image bytes must be a Buffer", "bytes");
  const metadata = pngDimensions(bytes) ?? gifDimensions(bytes) ?? jpegDimensions(bytes);
  assert(metadata && finitePositive(metadata.width) && finitePositive(metadata.height), "unsupported or invalid PNG, JPEG, or GIF", "bytes");
  return {
    ...metadata,
    aspectRatio: metadata.width / metadata.height,
    bytes: bytes.length,
    sha256: createHash("sha256").update(bytes).digest("hex"),
  };
}

export async function inspectImageFiles(paths = []) {
  const assets = [];
  for (const path of paths) {
    assert(typeof path === "string" && path.length > 0, "image path must be a non-empty string", "paths");
    const bytes = await readFile(path);
    assets.push({ path, ...imageMetadataFromBytes(bytes) });
  }
  return { version: VERSION, kind: "slides-media-assets", assets };
}

export function mediaAssetMap(manifest) {
  if (!isRecord(manifest) || manifest.kind !== "slides-media-assets" || !Array.isArray(manifest.assets)) return new Map();
  return new Map(manifest.assets.map((asset) => [asset.path, asset]));
}

export { RATIO_TOLERANCE };

SHA-256: 4d0930a9d2bec3ed29f10a19c430a319dc450bc087c96dbb47239cd5171666b6