2020-08-09 11:21:29 +00:00
import * as fs from "fs-extra" ;
import * as path from "path" ;
import { pascalCase } from "change-case" ;
2021-03-16 18:20:57 +00:00
import XMLParser from "xml-parser" ;
import potpack from "potpack" ;
2020-08-09 11:21:29 +00:00
function generateAttributes ( attributes : XMLParser.Attributes ) {
const keys = Object . keys ( attributes ) ;
if ( keys . length === 0 ) return "" ;
return Object . keys ( attributes ) . map ( e = > ` ${ e } =" ${ attributes [ e ] } " ` ) . join ( "" ) ;
}
function jsNode2xml ( indent : string , data : XMLParser.Node ) {
if ( data . content && data . children . length )
throw "invalid node" ;
const tagOpen = ` < ${ data . name } ${ generateAttributes ( data . attributes ) } > ` ;
const tagClose = ` </ ${ data . name } > ` ;
2020-08-10 12:52:50 +00:00
let content = data . children . length ? data . children . map ( e = > jsNode2xml ( indent + " " , e ) ) . join ( "\n" ) : data . content || "" ;
if ( content . length !== 0 )
2020-08-09 11:21:29 +00:00
content = "\n" + content + "\n" + indent ;
return (
` ${ indent } ${ tagOpen } ` +
` ${ content } ` +
` ${ tagClose } `
) ;
}
interface SVGFile {
bounds : {
/* w & h required for potpack */
w : number ,
h : number ,
x : number ,
y : number
} ,
name : string ,
data : XMLParser.Document
}
export async function generateSpriteSvg ( sprite : GeneratedSprite ) {
let result = "" ;
result += ` <?xml version="1.0" encoding="utf-8"?> \ n ` ;
result += ` <!-- ${ sprite . entries . length } icons packed --> \ n ` ;
2021-03-16 18:20:57 +00:00
result += ` <svg xmlns="http://www.w3.org/2000/svg" width=" ${ sprite . width } " height=" ${ sprite . height } " viewBox="0 0 ${ sprite . width } ${ sprite . height } "> \ n ` ;
2020-08-09 11:21:29 +00:00
for ( const file of sprite . entries ) {
const root = file . data . root ;
delete root . attributes [ "xmlns" ] ;
delete root . attributes [ "xmlns:xlink" ] ;
root . attributes [ "id" ] = "client-" + file . name ; /* appending the "client-" due to legacy reasons */
root . attributes [ "x" ] = file . bounds . x . toString ( ) ;
root . attributes [ "y" ] = file . bounds . y . toString ( ) ;
root . attributes [ "width" ] = file . bounds . w . toString ( ) ;
root . attributes [ "height" ] = file . bounds . h . toString ( ) ;
result += jsNode2xml ( " " , root ) + "\n" ;
}
result += "</svg>" ;
return result ;
}
export interface SpriteCssOptions {
selector : string ;
scale : number ;
unit : "px" | "em" ;
}
export async function generateSpriteCss ( options : SpriteCssOptions , classPrefix : string , sprite : GeneratedSprite , publicUrl : string ) {
const boundsMap = { } ;
sprite . entries . forEach ( e = > {
const key = e . bounds . w + " " + e . bounds . h ;
boundsMap [ key ] = ( boundsMap [ key ] | 0 ) + 1 ;
} ) ;
const best = Object . keys ( boundsMap ) . sort ( ( a , b ) = > boundsMap [ b ] - boundsMap [ a ] ) [ 0 ] ;
const [ defaultWidth , defaultHeight ] = best . split ( " " ) . map ( parseFloat ) ;
const scaleX = options . unit === "px" ? options.scale : options.scale / defaultWidth ;
const scaleY = options . unit === "px" ? options.scale : options.scale / defaultHeight ;
let result = "" ;
result += ` ${ options . selector } { ` ;
result += ` display:inline-block; ` ;
2020-09-25 16:00:28 +00:00
/* doing a relative path here as well since WebPack may messes around here since the public path may start with / and if it's a file path / means the root... */
result += ` background-image:url(". ${ publicUrl } "),url(" ${ publicUrl } "); ` ;
result += ` background-repeat:no-repeat; ` ;
2020-08-09 11:21:29 +00:00
result += ` background-size: ${ sprite . width * scaleX } ${ options . unit } ${ sprite . height * scaleY } ${ options . unit } ; ` ;
result += ` height: ${ defaultHeight * scaleY } ${ options . unit } ; ` ;
result += ` width: ${ defaultWidth * scaleX } ${ options . unit } ` ;
result += ` } ` ;
/ *
const unit = ( value : number ) = > {
let valStr = value . toString ( ) ;
if ( valStr . length < 5 )
valStr = " " . substr ( valStr . length - 5 ) + valStr ;
return value === 0 ? ` ${ valStr } ` : ` ${ valStr } ${ options . unit } ` ;
} ;
* /
const unit = ( value : number ) = > {
let valStr = value . toString ( ) ;
return value === 0 ? ` ${ valStr } ` : ` ${ valStr } ${ options . unit } ` ;
} ;
for ( const file of sprite . entries ) {
result += ` ${ options . selector } . ${ classPrefix } ${ file . name } { ` ;
result += ` background-position: ${ unit ( - file . bounds . x * scaleX ) } ${ unit ( - file . bounds . y * scaleY ) } ` ;
if ( file . bounds . w !== defaultWidth || file . bounds . h !== defaultHeight ) {
result += ";" ; /* from before */
result += ` background-size: ${ sprite . width * scaleX } ${ options . unit } ${ sprite . height * scaleY } ${ options . unit } ; ` ;
result += ` width: ${ file . bounds . w * scaleY } ${ options . unit } ; ` ;
result += ` height: ${ file . bounds . h * scaleX } ${ options . unit } ` ;
}
result += ` } ` ;
}
return result ;
}
export interface SpriteDtsOptions {
module : boolean ;
enumName : string ;
classUnionName : string ;
}
function generateEnumMembers ( options : SpriteDtsOptions , sprite : GeneratedSprite , cssClassPrefix : string ) : { [ key : string ] : string } {
const result = { } ;
for ( const file of sprite . entries ) {
let name = file . name ;
name = name . replace ( /[- ]/g , "_" )
. replace ( /[^a-zA-Z0-9_]/g , "" ) ;
name = pascalCase ( name ) ;
result [ name ] = cssClassPrefix + file . name ;
}
return result ;
}
2020-08-09 11:33:19 +00:00
export async function generateSpriteDts ( options : SpriteDtsOptions , module Name : string , sprite : GeneratedSprite , cssClassPrefix : string , module Prefix : string , sourceDirectory : string ) {
const headerLines = [ ] ;
2020-08-09 11:21:29 +00:00
const lines = [ ] ;
2020-08-09 11:33:19 +00:00
headerLines . push ( ` /* ` ) ;
headerLines . push ( ` * DO NOT MODIFY THIS FILE! ` ) ;
headerLines . push ( ` * ` ) ;
headerLines . push ( ` * This file has been auto generated by the svg-sprite generator. ` ) ;
headerLines . push ( ` * Sprite source directory: ${ sourceDirectory } ` ) ;
headerLines . push ( ` * Sprite count: ${ sprite . entries . length } ` ) ;
headerLines . push ( ` */ ` ) ;
headerLines . push ( ` ` ) ;
2020-08-09 11:21:29 +00:00
{
let union = "" ;
for ( const file of sprite . entries )
union += ` | " ${ cssClassPrefix } ${ file . name } " ` ;
lines . push ( ` export type ${ options . classUnionName } = ${ union . substr ( 3 ) } ; ` ) ;
}
lines . push ( ` ` ) ;
{
lines . push ( ` export enum ${ options . enumName } { ` ) ;
const members = generateEnumMembers ( options , sprite , cssClassPrefix ) ;
for ( const key of Object . keys ( members ) )
lines . push ( ` ${ key } = " ${ members [ key ] } ", ` ) ;
lines . push ( "}" ) ;
}
//sprite.entries[0].bounds.
lines . push ( ` ` ) ;
lines . push ( ` export const spriteEntries: { ` ) ;
lines . push ( ` id: string; ` ) ;
lines . push ( ` className: string; ` ) ;
lines . push ( ` width: number; ` ) ;
lines . push ( ` height: number; ` ) ;
lines . push ( ` xOffset: number; ` ) ;
lines . push ( ` yOffset: number; ` ) ;
lines . push ( ` }[]; ` ) ;
lines . push ( ` ` ) ;
lines . push ( ` export const spriteUrl: string; ` ) ;
lines . push ( ` export const classList: string[]; ` ) ;
lines . push ( ` ` ) ;
lines . push ( ` export const spriteWidth: number; ` ) ;
lines . push ( ` export const spriteHeight: number; ` ) ;
if ( options . module ) {
let result = "" ;
2020-08-09 11:33:19 +00:00
result += headerLines . join ( "\n" ) ;
2020-08-09 11:21:29 +00:00
result += ` declare module " ${ module Prefix } ${ module Name } " { \ n ` ;
result += lines . map ( e = > " " + e ) . join ( "\n" ) ;
result += ` \ n} ` ;
return result ;
} else {
2020-08-09 11:33:19 +00:00
return headerLines . join ( "\n" ) + lines . join ( "\n" ) ;
2020-08-09 11:21:29 +00:00
}
}
export async function generateSpriteJs ( options : SpriteDtsOptions , sprite : GeneratedSprite , publicUrl : string , cssClassPrefix : string ) {
let lines = [ ] ;
{
lines . push ( ` let EnumClassList = {}; ` ) ;
const members = generateEnumMembers ( options , sprite , cssClassPrefix ) ;
for ( const key of Object . keys ( members ) )
lines . push ( ` EnumClassList[EnumClassList[" ${ key } "] = " ${ members [ key ] } "] = " ${ key } "; ` ) ;
}
{
lines . push ( ` ` ) ;
lines . push ( ` let SpriteEntries = [ ` ) ;
for ( const entry of sprite . entries ) {
lines . push ( ` Object.freeze({ ` ) ;
lines . push ( ` id: " ${ entry . name } ", ` ) ;
lines . push ( ` className: " ${ cssClassPrefix } ${ entry . name } ", ` ) ;
lines . push ( ` width: ${ entry . bounds . w } , ` ) ;
lines . push ( ` height: ${ entry . bounds . h } , ` ) ;
lines . push ( ` xOffset: ${ entry . bounds . x } , ` ) ;
lines . push ( ` yOffset: ${ entry . bounds . y } , ` ) ;
lines . push ( ` }), ` ) ;
}
lines . push ( ` ]; ` ) ;
}
lines . push ( ` ` ) ;
lines . push ( ` let SpriteUrl = decodeURIComponent(" ${ encodeURIComponent ( publicUrl ) } "); ` ) ;
lines . push ( ` ` ) ;
lines . push ( ` let ClassList = [ ${ sprite . entries . map ( e = > ` " ${ cssClassPrefix } ${ e . name } ", ` ) } ]; ` ) ;
lines . push ( ` ` ) ;
2021-03-16 18:20:57 +00:00
lines . push ( ` Object.defineProperty(module.exports, "__esModule", { value: true }); ` ) ;
lines . push ( ` module.exports. ${ options . enumName } = Object.freeze(EnumClassList); ` ) ;
lines . push ( ` module.exports.spriteUrl = SpriteUrl; ` ) ;
lines . push ( ` module.exports.classList = Object.freeze(ClassList); ` ) ;
lines . push ( ` module.exports.spriteEntries = Object.freeze(SpriteEntries); ` ) ;
lines . push ( ` module.exports.spriteWidth = ${ sprite . width } ; ` ) ;
lines . push ( ` module.exports.spriteHeight = ${ sprite . height } ; ` ) ;
2020-08-09 11:21:29 +00:00
return lines . join ( "\n" ) ;
}
export interface GeneratedSprite {
width : number ;
height : number ;
entries : SVGFile [ ] ;
}
export async function generateSprite ( files : string [ ] ) : Promise < GeneratedSprite > {
const result = {
entries : [ ] ,
} as GeneratedSprite ;
for ( const file of files ) {
const svg = { } as SVGFile ;
svg . data = XMLParser ( ( await fs . readFile ( file ) ) . toString ( ) ) ;
svg . name = path . basename ( file , path . extname ( file ) ) ;
2020-08-10 12:52:50 +00:00
if ( ! svg . data . root ) {
console . warn ( "Missing svg root element for %s. Skipping file." , file ) ;
continue ;
}
2020-08-09 11:21:29 +00:00
if ( svg . data . root . name !== "svg" ) {
console . warn ( "invalid svg root attribute for " + file + " (" + svg . data . root . name + ")" ) ;
continue ;
}
const rootAttributes = svg . data . root . attributes ;
2021-03-16 18:20:57 +00:00
const [ /* xOff */ , /* yOff */ , width , height ] = rootAttributes [ "viewBox" ] . split ( " " ) . map ( parseFloat ) ;
2020-08-09 11:21:29 +00:00
if ( isNaN ( width ) || isNaN ( height ) ) {
console . warn ( "Skipping SVG %s because of invalid bounds (Parsed: %d x %d, Values: %o)." , file , width , height , rootAttributes [ "viewBox" ] . split ( " " ) ) ;
continue ;
2020-08-24 08:33:41 +00:00
} else if ( Math . floor ( width ) !== width || Math . floor ( height ) !== height ) {
console . warn ( "Skipping SVG %s because of an non interger height/width (%fx%f)" , file , width , height ) ;
continue ;
2020-08-09 11:21:29 +00:00
}
svg . bounds = {
w : width ,
h : height ,
x : undefined ,
y : undefined
} ;
result . entries . push ( svg ) ;
}
/* take the element height/with divided by two since that's a good number to work with */
//const svgSpaceWidth = result.elementWidth / 2;
//const svgSpaceHeight = result.elementHeight / 2;
//result.entries.forEach(e => { e.bounds.w += svgSpaceWidth; e.bounds.h += svgSpaceHeight });
const spriteDim = potpack ( result . entries . map ( e = > e . bounds ) ) ;
//result.entries.forEach(e => { e.bounds.w -= svgSpaceWidth; e.bounds.h -= svgSpaceHeight });
result . width = spriteDim . w ;
result . height = spriteDim . h ;
return result ;
}