Skip to content

Commit

Permalink
Rewrite utils to Plugin.
Browse files Browse the repository at this point in the history
Rewrite and rearrange utilities into plugins.

-Create sqip.js to run plugins over the input file
-Turn SVG, SVGO, primitive and base64 as plugins
-Move loadSVG into utils/helpers
  • Loading branch information
Efe Gürkan YALAMAN committed Jul 17, 2018
1 parent 3fdaaa1 commit 023be72
Show file tree
Hide file tree
Showing 9 changed files with 556 additions and 1,188 deletions.
1,433 changes: 249 additions & 1,184 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
"precommit": "npm run lint",
"prepush": "npm run test"
},
"files": ["src", "vendor"],
"files": [
"src",
"vendor"
],
"keywords": [
"lqip",
"svg",
Expand Down Expand Up @@ -67,6 +70,8 @@
"prettier": "^1.11.1"
},
"jest": {
"collectCoverageFrom": ["src/**/*.js"]
"collectCoverageFrom": [
"src/**/*.js"
]
}
}
2 changes: 1 addition & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

const yargs = require('yargs')

const sqip = require('./index.js')
const sqip = require('./sqip.js')

const { argv } = yargs
.usage('\nUsage: sqip --input [path]')
Expand Down
29 changes: 29 additions & 0 deletions src/plugins/base64.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const encodeBase64 = rawSVG => Buffer.from(rawSVG).toString('base64')

class Base64EncodePlugin {
constructor(options) {
this.options = options || { wrapImageTag: true }
}

apply(svg) {
const shouldWrap = this.options.wrapImageTag
const base64svg = encodeBase64(svg)

if (shouldWrap) {
return this.wrapImageTag(base64svg)
} else {
return base64svg
}
}

wrapImageTag(svg) {
const {
dimensions: { width, height },
filename
} = this.options

return `<img width="${width}" height="${height}" src="${filename}" alt="Add descriptive alt text" style="background-size: cover; background-image: url(data:image/svg+xml;base64,${svg});">`
}
}

module.exports = Base64EncodePlugin
71 changes: 71 additions & 0 deletions src/plugins/primitive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const childProcess = require('child_process')
const fs = require('fs')
const path = require('path')
const os = require('os')

const VENDOR_DIR = path.resolve(__dirname, '..', '..', 'vendor')
const PRIMITIVE_TEMP_FILE = os.tmpdir() + '/primitive_tempfile.svg'
let primitiveExecutable = 'primitive'

// Since Primitive is only interested in the larger dimension of the input image, let's find it
const findLargerImageDimension = ({ width, height }) =>
width > height ? width : height

class PrimitivePlugin {
constructor(options) {
this.options = options || { shouldThrow: false }
}

apply(filename) {
this.checkForPrimitive()

const { numberOfPrimitives = 8, mode = 0, dimensions } = this.options

childProcess.execFileSync(primitiveExecutable, [
'-i',
filename,
'-o',
PRIMITIVE_TEMP_FILE,
'-n',
numberOfPrimitives,
'-m',
mode,
'-s',
findLargerImageDimension(dimensions)
])
return fs.readFileSync(PRIMITIVE_TEMP_FILE, {
encoding: 'utf-8'
})
}

// Sanity check: use the exit state of 'type' to check for Primitive availability
checkForPrimitive() {
const primitivePath = path.join(
VENDOR_DIR,
`primitive-${os.platform()}-${os.arch()}`
)

if (fs.existsSync(primitivePath)) {
primitiveExecutable = primitivePath
return
}

const errorMessage =
'Please ensure that Primitive (https://github.com/fogleman/primitive, written in Golang) is installed and globally available'
try {
if (os.platform() === 'win32') {
childProcess.execSync('where primitive')
} else {
childProcess.execSync('type primitive')
}
} catch (e) {
if (this.options.shouldThrow) {
throw new Error(errorMessage)
}
console.log(errorMessage)
process.exit(1)
}
}
}

module.exports = PrimitivePlugin
94 changes: 94 additions & 0 deletions src/plugins/svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const { loadSVG } = require('../utils/helpers')

const PRIMITIVE_SVG_ELEMENTS = 'circle, ellipse, line, polygon, path, rect, g'

const patchSVGGroup = svg => {
const $ = loadSVG(svg)

const $svg = $('svg')
const $primitiveShapes = $svg.children(PRIMITIVE_SVG_ELEMENTS)

// Check if actual shapes are grouped
if (!$primitiveShapes.filter('g').length !== 1) {
const $group = $('<g/>')
const $realShapes = $primitiveShapes.not('rect:first-child')

$group.append($realShapes)
$svg.append($group)
}

return $.html()
}

