Skip to content

Commit

Permalink
Merge branch 'main' into always-rotate
Browse files Browse the repository at this point in the history
  • Loading branch information
zachleat authored Jan 15, 2024
2 parents 644b093 + 0d6d4b3 commit e53f71b
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 23 deletions.
6 changes: 4 additions & 2 deletions generate-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const DEFAULT_ATTRIBUTES = {
// decoding: "async",
};

const LOWSRC_FORMAT_PREFERENCE = ["jpeg", "png", "svg", "webp", "avif"];
const LOWSRC_FORMAT_PREFERENCE = ["gif", "jpeg", "png", "svg", "webp", "avif"];

/*
Returns:
Expand Down Expand Up @@ -148,7 +148,9 @@ function generateHTML(metadata, attributes = {}, options = {}) {
if(!Array.isArray(obj[tag])) {
markup.push(mapObjectToHTML(tag, obj[tag]));
} else {
markup.push(`<${tag}>`);
// <picture>
markup.push(mapObjectToHTML(tag, options.pictureAttributes || {}));

for(let child of obj[tag]) {
let childTagName = Object.keys(child)[0];
markup.push((!isInline ? " " : "") + mapObjectToHTML(childTagName, child[childTagName]));
Expand Down
107 changes: 89 additions & 18 deletions img.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { createHash } = require("crypto");
const {default: PQueue} = require("p-queue");
const getImageSize = require("image-size");
const sharp = require("sharp");
const brotliSize = require("brotli-size");
const debug = require("debug")("EleventyImg");

const svgHook = require("./format-hooks/svg");
Expand All @@ -19,8 +20,11 @@ const globalOptions = {
concurrency: 10,
urlPath: "/img/",
outputDir: "img/",
svgShortCircuit: false, // skip raster formats if SVG input is found
// true to skip raster formats if SVG input is found
// "size" to skip raster formats if larger than SVG input
svgShortCircuit: false,
svgAllowUpscale: true,
svgCompressionSize: "", // "br" to report SVG `size` property in metadata as Brotli compressed
// overrideInputFormat: false, // internal, used to force svg output in statsSync et al
sharpOptions: {}, // options passed to the Sharp constructor
sharpWebpOptions: {}, // options passed to the Sharp webp output method
Expand Down Expand Up @@ -59,8 +63,11 @@ const globalOptions = {

hashLength: 10, // Truncates the hash to this length

fixOrientation: false, // always rotate images to ensure correct orientation

// Advanced
useCacheValidityInHash: true,

};

const MIME_TYPES = {
Expand Down Expand Up @@ -155,7 +162,6 @@ class Image {
opts.__originalSize = fs.statSync(this.src).size;
}


return JSON.stringify(opts);
}

Expand Down Expand Up @@ -201,7 +207,7 @@ class Image {
return filtered.sort((a, b) => a - b);
}

static getFormatsArray(formats, autoFormat) {
static getFormatsArray(formats, autoFormat, svgShortCircuit) {
if(formats && formats.length) {
if(typeof formats === "string") {
formats = formats.split(",");
Expand All @@ -220,15 +226,17 @@ class Image {
return format;
});

// svg must come first for possible short circuiting
formats.sort((a, b) => {
if(a === "svg") {
return -1;
} else if(b === "svg") {
return 1;
}
return 0;
});
if(svgShortCircuit !== "size") {
// svg must come first for possible short circuiting
formats.sort((a, b) => {
if(a === "svg") {
return -1;
} else if(b === "svg") {
return 1;
}
return 0;
});
}

// Remove duplicates (e.g., if null happens to coincide with an explicit format
// or a user passes in multiple duplicate values)
Expand Down Expand Up @@ -259,6 +267,40 @@ class Image {
return a.width - b.width;
});
}

let filterLargeRasterImages = this.options.svgShortCircuit === "size";
let svgEntry = byType.svg;
let svgSize = svgEntry && svgEntry.length && svgEntry[0].size;

if(filterLargeRasterImages && svgSize) {
for(let type of Object.keys(byType)) {
if(type === "svg") {
continue;
}

let svgAdded = false;
let originalFormatKept = false;
byType[type] = byType[type].map(entry => {
if(entry.size > svgSize) {
if(!svgAdded) {
svgAdded = true;
// need at least one raster smaller than SVG to do this trick
if(originalFormatKept) {
return svgEntry[0];
}
// all rasters are bigger
return false;
}

return false;
}

originalFormatKept = true;
return entry;
}).filter(entry => entry);
}
}

return byType;
}

Expand Down Expand Up @@ -407,27 +449,37 @@ class Image {
// src is used to calculate the output file names
getFullStats(metadata) {
let results = [];
let outputFormats = Image.getFormatsArray(this.options.formats, metadata.format || this.options.overrideInputFormat);
let outputFormats = Image.getFormatsArray(this.options.formats, metadata.format || this.options.overrideInputFormat, this.options.svgShortCircuit);

if (this.isQuarterTurn(metadata.orientation)) {
[metadata.height, metadata.width] = [metadata.width, metadata.height];
}

if(metadata.pageHeight) {
// When the { animated: true } option is provided to sharp, animated
// image formats like gifs or webp will have an inaccurate `height` value
// in their metadata which is actually the height of every single frame added together.
// In these cases, the metadata will contain an additional `pageHeight` property which
// is the height that the image should be displayed at.
metadata.height = metadata.pageHeight;
}

for(let outputFormat of outputFormats) {
if(!outputFormat || outputFormat === "auto") {
throw new Error("When using statsSync or statsByDimensionsSync, `formats: [null | auto]` to use the native image format is not supported.");
}
if(outputFormat === "svg") {
if((metadata.format || this.options.overrideInputFormat) === "svg") {
let svgStats = this.getStat("svg", metadata.width, metadata.height);

// SVG metadata.size is only available with Buffer input (remote urls)
if(metadata.size) {
// Note this is unfair for comparison with raster formats because its uncompressed (no GZIP, etc)
svgStats.size = metadata.size;
}
results.push(svgStats);

if(this.options.svgShortCircuit) {
if(this.options.svgShortCircuit === true) {
break;
} else {
continue;
Expand Down Expand Up @@ -466,9 +518,20 @@ class Image {
for(let outputFormat in fullStats) {
for(let stat of fullStats[outputFormat]) {
if(this.options.useCache && fs.existsSync(stat.outputPath)){
stat.size = fs.statSync(stat.outputPath).size;
// Cached images already exist in output
let contents;
if(this.options.dryRun) {
stat.buffer = this.getFileContents();
contents = this.getFileContents();
stat.buffer = contents;
}

if(outputFormat === "svg" && this.options.svgCompressionSize === "br") {
if(!contents) {
contents = this.getFileContents();
}
stat.size = brotliSize.sync(contents);
} else {
stat.size = fs.statSync(stat.outputPath).size;
}

outputFilePromises.push(Promise.resolve(stat));
Expand All @@ -480,14 +543,17 @@ class Image {
// Use sharp.rotate to bake orientation into the image (https://github.com/lovell/sharp/blob/v0.32.6/docs/api-operation.md#rotate):
// > If no angle is provided, it is determined from the EXIF data. Mirroring is supported and may infer the use of a flip operation.
// > The use of rotate without an angle will remove the EXIF Orientation tag, if any.
sharpInstance.rotate();
if(this.options.fixOrientation || this.needsRotation(metadata.orientation)) {
sharpInstance.rotate();
}
if(stat.width < metadata.width || (this.options.svgAllowUpscale && metadata.format === "svg")) {
let resizeOptions = {
width: stat.width
};
if(metadata.format !== "svg" || !this.options.svgAllowUpscale) {
resizeOptions.withoutEnlargement = true;
}

sharpInstance.resize(resizeOptions);
}

Expand All @@ -501,7 +567,12 @@ class Image {
if(this.options.formatHooks && this.options.formatHooks[outputFormat]) {
let hookResult = await this.options.formatHooks[outputFormat].call(stat, sharpInstance);
if(hookResult) {
stat.size = hookResult.length;
if(this.options.svgCompressionSize === "br") {
stat.size = brotliSize.sync(hookResult);
} else {
stat.size = hookResult.length;
}

if(this.options.dryRun) {
stat.buffer = Buffer.from(hookResult);
outputFilePromises.push(Promise.resolve(stat));
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@11ty/eleventy-img",
"version": "3.1.1",
"version": "3.1.8",
"description": "Low level utility to perform build-time image transformations.",
"publishConfig": {
"access": "public"
Expand Down Expand Up @@ -38,6 +38,7 @@
"homepage": "https://github.com/11ty/eleventy-img#readme",
"dependencies": {
"@11ty/eleventy-fetch": "^4.0.0",
"brotli-size": "^4.0.0",
"debug": "^4.3.4",
"entities": "^4.5.0",
"image-size": "^1.0.2",
Expand All @@ -48,7 +49,7 @@
"@11ty/eleventy": "^2.0.1",
"@11ty/eleventy-plugin-webc": "^0.11.1",
"ava": "^5.3.1",
"eslint": "^8.49.0",
"eslint": "^8.52.0",
"pixelmatch": "^5.3.0"
},
"ava": {
Expand Down
33 changes: 32 additions & 1 deletion test/test-markup.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,18 @@ test("Image markup (animated gif)", async t => {
test("Image markup (animated gif, two formats)", async t => {
let results = await eleventyImage("./test/earth-animated.gif", {
dryRun: true,
formats: ["tiff", "auto"]
formats: ["webp", "auto"]
});

t.is(generateHTML(results, {
alt: ""
}), `<picture><source type="image/webp" srcset="/img/YQVTYq1wRQ-400.webp 400w"><img alt="" src="/img/YQVTYq1wRQ-400.gif" width="400" height="400"></picture>`);
});

test("Image markup (two formats, neither priority defined)", async t => {
let results = await eleventyImage("./test/earth-animated.gif", {
dryRun: true,
formats: ["tif", "heic"]
});

let e = t.throws(() => generateHTML(results, { alt: "" }));
Expand All @@ -256,3 +267,23 @@ test("Image markup (escaped `alt`)", async t => {
alt: "This is a \"test"
}), `<img alt="This is a &quot;test" src="/img/KkPMmHd3hP-1280.jpeg" width="1280" height="853">`);
});

test.only("Image markup (<picture> with attributes issue #197)", async t => {
let results = await eleventyImage("./test/bio-2017.jpg", {
dryRun: true,
widths: [200,400]
});

t.is(generateHTML(results, {
alt: "",
sizes: "100vw",
}, {
pictureAttributes: {
class: "pic"
}
}), [`<picture class="pic">`,
`<source type="image/webp" srcset="/img/KkPMmHd3hP-200.webp 200w, /img/KkPMmHd3hP-400.webp 400w" sizes="100vw">`,
`<source type="image/jpeg" srcset="/img/KkPMmHd3hP-200.jpeg 200w, /img/KkPMmHd3hP-400.jpeg 400w" sizes="100vw">`,
`<img alt="" src="/img/KkPMmHd3hP-200.jpeg" width="400" height="266">`,
`</picture>`].join(""));
});
64 changes: 64 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,7 @@ test("Animated gif", async t => {

t.is(stats.gif.length, 1);
t.is(stats.gif[0].width, 400);
t.is(stats.gif[0].height, 400);
// it’s a big boi
t.true( stats.gif[0].size > 1000*1000 );
});
Expand Down Expand Up @@ -989,3 +990,66 @@ test("Remote image with dryRun should have a buffer property, useCache: false",

t.truthy(stats.png[0].buffer);
});

test("SVG files svgShortCircuit based on file size", async t => {
let stats = await eleventyImage("./test/Ghostscript_Tiger.svg", {
formats: ["svg", "webp"],
widths: [100, 1000, 1100],
dryRun: true,
svgShortCircuit: "size",
});

t.deepEqual(Object.keys(stats), ["svg", "webp"]);

t.is(stats.svg.length, 1);

t.is(stats.webp.length, 2);
t.is(stats.webp.filter(entry => entry.format === "svg").length, 1);

t.is(stats.webp[0].format, "webp");
t.is(stats.webp[0].width, 100);
t.truthy(stats.webp[0].size < 20000);

t.is(stats.webp[1].format, "svg");
t.is(stats.webp[1].width, 900);
});

test("SVG files svgShortCircuit based on file size (small SVG, exclusively SVG output)", async t => {
let stats = await eleventyImage("./test/logo.svg", {
formats: ["svg", "webp"],
widths: [500],
dryRun: true,
svgShortCircuit: "size",
});

t.deepEqual(Object.keys(stats), ["svg", "webp"]);

t.is(stats.svg.length, 1);
t.is(stats.webp.length, 0);
});


test("SVG files svgShortCircuit based on file size (brotli compression)", async t => {
let stats = await eleventyImage("./test/Ghostscript_Tiger.svg", {
formats: ["svg", "webp"],
widths: [100, 1000, 1100],
dryRun: true,
svgShortCircuit: "size",
svgCompressionSize: "br",
});

t.deepEqual(Object.keys(stats), ["svg", "webp"]);

t.is(stats.svg.length, 1);
t.true(stats.svg[0].size < 30000); // original was ~68000, br compression was applied.

t.is(stats.webp.length, 2);
t.is(stats.webp.filter(entry => entry.format === "svg").length, 1);

t.is(stats.webp[0].format, "webp");
t.is(stats.webp[0].width, 100);
t.truthy(stats.webp[0].size < 20000);

t.is(stats.webp[1].format, "svg");
t.is(stats.webp[1].width, 900);
});

0 comments on commit e53f71b

Please sign in to comment.