BoxLang 🚀 A New JVM Dynamic Language Learn More...
A CFML wrapper around the Scrimage Java library
(com.sksamuel.scrimage:scrimage-core: 4.5.4). It does
the usual image work (resize, crop, rotate, scale, filters,
watermarks, format conversion) plus WebP read/write, which is the main
thing you can't get out of cf-Thumbnailator.
The API has two shapes. There's a mutable fluent builder for chained operations, and one-shot helpers for the everyday "resize this JPEG to that size and write it back" calls. Pick whichever fits the call site.
Optional capability buckets let you start with a 2 MB minimum footprint and add WebP, filters, extra formats, or rich EXIF metadata only when you actually need them.
Java 11 is required. Scrimage 4.x is compiled for
Java 11+. Adobe CF 2016 ships with Java 8, which throws
UnsupportedClassVersionError the moment the Scrimage JARs
load, so you need to point CF 2016 at a Java 11+ JRE. Most admins swap
in OpenJDK 11 or 17 anyway for security. The bundled CommandBox server
profiles take care of this: server-cf2016.json,
server-cf2023.json, and server.json (CF
2021) all use OpenJDK 11, and server-cf2025.json uses
OpenJDK 21. CF 2018 and newer typically already run on Java 11.
BoxLang 1.13+ needs Java 21, which server-boxlang.json
sets via "javaVersion":
"openjdk21_jre". BoxLang also loads JARs differently
from ACF. Scrimage JARs go in BoxLang's runtime lib directory
(WEB-INF/boxlang/lib/) instead of through
this.javaSettings.loadPaths. Application.cfc
sniffs the engine and skips loadPaths on BoxLang and
Lucee when the JARs are already there. The per-engine details are in
docs/boxlang-notes.md and docs/lucee-notes.md.
CF 2025 needs one extra thing because of Java 21's stricter module
system: a list of --add-opens JVM args so ACF can reflect
into the JDK's ImageIO classes. The bundled
server-cf2025.json ships with that list baked in; if
you're running CF 2025 outside CommandBox, copy the
JVM.args value into your own startup config.
box install cf-scrimage
CommandBox fetches the wrapper CFC from ForgeBox. It doesn't pull the Scrimage JARs. You supply those separately. See below.
Drop the JARs into lib/ next to
Application.cfc, or set SCRIMAGE_JAR_DIR to
the directory that contains them. The wrapper resolves JARs from one
of three places in order:
SCRIMAGE_JAR_PATH env var or system property (full path
to a single JAR)SCRIMAGE_JAR_DIR env var or system property (directory
containing the JARs)./lib/ next to Application.cfc
Which JARs you need depends on which capability buckets you want.
Start with the Minimum bucket (three JARs, about 2 MB) and add from
there. See docs/capabilities.md for the per-bucket JAR
list with Maven coordinates and direct download URLs.
The buckets are additive. Drop in only the JARs for what you're going to use.
| Bucket | Size | Unlocks |
|---|---|---|
| Minimum | ~2 MB | Resize, crop, rotate, scale, watermark, JPEG/PNG/GIF read/write. No rich EXIF metadata. |
| + Rich metadata | +1 MB | Full EXIF/IPTC/XMP in inspect(). |
| + Robust JPEG/PNG | +1.5 MB | CMYK JPEGs, Adobe JPEG variants, EXIF-rotated JPEGs decoded cleanly. |
| + Extra formats | +2 MB | TIFF, PCX, PNM, TGA, IFF, SGI read/write; enhanced BMP. |
| + Filters | +1.3 MB | filter()
builder op, applyFilter() one-shot, 46 named filters. |
| + WebP / animated | +21 MB | WebP read/write,
animated WebP, gifToWebp(). Most of the size is
bundled native binaries. |
Each bucket above the minimum is capability-gated. Methods that
depend on a missing bucket still exist on the API surface, but they
throw Scrimage.MissingDependency with a message listing
exactly which JARs are needed.
See docs/capabilities.md for the full JAR list and
download URLs.
scrim = new Scrimage();
// Resize preserving aspect ratio
r = scrim.resize("photo.jpg", "small.jpg", 320, 240);
// r.ok, r.width, r.height, r.sizeBytes, r.format, r.durationMs
// Center crop to exact dimensions
r = scrim.cropImage("photo.jpg", "thumb.jpg", 200, 200);
// Convert to WebP (requires WebP capability)
r = scrim.convertFormat("photo.jpg", "photo.webp", "webp");
scrim = new Scrimage();
// Resize, apply sepia filter, write as WebP
scrim.of("photo.jpg")
.size(320, 240)
.filter("sepia")
.outputQuality(0.85)
.toFile("out.webp");
// Rotate and watermark, return result struct
r = scrim.of("photo.jpg")
.rotate(90)
.watermark("logo.png", "bottom_right", 0.5, 10)
.toFile("stamped.jpg");
scrim = new Scrimage();
img = scrim.load("photo.jpg");
// img.width(), img.height(), img.hasAlpha()
r = img.bound(320, 240).rotate(90).output("out.jpg");
// r.ok, r.width, r.height, r.sizeBytes, r.format, r.durationMs
// Each operation returns a new handle; the original is unchanged
img2 = img.filter("sepia"); // requires Filters capability
Each one-shot returns a result struct: ["ok": true,
"destPath": ..., "width": ...,
"height": ..., "sizeBytes": ...,
"format": ..., "durationMs": ...,
"capabilitiesUsed": [...]].
| Method | Signature | Notes |
|---|---|---|
resize
| (srcPath, destPath, width, height, opts)
| Aspect-preserving by default |
scaleImage
| (srcPath, destPath, factor, opts)
| Uniform scale multiplier |
rotateImage
| (srcPath, destPath, degrees, opts)
| Clockwise; negative goes the other way |
cropImage
| (srcPath, destPath, width, height, positionName, opts)
| positionName defaults to "center"
|
watermarkImage
| (srcPath, destPath, wmPath, positionName, opacity,
insets, opts)
| opacity 0..1; insets in pixels |
convertFormat
| (srcPath, destPath, formatName, opts)
| Keeps dimensions, changes encoding |
createThumbnail
| (srcPath, destPath, width, height, opts)
| Forces JPEG out, quality 0.85, EXIF orientation honored |
batchResize
| (srcDir, destDir, width, height, opts)
| Returns ["results": [],
"count": ..., "totalMs": ...,
"totalBytes": ...]
|
applyFilter
| (srcPath, destPath, filterName, filterArgs, opts)
| Requires Filters capability; filterArgs
array overrides defaults |
applyTransform
| (srcPath, destPath, transformName, transformArgs, opts)
| Two transforms: background_gradient, dominant_gradient
|
compositeImages
| (srcPath, destPath, otherImagePath, compositeName,
alpha, opts)
| Blend-mode merge; 21 modes available |
gifToWebp
| (srcGif, destWebp, opts)
| Requires WebP capability; returns ["ok",
"destPath", "sizeBytes",
"format", "durationMs"]
|
inspect
| (srcPath)
| Returns width, height, format, sizeBytes, hasAlpha,
exifOrientation. Adds metadata struct when Rich
metadata capability is present. |
The opts struct for
resize/scale/rotate/crop/watermark/convert/createThumbnail accepts:
quality, scaleMethod,
useExifOrientation, allowOverwrite,
outputFormat, outputFormatType,
keepAspectRatio, exifPassthrough. Only keys
you set get applied.
exifPassthrough defaults to false. When you
set it to true, the wrapper copies the source JPEG's
APP1/Exif segment into the destination JPEG after writing and forces
the Orientation tag back to 1 so downstream
viewers don't double-rotate the image. Only meaningful when both
source and destination are JPEG. Silently skipped for other format
combinations or when the source has no EXIF segment.
scrim.resize("photo.jpg", "small.jpg", 320, 240, ["exifPassthrough": true]);
// Make, Model, DateTimeOriginal, GPS tags all survive. Orientation is reset to 1.
Setters return this. Terminals run the operation and
return data.
Source:
of(srcPath | array | directory)
Sizing:
size(width, height) scale down to fit, preserving aspect ratio
forceSize(width, height) scale to exact dimensions regardless of aspect
width(value) set width, height calculated from aspect ratio
height(value) set height, width calculated from aspect ratio
scale(factor) uniform scale multiplier
keepAspectRatio(true|false) when false, size() behaves like forceSize()
Geometry:
rotate(degrees) clockwise; 90/180/270 use lossless Java ops
flipX() horizontal mirror
flipY() vertical mirror
crop(positionName) crop at position; size() provides the target dimensions
sourceRegion(x, y, w, h) rectangular subimage by pixel coords
sourceRegion(posName, w, h) positioned crop
pad(size, color) add equal padding on all sides; color is hex (default black)
autocrop(colorTolerance) remove solid-color borders; 0 = transparent, >0 = black with tolerance
trim(pixels) trim N pixels from all sides
zoom(factor) centered zoom
bound(width, height) scale down to fit within box, preserving aspect ratio
cover(width, height, pos) scale and crop to cover exact dimensions
fill(width, height, color) fit into box, padding empty space with color
Adjustments:
brightness(factor) 1.0 = unchanged; >1 = brighter; <1 = darker
contrast(factor) 1.0 = unchanged
Composition:
watermark(wmPath, posName, opacity) overlay watermark at position, opacity 0..1
watermark(wmPath, posName, opacity, insets) same with pixel inset from edge
overlay(overlayPath, posName) overlay image without opacity blending
Filters (requires Filters capability):
filter(name) apply a named filter; see filter table below
filter(name, args) apply a named filter with custom constructor args
Transforms:
transform(name) apply a named transform; see transform table below
transform(name, args) apply a named transform with dimension overrides (background_gradient only)
Composites (blend-mode merge with a second image):
composite(name, otherPath, alpha) blend this image with otherPath using the named blend mode; alpha 0..1
Output controls:
outputFormat(name) override format (overrides dest-path extension)
outputQuality(0..1) encoder quality for JPEG and WebP
outputFormatType(subtype) format-specific subtype string
useOriginalFormat() force output to match source format
scaleMethod(name) scaling algorithm; see scale-method table below
allowOverwrite(true|false) default true
useExifOrientation(true|false) honor EXIF orientation on read; default false
Terminals:
toFile(destPath) -> result struct (ok, destPath, width, height, sizeBytes, format, durationMs, capabilitiesUsed)
toFiles(destDir, prefix) -> array of result structs; processes each source from of(array|dir)
toBytes(format) -> java byte[]
asBufferedImage() -> java.awt.image.BufferedImage
asImage() -> ScrimageImage handle wrapping the result
load(srcPath) -> ScrimageImage handle for the source file (fluent side not used)
The builder is reusable: setters accumulate into an internal op list and terminals replay them. You can call a terminal more than once on the same chain.
scrim.load(srcPath) returns a ScrimageImage. Operations
return a new ScrimageImage; the original is not modified.
Properties (no arguments):
width() numeric
height() numeric
hasAlpha() boolean
bufferedImage() java.awt.image.BufferedImage
Sizing operations:
resize(width, height, scaleMethod) scale to exact dimensions
bound(width, height) scale down to fit within box (same as size() with keepAspectRatio=true)
scaleTo(width, height, scaleMethod) alias for resize()
fit(width, height, color) fit into box, pad with color
cover(width, height, positionName) scale and crop to cover exact dimensions
Geometry operations:
crop(x, y, width, height) rectangular subimage by pixel coords
rotate(degrees) clockwise
flipX()
flipY()
pad(size, color)
autocrop(tolerance)
trim(pixels)
zoom(factor)
Adjustments:
brightness(factor)
contrast(factor)
Composition:
overlay(overlayPath, positionName) overlay without opacity blending
Filters (requires Filters capability):
filter(filterName)
filter(filterName, args) override default constructor args
Transforms:
transform(name)
transform(name, args) args struct: {width: N, height: N} for background_gradient
Composites:
composite(name, otherImagePath, alpha)
Terminals:
output(destPath, opts) -> result struct (ok, destPath, width, height, sizeBytes, format, durationMs)
opts for output() accepts
quality to override the encoder quality for JPEG and WebP.
center
top_left top_center top_right
left_center right_center
bottom_left bottom_center bottom_right
Used by crop, sourceRegion,
watermark, overlay, cover,
fill, and ScrimageImage.cover().
| Name | Algorithm |
|---|---|
default
| Lanczos3 (alias) |
quality
| Lanczos3 (alias) |
speed
| FastScale (alias) |
fast_scale
| FastScale |
bicubic
| Bicubic |
bilinear
| Bilinear |
progressive_bilinear
| Progressive |
bspline
| BSpline |
lanczos3
| Lanczos3 |
46 filters are registered. Pass the name string to
filter(), applyFilter(), or ScrimageImage.filter().
No-arg (default constructor):
blur bump chrome contour crystallize
diffuse dither edge emboss gain_bias
glow gotham grayscale hsb invert
kaleidoscope lens_blur lens_flare nashville prewitt
rgb roberts rylanders sepia sharpen
sobel solarize sparkle summer swim
vintage
Single-arg with default (override with filter(name, [value])):
black_threshold default 50.0 (double, range 0..100)
border default 5 (int, thickness in pixels)
gaussian default 3 (int, radius)
noise default 25 (int)
oil default 4 (int)
pixelate default 10 (int, block size)
posterize default 4 (int, levels)
quantize default 16 (int, colors)
threshold default 127 (int, 0..255)
twirl default 1.0 (float, angle in radians)
Multi-arg (override with filter(name, [v1, v2, ...])):
colorize default [255, 0, 0] (int r, int g, int b)
motion_blur default [5.0, 0.0] (double distance, double angle)
String-arg (override with filter(name, ["text"])):
watermark_cover default "" (string text)
watermark_stamp default "" (string text)
Two transforms are available. They ship in scrimage-core so no extra JAR is needed.
| Name | Constructor | Notes |
|---|---|---|
dominant_gradient
| no-arg | Replaces image pixels with a gradient derived from the dominant colors |
background_gradient
| (width, height)
| Builds a gradient underlay sized to the image (or pass
args struct with
width/height keys) |
Builder:
scrim.of("photo.jpg").size(400, 300).transform("dominant_gradient").toFile("gradient.jpg");
scrim.of("photo.jpg").size(400, 300).transform("background_gradient").toFile("bg.jpg");
One-shot:
scrim.applyTransform("photo.jpg", "out.jpg", "dominant_gradient");
Immutable handle:
scrim.load("photo.jpg").bound(400, 300).transform("dominant_gradient").output("out.jpg");
21 blend modes. The blend is between the current pipeline image
(destination) and a second image (source). Both are normalized to
TYPE_INT_ARGB before blending. alpha
controls blend strength (0.0..1.0).
| Name | Java class |
|---|---|
average
| AverageComposite |
blue
| BlueComposite |
color
| ColorComposite |
color_burn
| ColorBurnComposite |
color_dodge
| ColorDodgeComposite |
diff
| DifferenceComposite |
glow
| GlowComposite |
green
| GreenComposite |
hard_light
| HardLightComposite |
heat
| HeatComposite |
hue
| HueComposite |
lighten
| LightenComposite |
luminosity
| LuminosityComposite |
multiply
| MultiplyComposite |
negation
| NegationComposite |
overlay
| OverlayComposite |
red
| RedComposite |
reflect
| ReflectComposite |
saturation
| SaturationComposite |
screen
| ScreenComposite |
subtract
| SubtractComposite |
Note: overlay here is the blend mode
(OverlayComposite). The existing positional
overlay() builder method (which places one image on top
of another at a named position) is separate and unchanged.
Builder:
scrim.of("photo.jpg")
.size(400, 300)
.composite("multiply", "texture.jpg", 1.0)
.toFile("blended.jpg");
One-shot:
scrim.compositeImages("photo.jpg", "out.jpg", "texture.jpg", "screen", 0.8);
Immutable handle:
scrim.load("photo.jpg").bound(400, 300).composite("multiply", "texture.jpg", 1.0).output("out.jpg");
Always available (Minimum bucket): jpg (alias
jpeg), png, gif.
Requires Minimum bucket - note that bmp write requires
the Extra formats bucket (BmpWriter lives in scrimage-formats-extra): bmp.
Requires Extra formats bucket: tiff, pnm,
pcx, tga, sgi, iff.
Requires WebP bucket: webp.
The scrimage-webp JAR bundles native cwebp,
dwebp, and gif2webp binaries for:
On first use, Scrimage extracts the platform-appropriate binaries to
java.io.tmpdir and runs them as child processes. The CF
server has to be allowed to fork external processes. Most ACF, Lucee,
and BoxLang installations are. Locked-down sandbox hosts often aren't,
and that's where this breaks.
If your architecture isn't in the list above (ARM Windows,
Alpine/musl Linux, and so on), point Scrimage at your own
cwebp/dwebp/gif2webp binaries:
-Dcom.sksamuel.scrimage.webp.binary.dir=/path/to/binaries
scrim.setWebpBinaryDir("/path/to/binaries")
Heads up on option 2: if the JVM property was already set at startup, it's locked and the runtime call silently does nothing. Set it at startup when you can.
First call adds roughly 10-50 ms for the extraction; everything after that reuses the extracted binaries. Each WebP write spawns its own process, which is the documented Scrimage behavior.
Run scrim.verifyWebpNative() once on app boot to confirm
the binaries actually execute on this host. It returns
true on success and throws
Scrimage.WebpBinaryError on failure, so you can fail fast
instead of finding out at the first WebP upload.
scrim = new Scrimage();
info = scrim.inspect("photo.jpg");
// info.width, info.height, info.format, info.sizeBytes, info.hasAlpha, info.exifOrientation
exifOrientation is the raw EXIF tag value (1..8) or 0 if
the image has no EXIF orientation marker.
When the Rich metadata capability (metadata-extractor JAR) is
present, inspect() also returns
info.metadata - a struct keyed by directory name (e.g.
"Exif IFD0", "IPTC",
"XMP"), each containing an array of
["name": tagName, "value":
tagValue] structs.
scrim = new Scrimage();
caps = scrim.capabilities();
// caps.summary - array of capability names that are present
// caps.missing - array of capability names that are absent
// caps.details - struct with per-capability probe results
// caps.javaVendor - e.g. "Eclipse Adoptium"
// caps.javaVersion - e.g. "11.0.25"
Capability names: core, metadataExtractor,
commonsIo, pngj,
twelveMonkeysCore, twelveMonkeysJpeg,
formatsExtra, filters,
commonsLang3, slf4j, webp.
core is fatal - init() throws
Scrimage.MissingDependency immediately if it is absent.
All other capabilities degrade gracefully.
All exception types live under the Scrimage.* namespace.
| Type | When |
|---|---|
Scrimage.MissingDependency
| Capability check failed. message lists the
JAR(s) needed; detail shows the probed class. |
Scrimage.SourceNotFound
| Source file or directory missing or unreadable. |
Scrimage.UnknownFormat
| outputFormat name not in the format table
for the available capabilities. |
Scrimage.UnknownPosition
| Position name not in the table. |
Scrimage.UnknownScalingMethod
| Scale-method name not in the table. |
Scrimage.UnknownFilter
| Filter name not in the table (thrown when Filters
capability is present; otherwise MissingDependency
fires first). |
Scrimage.UnknownTransform
| Transform name not in the table. |
Scrimage.UnknownComposite
| Composite name not in the table. |
Scrimage.OverwriteBlocked
| Destination file exists and
allowOverwrite(false) is set. |
Scrimage.InvalidArgument
| Numeric argument out of range (quality outside 0..1, opacity outside 0..1, etc.). |
Scrimage.IOError
| Wraps java.io.IOException from the JAR or writer. |
Scrimage.UnsupportedImage
| Source unreadable or wrong format for the codec. |
Scrimage.WebpBinaryError
| cwebp/dwebp/gif2webp process failed. detail
carries stderr from the binary when available. |
Each throw carries message and detail. The
type attribute is the value from the table, so you can
cfcatch on the specific case:
try {
r = scrim.of("photo.jpg").size(320, 240).toFile("out.webp");
} catch (Scrimage.MissingDependency e) {
writeOutput("Install: " & e.message);
} catch (Scrimage.WebpBinaryError e) {
writeOutput("WebP binary failed: " & e.detail);
}
box server start serverConfigFile=server.json
Then open http://localhost:8790/demo.cfm. The demo page
shows canned examples for each capability bucket and a sandbox form
where you can pick an operation, adjust inputs, and see the source and
result side by side with the CFC code that produced each one.
The Lucee and BoxLang profiles use the same command with
serverConfigFile=server-lucee.json or serverConfigFile=server-boxlang.json.
Start whichever server profile you want to test against, then open
/tests/index.cfm in a browser. The page prints
PASS/FAIL/PENDING lines for each assertion and a summary at the
bottom. The response returns HTTP 500 if anything failed, so you can
wire it into CI without parsing the HTML.
Tests are plain .cfm files with a tiny
assert() helper. No TestBox, no MXUnit, nothing to
install. Capability-gated tests skip cleanly when their JARs are
absent (you see PENDING instead of FAIL), which means you can develop
against the Minimum bucket without fighting red builds.
MIT. See LICENSE.
Scrimage 4.5.4 is licensed under Apache 2.0. The JARs in
lib/ carry their upstream licenses intact.
$
box install cf-scrimage