class SVGPlugin {
constructor(options) {
this.options = options || {}
}

apply(svg) {
let retval = this.prepareSVG(svg)
if (this.options.blur) {
retval = this.applyBlurFilter(retval)
}
return retval
}

// Prepare SVG. For now, this will just ensure that the viewbox attribute is set
prepareSVG(svg) {
const $ = loadSVG(svg)
const $svg = $('svg')
const { width, height } = this.options.dimensions

// Ensure viewbox
if (!$svg.is('[viewBox]')) {
if (!(width && height)) {
throw new Error(
`SVG is missing viewBox attribute while Width and height were not passed:\n\n${svg}`
)
}
$svg.attr('viewBox', `0 0 ${width} ${height}`)
}

const $bgRect = $svg
.children(PRIMITIVE_SVG_ELEMENTS)
.filter('rect:first-child[fill]')

// Check if filling background rectangle exists
// This must exist for proper blur and other transformations
if (!$bgRect.length) {
throw new Error(
`The SVG must have a rect as first shape element which represents the svg background color:\n\n${svg}`
)
}

// Remove x and y attributes since they default to 0
// @todo test in rare browsers
$bgRect.attr('x', null)
$bgRect.attr('y', null)

// Improve compression via simplifying fill
$bgRect.attr('width', '100%')
$bgRect.attr('height', '100%')

return $.html()
}

applyBlurFilter(svg) {
if (!this.options.blur) {
return svg
}
const patchedSVG = patchSVGGroup(svg)
const $ = loadSVG(patchedSVG)
const blurFilterId = 'b'
$('svg > g').attr('filter', `url(#${blurFilterId})`)
$('svg').prepend(
`<filter id="${blurFilterId}"><feGaussianBlur stdDeviation="${
this.options.blur
}" />`
)

return $.html()
}
}

module.exports = SVGPlugin
21 changes: 21 additions & 0 deletions src/plugins/svgo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const SVGO = require('svgo')

// SVGO with settings for maximum compression to optimize the Primitive-generated SVG
class SVGOPlugin {
constructor(options) {
this.options = options || {}
}
apply(svg) {
return this.optimize(svg)
}
optimize(svg) {
const { multipass = true, floatPrecision = 1 } = this.options

const svgo = new SVGO({ multipass, floatPrecision })
let retVal = ''
svgo.optimize(svg, ({ data }) => (retVal = data))
return retVal
}
}

module.exports = SVGOPlugin
74 changes: 74 additions & 0 deletions src/sqip.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const fs = require('fs')
const path = require('path')

const SVGPlugin = require('./plugins/svg')
const SVGOPlugin = require('./plugins/svgo')
const PrimitivePlugin = require('./plugins/primitive')
const Base64EncodePlugin = require('./plugins/base64')

const { getDimensions, printFinalResult } = require('./utils/helpers')

const sqip = options => {
// TODO validate Options
const config = Object.assign({}, options)
config.filename = config.input

if (!config.filename) {
throw new Error(
'Please provide an input image, e.g. sqip({ filename: "input.jpg" })'
)
}

const inputPath = path.resolve(config.filename)

try {
fs.accessSync(inputPath, fs.constants.R_OK)
} catch (err) {
throw new Error(`Unable to read input file: ${inputPath}`)
}
const imgDimensions = getDimensions(inputPath)

let plugins = []
if (!config.plugins) {
plugins = [
new PrimitivePlugin({
numberOfPrimitives: 8,
mode: 0,
filename: config.filename,
dimensions: imgDimensions
}),
new SVGPlugin({
blur: 12,
dimensions: imgDimensions
}),
new SVGOPlugin({
multipass: true,
floatPrecision: 1
}),
new Base64EncodePlugin({
dimensions: imgDimensions,
filename: config.filename,
wrapImageTag: true
})
]
} else {
plugins = config.plugins
}

let finalSvg = config.filename
for (let plugin of plugins) {
finalSvg = plugin.apply(finalSvg)
}

// Write to disk or output result
if (config.output) {
const outputPath = path.resolve(config.output)
fs.writeFileSync(outputPath, finalSvg)
} else {
printFinalResult(imgDimensions, inputPath, finalSvg)
}

return { finalSvg, imgDimensions }
}

module.exports = sqip
11 changes: 10 additions & 1 deletion src/utils/helpers.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const cheerio = require('cheerio')
const sizeOf = require('image-size')

// In case the user the did not provide the --output switch and is thus opting for the default stdout output inside an <img>, prepare the base64 encoded version of the SVG
Expand All @@ -13,8 +14,16 @@ const printFinalResult = ({ width, height }, filename, svgBase64Encoded) => {
console.log(result)
}

const loadSVG = svg => {
return cheerio.load(svg, {
normalizeWhitespace: true,
xmlMode: true
})
}

module.exports = {
encodeBase64,
getDimensions,
printFinalResult
printFinalResult,
loadSVG
}

0 comments on commit 023be72

Please sign in to comment.