11import { existsSync , readFileSync , writeFileSync , globSync } from "fs" ;
2+ import { readFile } from "fs/promises" ;
23import { join , parse } from "path" ;
34import { downloadExternalSourceAssets } from "../src/ExternalSourceManager.js" ;
45import { downloadLegacyAssets } from "../src/LegacyAssetManager.js" ;
6+ import sharp from "sharp" ;
57
68const version = JSON . parse ( readFileSync ( "package.json" ) ) . version ;
79const currentMajorVersion = version . split ( "." ) [ 1 ] ;
@@ -21,6 +23,9 @@ for (const importSource of importSources) {
2123 importSource . seenIcons = { } ;
2224}
2325
26+ const svgPromisesByPath = new Map ( ) ;
27+ const rasterBufferPromisesBySvgPath = new Map ( ) ;
28+
2429const iconChangeProps = [
2530 "oldId" ,
2631 "newId" ,
@@ -46,13 +51,17 @@ const changelogPath = "metadata/changelog.json";
4651
4752const changelogs = JSON . parse ( readFileSync ( changelogPath ) ) ;
4853
49- if ( validateChangelogs ( changelogs ) ) {
54+ const startTime = Date . now ( ) ;
55+
56+ if ( await validateChangelogs ( changelogs ) ) {
5057 const currentChangelog = changelogs . find (
5158 ( c ) => c . majorVersion === currentMajorVersion ,
5259 ) ;
53- printTextForChangelog ( currentChangelog ) ;
60+ // printTextForChangelog(currentChangelog);
5461
55- console . log ( "changelog.json is valid" ) ;
62+ console . log (
63+ "changelog.json is valid, done in " + ( Date . now ( ) - startTime ) + " ms" ,
64+ ) ;
5665} else {
5766 console . log ( "changelog.json is not valid, exiting…" ) ;
5867 process . exit ( 1 ) ;
@@ -84,7 +93,7 @@ function formatChangelogs(changelogs) {
8493 return formattedChangelogs ;
8594}
8695
87- function validateChangelogs ( changelogs ) {
96+ async function validateChangelogs ( changelogs ) {
8897 // sort oldest to newest
8998 const sortedChangelogs = changelogs . toSorted (
9099 ( a , b ) => parseInt ( a . majorVersion ) - parseInt ( b . majorVersion ) ,
@@ -93,7 +102,7 @@ function validateChangelogs(changelogs) {
93102 const iconsById = { } ;
94103
95104 for ( const versionChangelog of sortedChangelogs ) {
96- if ( ! validateChangelog ( versionChangelog , iconsById ) ) {
105+ if ( ! ( await validateChangelog ( versionChangelog , iconsById ) ) ) {
97106 return ;
98107 }
99108 }
@@ -117,23 +126,59 @@ function validateChangelogs(changelogs) {
117126 return true ;
118127}
119128
120- function validateChangelog ( versionChangelog , iconsById ) {
129+ async function validateChangelog ( versionChangelog , iconsById ) {
121130 // Make sure we process all deletions/changes before additions
122131 const sortedIconChanges = versionChangelog . iconChanges . toSorted ( ( a , b ) => {
123132 if ( b . oldId && ! a . oldId ) return 1 ;
124133 if ( ! b . oldId && a . oldId ) return - 1 ;
125134 return 0 ;
126135 } ) ;
127136
137+ const hasIconChangeForIconId = { } ;
138+
128139 for ( const iconChange of sortedIconChanges ) {
129- if ( ! validateIconChange ( iconChange , versionChangelog , iconsById ) ) {
140+ if ( iconChange . newId ) {
141+ hasIconChangeForIconId [ iconChange . newId ] = true ;
142+ }
143+ if ( ! ( await validateIconChange ( iconChange , versionChangelog , iconsById ) ) ) {
144+ return ;
145+ }
146+ }
147+
148+ const v = parseInt ( versionChangelog . majorVersion ) ;
149+
150+ if ( v > 1 ) {
151+ // ensure that a changelog entry exists if the icon SVG has changed
152+ const iconsDir =
153+ v === parseInt ( currentMajorVersion ) ? "./icons" : `./docs/v${ v } ` ;
154+ const iconFiles = globSync ( `${ iconsDir } /**/*.svg` ) ;
155+ const promises = iconFiles . map ( async ( file ) => {
156+ const id = parse ( file ) . name ;
157+ if ( ! hasIconChangeForIconId [ id ] ) {
158+ if (
159+ ! ( await svgsAreVisuallyEquivalent (
160+ `./docs/v${ v - 1 } /${ id } .svg` ,
161+ file ,
162+ v > 4 ,
163+ ) )
164+ ) {
165+ throw new Error (
166+ `Missing changelog entry for changed file "${ id } .svg" in version ${ v } ` ,
167+ ) ;
168+ }
169+ }
170+ } ) ;
171+ try {
172+ await Promise . all ( promises ) ;
173+ } catch ( error ) {
174+ console . error ( error ) ;
130175 return ;
131176 }
132177 }
133178 return true ;
134179}
135180
136- function validateIconChange ( iconChange , versionChangelog , iconsById ) {
181+ async function validateIconChange ( iconChange , versionChangelog , iconsById ) {
137182 const v = parseInt ( versionChangelog . majorVersion ) ;
138183 for ( const key in iconChange ) {
139184 if ( ! iconChangeProps . includes ( key ) ) {
@@ -243,23 +288,29 @@ function validateIconChange(iconChange, versionChangelog, iconsById) {
243288 ) ;
244289 return ;
245290 }
246- if ( v > 1 && ( iconChange . by || iconChange . src ) ) {
291+ if ( v > 1 ) {
247292 // expect SVGs to be different
248- const oldFile = readFileSync (
249- `./docs/v${ v - 1 } /${ iconChange . oldId } .svg` ,
250- "utf8" ,
251- ) ;
252293 const newfileRoot =
253294 parseInt ( currentMajorVersion ) === v ? "./icons" : `./docs/v${ v } ` ;
254- const newFile = readFileSync (
295+ const sameSvg = await svgsAreVisuallyEquivalent (
296+ `./docs/v${ v - 1 } /${ iconChange . oldId } .svg` ,
255297 `${ newfileRoot } /${ iconChange . newId } .svg` ,
256- "utf8" ,
298+ v > 4 ,
257299 ) ;
258- if ( newFile === oldFile ) {
259- console . error (
260- `No difference between old icon "v${ v - 1 } /${ iconChange . oldId } " and new icon "v${ v } /${ iconChange . newId } "` ,
261- ) ;
262- return ;
300+ if ( iconChange . by || iconChange . src ) {
301+ if ( sameSvg ) {
302+ console . error (
303+ `No difference between SVGs of old icon "v${ v - 1 } /${ iconChange . oldId } " and new icon "v${ v } /${ iconChange . newId } "` ,
304+ ) ;
305+ return ;
306+ }
307+ } else {
308+ if ( ! sameSvg ) {
309+ console . error (
310+ `Unexpected difference between SVGs of old icon "v${ v - 1 } /${ iconChange . oldId } " and new icon "v${ v } /${ iconChange . newId } "` ,
311+ ) ;
312+ return ;
313+ }
263314 }
264315 }
265316 if ( iconChange . newId !== iconChange . oldId ) {
@@ -457,6 +508,65 @@ function printTextForChangelog(changelog) {
457508 }
458509}
459510
511+ function getSvg ( svgPath ) {
512+ if ( ! svgPromisesByPath . has ( svgPath ) ) {
513+ svgPromisesByPath . set ( svgPath , readFile ( svgPath , "utf8" ) ) ;
514+ }
515+
516+ return svgPromisesByPath . get ( svgPath ) ;
517+ }
518+
519+ function getRasterBuffer ( svgPath , svg ) {
520+ if ( ! rasterBufferPromisesBySvgPath . has ( svgPath ) ) {
521+ rasterBufferPromisesBySvgPath . set (
522+ svgPath ,
523+ sharp ( Buffer . from ( svg ) ) . resize ( 60 , 60 ) . ensureAlpha ( ) . raw ( ) . toBuffer ( ) ,
524+ ) ;
525+ }
526+
527+ return rasterBufferPromisesBySvgPath . get ( svgPath ) ;
528+ }
529+
530+ async function svgsAreVisuallyEquivalent ( svgPath1 , svgPath2 , strict ) {
531+ const [ svg1 , svg2 ] = await Promise . all ( [ getSvg ( svgPath1 ) , getSvg ( svgPath2 ) ] ) ;
532+ if ( svg1 === svg2 ) return true ;
533+
534+ const [ a , b ] = await Promise . all ( [
535+ getRasterBuffer ( svgPath1 , svg1 ) ,
536+ getRasterBuffer ( svgPath2 , svg2 ) ,
537+ ] ) ;
538+
539+ if ( strict ) return a . equals ( b ) ;
540+
541+ if ( a . length !== b . length ) return false ;
542+
543+ // amount within which different channel values will be treated as the same value
544+ const channelTolerance = 8 ;
545+ // percent of pixels allowed to differ
546+ const pixelTolerance = 0.01 ;
547+
548+ let differentPixels = 0 ;
549+ const totalPixels = a . length / 4 ;
550+
551+ for ( let i = 0 ; i < a . length ; i += 4 ) {
552+ const different =
553+ Math . abs ( a [ i ] - b [ i ] ) > channelTolerance ||
554+ Math . abs ( a [ i + 1 ] - b [ i + 1 ] ) > channelTolerance ||
555+ Math . abs ( a [ i + 2 ] - b [ i + 2 ] ) > channelTolerance ||
556+ Math . abs ( a [ i + 3 ] - b [ i + 3 ] ) > channelTolerance ;
557+
558+ if ( different ) {
559+ differentPixels ++ ;
560+
561+ if ( differentPixels / totalPixels > pixelTolerance ) {
562+ return false ;
563+ }
564+ }
565+ }
566+
567+ return true ;
568+ }
569+
460570function stringArray ( value ) {
461571 return typeof value === "string" ? [ value ] : [ ...value ] ;
462572}
0 commit comments