purgetss
Version:
A package that simplifies mobile app creation for Titanium developers.
265 lines (237 loc) • 10.4 kB
JavaScript
/**
* PurgeTSS - gen-scales
*
* For a single source image, generate the 8 Titanium multi-density variants:
*
* Android (5 densities, each in its own mipmap/drawable folder):
* res-mdpi = 1/4 of source (1× baseline)
* res-hdpi = 1.5/4 of source
* res-xhdpi = 2/4 of source (2×)
* res-xxhdpi = 3/4 of source (3×)
* res-xxxhdpi = full source (4×, maximum density)
*
* iPhone (3 scales in one folder, suffix in filename):
* @1x = 1/4 of source
* @2x = 2/4 of source
* @3x = 3/4 of source
*
* Convention inherited from Titanium Alloy: source images are treated as
* 4× (xxxhdpi/@4x) masters, and all other scales are derived from them.
*
* @fileoverview Scale a source image into the 8 Titanium density variants
* @author César Estrada
*/
import fs from 'fs'
import path from 'path'
import sharp from 'sharp'
import { logger } from '../branding/branding-logger.js'
import { computeSvgDensity, readSvgSafely } from '../../shared/svg-utils.js'
/**
* Read source image: for SVGs, uses readSvgSafely (buffer + metadata + viewBox
* warning); for raster images, reads metadata directly from the file path.
*
* @returns {Promise<{path: string, meta: Object, isSvg: boolean, buffer: Buffer|null}>}
*/
async function readSource(sourceFile) {
const isSvg = path.extname(sourceFile).toLowerCase() === '.svg'
if (isSvg) {
const { buffer, meta } = await readSvgSafely(sourceFile, { logger })
return { path: sourceFile, meta, isSvg: true, buffer }
}
const meta = await sharp(sourceFile).metadata()
return { path: sourceFile, meta, isSvg: false, buffer: null }
}
/**
* Build a Sharp pipeline for the requested scale. For SVG sources, density is
* computed so the rasterization lands at ~2× target — avoiding the pixel limit
* regardless of viewBox size.
*/
function buildScalePipeline(src, targetMax) {
if (src.isSvg) {
const naturalMax = Math.max(src.meta.width, src.meta.height)
// 2× target for antialiasing headroom before Sharp's downsample.
const density = computeSvgDensity(naturalMax, targetMax * 2)
return sharp(src.buffer, { density, limitInputPixels: false })
}
return sharp(src.path)
}
export const ANDROID_SCALES = Object.freeze([
{ name: 'res-mdpi', factor: 1 / 4 },
{ name: 'res-hdpi', factor: 1.5 / 4 },
{ name: 'res-xhdpi', factor: 2 / 4 },
{ name: 'res-xxhdpi', factor: 3 / 4 },
{ name: 'res-xxxhdpi', factor: 4 / 4 }
])
export const IPHONE_SCALES = Object.freeze([
{ suffix: '', factor: 1 / 4 },
{ suffix: '@2x', factor: 2 / 4 },
{ suffix: '@3x', factor: 3 / 4 }
])
// Resolve target dimensions for a single scale.
// `factor * 4` recovers the integer multiplier (1, 1.5, 2, 3, 4 for Android;
// 1, 2, 3 for iPhone) only because every entry in *_SCALES is normalized to
// n/4 with the largest scale (xxxhdpi/@4x) at 4/4. If a future density is
// added beyond xxxhdpi, this conversion factor needs to be revisited.
//
// `baseHeight` pins the height explicitly (e.g. SVG pipeline resolved both
// w-* and h-* to numbers); when omitted, height follows the source aspect.
function computeScaleTarget(srcMeta, factor, baseWidth, baseHeight) {
// No pin in either direction → fall back to the source as the 4× master.
if (baseWidth == null && baseHeight == null) {
return {
targetWidth: Math.max(1, Math.round(srcMeta.width * factor)),
targetHeight: Math.max(1, Math.round(srcMeta.height * factor))
}
}
const multiplier = factor * 4
const widthOverHeight = srcMeta.width > 0 && srcMeta.height > 0
? srcMeta.width / srcMeta.height
: 1
const heightOverWidth = srcMeta.width > 0 && srcMeta.height > 0
? srcMeta.height / srcMeta.width
: 1
if (baseWidth != null) {
const targetWidth = Math.max(1, Math.round(baseWidth * multiplier))
const targetHeight = baseHeight != null
? Math.max(1, Math.round(baseHeight * multiplier))
: Math.max(1, Math.round(baseWidth * multiplier * heightOverWidth))
return { targetWidth, targetHeight }
}
// Height pinned, width derived from inverse aspect.
const targetHeight = Math.max(1, Math.round(baseHeight * multiplier))
const targetWidth = Math.max(1, Math.round(baseHeight * multiplier * widthOverHeight))
return { targetWidth, targetHeight }
}
// Hard ceiling for any individual PNG output. Mirrors the constant exported
// from the SVG pipeline; centralizing it here would create a cycle, so we keep
// a local copy and rely on derive-dimensions to enforce the dp-side budget.
const MAX_OUTPUT_PIXELS = 4096
/**
* Scale a source image into all Android density variants.
*
* @param {string} sourceFile - Absolute path to source image
* @param {string} relPath - Path inside the source root (e.g. 'buttons/btn.png')
* @param {string} androidBaseDir - e.g. <project>/app/assets/android/images
* @param {Object} opts
* @param {string|null} [opts.format] - 'webp'|'jpeg'|'png'|null (null = keep original)
* @param {number} [opts.quality=85]
* @returns {Promise<string[]>} Paths written
*/
export async function genAndroidScales(sourceFile, relPath, androidBaseDir, opts = {}) {
const { format = null, quality = 85, baseWidth = null, baseHeight = null, opacity = null, padding = null } = opts
const src = await readSource(sourceFile)
const written = []
for (const { name, factor } of ANDROID_SCALES) {
const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth, baseHeight)
assertWithinCap(targetWidth, targetHeight, sourceFile, name)
const outDir = path.join(androidBaseDir, name, path.dirname(relPath))
fs.mkdirSync(outDir, { recursive: true })
const outPath = path.join(outDir, renameWithFormat(path.basename(relPath), format, src.isSvg))
await writeScaled(src, outPath, targetWidth, targetHeight, format, quality, opacity, padding)
written.push(outPath)
}
return written
}
/**
* Scale a source image into all iPhone scale variants.
*
* @param {string} sourceFile - Absolute path to source image
* @param {string} relPath - Path inside the source root (e.g. 'buttons/btn.png')
* @param {string} iphoneBaseDir - e.g. <project>/app/assets/iphone/images
* @param {Object} opts - Same shape as genAndroidScales
* @returns {Promise<string[]>} Paths written
*/
export async function genIphoneScales(sourceFile, relPath, iphoneBaseDir, opts = {}) {
const { format = null, quality = 85, baseWidth = null, baseHeight = null, opacity = null, padding = null } = opts
const src = await readSource(sourceFile)
const written = []
const parsed = path.parse(relPath)
const outDir = path.join(iphoneBaseDir, parsed.dir)
fs.mkdirSync(outDir, { recursive: true })
for (const { suffix, factor } of IPHONE_SCALES) {
const { targetWidth, targetHeight } = computeScaleTarget(src.meta, factor, baseWidth, baseHeight)
assertWithinCap(targetWidth, targetHeight, sourceFile, suffix || '@1x')
// SVG sources can't be written as SVG by Sharp — fall back to PNG if the
// user didn't specify an explicit output format.
const ext = format ? `.${format}` : (src.isSvg ? '.png' : parsed.ext)
const outName = `${parsed.name}${suffix}${ext}`
const outPath = path.join(outDir, outName)
await writeScaled(src, outPath, targetWidth, targetHeight, format, quality, opacity, padding)
written.push(outPath)
}
return written
}
function assertWithinCap(width, height, sourceFile, label) {
if (width > MAX_OUTPUT_PIXELS || height > MAX_OUTPUT_PIXELS) {
throw new Error(
`${path.basename(sourceFile)} at ${label} would render ${width}×${height}px, ` +
`which exceeds the ${MAX_OUTPUT_PIXELS}px cap. Reduce the resolved width or override it manually in config.cjs > images.files.`
)
}
}
function renameWithFormat(filename, format, isSvg = false) {
if (format) {
const parsed = path.parse(filename)
return `${parsed.name}.${format}`
}
// SVG masters can't be written back as SVG by Sharp — coerce to PNG.
if (isSvg) {
const parsed = path.parse(filename)
return `${parsed.name}.png`
}
return filename
}
async function writeScaled(src, outPath, width, height, format, quality, opacity, paddingPct) {
// Padding shrinks the rendered image inside the same canvas so each density
// gets symmetric transparent borders. Computed from the canvas dimensions so
// the visual ratio (e.g. 15%) is identical across every density variant.
const padX = paddingPct ? Math.floor(width * paddingPct / 100) : 0
const padY = paddingPct ? Math.floor(height * paddingPct / 100) : 0
const innerW = Math.max(1, width - 2 * padX)
const innerH = Math.max(1, height - 2 * padY)
const targetMax = Math.max(innerW, innerH)
let pipeline = buildScalePipeline(src, targetMax).resize({
width: innerW,
height: innerH,
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
if (padX > 0 || padY > 0) {
pipeline = pipeline.extend({
top: padY,
bottom: padY,
left: padX,
right: padX,
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
}
// Apply opacity by multiplying the dest alpha against a uniform-alpha tile.
// `dest-in` keeps RGB and multiplies dest alpha by source alpha (opacity/100).
if (opacity != null && opacity < 100) {
pipeline = pipeline
.ensureAlpha()
.composite([{
input: Buffer.from([255, 255, 255, Math.round(255 * opacity / 100)]),
raw: { width: 1, height: 1, channels: 4 },
tile: true,
blend: 'dest-in'
}])
}
// For SVG sources without an explicit format, coerce output to PNG
// (Sharp cannot write SVG).
const fallbackExt = src.isSvg ? 'png' : path.extname(src.path).slice(1).toLowerCase()
const fmt = format || fallbackExt
pipeline = applyFormat(pipeline, fmt === 'jpg' ? 'jpeg' : fmt, quality)
await pipeline.toFile(outPath)
}
function applyFormat(pipeline, format, quality) {
switch (format) {
case 'png': return pipeline.png({ compressionLevel: 9 })
case 'webp': return pipeline.webp({ quality })
case 'avif': return pipeline.avif({ quality })
case 'tiff': return pipeline.tiff({ quality, compression: 'lzw' })
case 'gif': return pipeline.gif()
case 'jpeg': return pipeline.flatten({ background: '#ffffff' }).jpeg({ quality })
default: return pipeline
}
}