Browse Source

Webfonts analyzis (#209)

Add 3 new rules about webfonts
Gaël Métais 8 years ago
parent
commit
5273dfbbf3

+ 1 - 1
Gruntfile.js

@@ -143,7 +143,7 @@ module.exports = function(grunt) {
                 options: {
                 options: {
                     reporter: 'spec',
                     reporter: 'spec',
                 },
                 },
-                src: ['test/core/imageOptimizerTest.js']
+                src: ['test/core/fontAnalyzerTest.js']
             },
             },
             coverage: {
             coverage: {
                 options: {
                 options: {

+ 1 - 1
front/src/css/rule.css

@@ -237,7 +237,7 @@
 .totalWeightPie canvas {
 .totalWeightPie canvas {
   max-width: inherit;
   max-width: inherit;
 }
 }
-.hugeFile {
+.offenderProblem {
   font-weight: bold;
   font-weight: bold;
   color: #e74c3c;
   color: #e74c3c;
 }
 }

+ 6 - 0
front/src/js/directives/offendersDirectives.js

@@ -918,4 +918,10 @@
         };
         };
     });
     });
 
 
+    offendersDirectives.filter('addSpaces', function() {
+        return function(str) {
+            return str.split('').join(' ');
+        };
+    });
+
 })();
 })();

+ 1 - 1
front/src/less/rule.less

@@ -261,7 +261,7 @@
     }
     }
 }
 }
 
 
-.hugeFile {
+.offenderProblem {
     font-weight: bold;
     font-weight: bold;
     color: #e74c3c;
     color: #e74c3c;
 }
 }

+ 57 - 1
front/src/views/rule.html

@@ -158,6 +158,11 @@
                     <div ng-if="policyName === 'globalVariables' || policyName === 'jQueryVersionsLoaded' || policyName === 'synchronousXHR'">
                     <div ng-if="policyName === 'globalVariables' || policyName === 'jQueryVersionsLoaded' || policyName === 'synchronousXHR'">
                         {{offender}}
                         {{offender}}
                     </div>
                     </div>
+
+                    <div ng-if="policyName === 'fontsCount'">
+                        <url-link url="offender.url" max-length="70"></url-link>
+                        ({{offender.size | bytes}})
+                    </div>
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
@@ -250,7 +255,7 @@
             <div class="offendersTable">
             <div class="offendersTable">
                 <div ng-repeat="request in rule.offendersObj.list.byType[type].requests | orderBy:'-weight'" ng-if="request.weight > 0">
                 <div ng-repeat="request in rule.offendersObj.list.byType[type].requests | orderBy:'-weight'" ng-if="request.weight > 0">
                     <div><url-link url="request.url" max-length="60"></url-link></div>
                     <div><url-link url="request.url" max-length="60"></url-link></div>
-                    <div ng-class="{hugeFile: request.weight > 102400}">{{request.weight | bytes}}</div>
+                    <div ng-class="{offenderProblem: request.weight > 102400}">{{request.weight | bytes}}</div>
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
@@ -403,6 +408,57 @@
         </div>
         </div>
     </div>
     </div>
 
 
+    <div ng-if="policyName === 'heavyFonts'">
+        <div ng-repeat="font in rule.offendersObj.fonts | orderBy:'-weight' track by $index">
+            <h3><url-link url="font.url" max-length="80"></url-link></h3>
+            <div class="offendersTable">
+                <div>
+                    <div>Weight</div>
+                    <div ng-if="font.weight <= 40960">{{font.weight | bytes}}</div>
+                    <div ng-if="font.weight > 40960" class="offenderProblem">{{font.weight | bytes}}</div>
+                </div>
+                <div>
+                    <div>Number of glyphs</div>
+                    <div ng-if="font.numGlyphs <= 500">{{font.numGlyphs}}</div>
+                    <div ng-if="font.numGlyphs > 500" class="offenderProblem">{{font.numGlyphs}} (better &lt; 500)</div>
+                </div>
+                <div>
+                    <div>Average glyph complexity</div>
+                    <div ng-if="font.averageGlyphComplexity <= 35">{{font.averageGlyphComplexity}}</div>
+                    <div ng-if="font.averageGlyphComplexity > 35" class="offenderProblem">{{font.averageGlyphComplexity}} (better &lt; 35)</div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div ng-if="policyName === 'unusedUnicodeRanges'">
+        <div ng-repeat="font in rule.offendersObj.fonts | orderBy:'-compressedWeigth' track by $index">
+            <h3><url-link url="font.url" max-length="60"></url-link> ({{font.weight | bytes}})</h3>
+            <div ng-if="font.isIconFont" class="offendersTable">
+                <div>
+                    <div>
+                        This font seems to be an icon font
+                        <span ng-if="font.numGlyphsInCommonWithPageContent / font.glyphs <= 0.05" class="offenderProblem">but only {{font.numGlyphsInCommonWithPageContent}} of its {{font.glyphs}} glyphs <ng-pluralize count="font.numGlyphsInCommonWithPageContent" when="{'one': 'is', 'other': 'are'}"></ng-pluralize> possibly used!</span>
+                        <span ng-if="font.numGlyphsInCommonWithPageContent / font.glyphs > 0.05">and {{font.numGlyphsInCommonWithPageContent}} of its {{font.glyphs}} glyphs <ng-pluralize count="font.numGlyphsInCommonWithPageContent" when="{'one': 'is', 'other': 'are'}"></ng-pluralize> possibly used.</span>
+                    </div>
+                </div>
+            </div>
+            <div ng-if="!font.isIconFont" class="offendersTable">
+                <div ng-repeat="range in font.unicodeRanges track by $index">
+                    <div><b>{{range.name}}</b></div>
+                    <div ng-if="!range.underused">{{range.numGlyphsInCommonWithPageContent}} of its {{range.charset.length}} glyphs <ng-pluralize count="range.numGlyphsInCommonWithPageContent" when="{'one': 'is', 'other': 'are'}"></ng-pluralize> possibly used</div>
+                    <div ng-if="range.underused" class="offenderProblem">{{range.numGlyphsInCommonWithPageContent}} of its {{range.charset.length}} glyphs are used</div>
+                    <div>
+                        <div class="offenderButton opens">
+                            glyphes list
+                            <div>{{range.charset | addSpaces}}</div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
     <div ng-if="policyName === 'http2'">
     <div ng-if="policyName === 'http2'">
         <h3>Protocols advertised by the server</h3>
         <h3>Protocols advertised by the server</h3>
         <div class="offendersTable">
         <div class="offendersTable">

+ 38 - 1
lib/metadata/policies.js

@@ -941,7 +941,7 @@ var policies = {
     "totalRequests": {
     "totalRequests": {
         "tool": "redownload",
         "tool": "redownload",
         "label": "Requests number",
         "label": "Requests number",
-        "message": "<p>This is one of the most important performance rule. Every request is slowing down the page loading.</p><p>There are several technics to reduce their number:<ul><li>Concatenate JS files</li><li>Concatenate CSS files</li><li>Embed or inline small JS or CSS files in the HTML</li><li>Create sprites or icon fonts</li><li>Base64 encode small images in HTML or stylesheets</li><li>Use lazyloading for images</li></ul></p>",
+        "message": "<p>This is one of the most important performance rule. Every request is slowing down the page loading.</p><p>There are several technics to reduce their number:<ul><li>Concatenate JS files</li><li>Concatenate CSS files</li><li>Embed or inline small JS or CSS files in the HTML</li><li>Create sprites</li><li>Base64 encode small images in HTML or stylesheets</li><li>Use lazyloading for images</li></ul></p>",
         "isOkThreshold": 15,
         "isOkThreshold": 15,
         "isBadThreshold": 100,
         "isBadThreshold": 100,
         "isAbnormalThreshold": 180,
         "isAbnormalThreshold": 180,
@@ -1042,6 +1042,43 @@ var policies = {
         "isAbnormalThreshold": 30,
         "isAbnormalThreshold": 30,
         "hasOffenders": true
         "hasOffenders": true
     },
     },
+    "fontsCount": {
+        "tool": "redownload",
+        "label": "Webfonts number",
+        "message": "<p>This is the number of custom web fonts loaded on the page.</p><p>Webfonts are beautiful, but heavy. You should keep their number as low as possible.</p>",
+        "isOkThreshold": 1,
+        "isBadThreshold": 5,
+        "isAbnormalThreshold": 7,
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            return offenders;
+        }
+    },
+    "heavyFonts": {
+        "tool": "redownload",
+        "label": "Overweighted webfonts",
+        "message": "<p>This metric is the sum of all bytes above 40KB in loaded fonts. Over this size, the font is probably not optimized for the web.</p><p>It can be a compresson issue, a font that contains too many glyphs or a font with complex shapes.</p><p>Sorry, Yellow Lab Tools is not yet compatible with the WOFF2 font format that generates 20-30% smaller fonts. You can proceed to a manual verification on a modern browser.</p>",
+        "isOkThreshold": 0,
+        "isBadThreshold": 102400,
+        "isAbnormalThreshold": 204800,
+        "unit": 'bytes',
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            return offenders;
+        }
+    },
+    "unusedUnicodeRanges": {
+        "tool": "redownload",
+        "label": "Unused Unicode ranges",
+        "message": "<p>This metric counts the number of unused Unicode ranges inside each font. For example, one font could include Cyrillic glyphs but none of them are used on the page.</p><p>Because of technical limitations, Yellow Lab Tools checks each font against the glyphs of the entire page. As a result, estimated use is >= to reality. For example, if you read that 10 glyphs are \"possibly used\", it means that these 10 glyphs are used on the page but nothing guaranties that they are displayed using this font.</p><p>Tools such as <a href=\"https://www.fontsquirrel.com/tools/webfont-generator\" target=\"_blank\">Font Squirrel</a> can remove some unicode ranges from a font.</p><p>In the case of an icon font, make sure you only keep the icons that are used on the website and to remove the others. Several tools are able to extract SVG images from a font, then some other tools can generate a font from the SVGs you want to keep.</p>",
+        "isOkThreshold": 0,
+        "isBadThreshold": 8,
+        "isAbnormalThreshold": 12,
+        "hasOffenders": true,
+        "offendersTransformFn": function(offenders) {
+            return offenders;
+        }
+    },
     "http2": {
     "http2": {
         "label": "HTTP/2 or SPDY",
         "label": "HTTP/2 or SPDY",
         "message": "<p>HTTP/2 is the latest version of the HTTP protocol and is designed to optimize load speed. SPDY is deprecated but still very well supported.</p><p>The latest versions of all major browsers are now compatible. The difficulty is on the server side, where technologies are not quite ready yet.</p>",
         "message": "<p>HTTP/2 is the latest version of the HTTP protocol and is designed to optimize load speed. SPDY is deprecated but still very well supported.</p><p>The latest versions of all major browsers are now compatible. The difficulty is on the server side, where technologies are not quite ready yet.</p>",

+ 9 - 0
lib/metadata/scoreProfileGeneric.json

@@ -93,6 +93,14 @@
                 "cssRedundantChildNodesSelectors": 1
                 "cssRedundantChildNodesSelectors": 1
             }
             }
         },
         },
+        "fonts": {
+            "label": "Web fonts",
+            "policies": {
+                "fontsCount": 1,
+                "heavyFonts": 1,
+                "unusedUnicodeRanges": 1
+            }
+        },
         "serverConfig": {
         "serverConfig": {
             "label": "Server config",
             "label": "Server config",
             "policies": {
             "policies": {
@@ -115,6 +123,7 @@
         "cssSyntaxError": 1,
         "cssSyntaxError": 1,
         "cssComplexity": 1,
         "cssComplexity": 1,
         "badCSS": 1,
         "badCSS": 1,
+        "fonts": 1,
         "serverConfig": 1
         "serverConfig": 1
     }
     }
 }
 }

+ 71 - 0
lib/tools/phantomas/custom_modules/modules/charactersCount/charactersCount.js

@@ -0,0 +1,71 @@
+/**
+ * Lists the number of different characters
+ */
+/* global document: true, Node: true, window: true */
+
+exports.version = '1.0';
+
+exports.module = function(phantomas) {
+    'use strict';
+
+    //phantomas.setMetric('differentCharacters'); // @desc the number of different characters in the body @offenders
+
+    phantomas.on('report', function() {
+        phantomas.log("charactersCount: starting to analyze characters on page...");
+
+        phantomas.evaluate(function() {
+            (function(phantomas) {
+
+                phantomas.spyEnabled(false, 'analyzing characters on page');
+
+                function getLetterCount(arr){
+                    return arr.reduce(function(prev, next){
+                        prev[next] = 1;
+                        return prev;
+                    }, {});
+                }
+
+                if (document.body && document.body.textContent) {
+                    var allText = '';
+
+                    // Traverse all DOM Tree to read text
+                    var runner = new phantomas.nodeRunner();
+                    runner.walk(document.body, function(node, depth) {
+                        switch (node.nodeType) {
+
+                            // Grabing text nodes
+                            case Node.TEXT_NODE:
+                                if (node.parentNode.tagName !== 'SCRIPT' && node.parentNode.tagName !== 'STYLE') {
+                                    allText += node.nodeValue;
+                                }
+                                break;
+
+                            // Grabing CSS content properties
+                            case Node.ELEMENT_NODE:
+                                if (node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE') {
+                                    allText += window.getComputedStyle(node).getPropertyValue('content');
+                                    allText += window.getComputedStyle(node, ':before').getPropertyValue('content');
+                                    allText += window.getComputedStyle(node, ':after').getPropertyValue('content');
+                                }
+                                break;
+                        }
+                    });
+
+                    // Reduce all text found in page into a string of unique chars
+                    var charactersList = getLetterCount(allText.split(''));
+                    var charsetString = Object.keys(charactersList).sort().join('');
+                    
+                    // Remove blank characters
+                    charsetString = charsetString.replace(/\s/g, '');
+
+                    phantomas.setMetric('differentCharacters', charsetString.length);
+                    phantomas.addOffender('differentCharacters', charsetString);
+                }
+
+                phantomas.spyEnabled(true);
+            }(window.__phantomas));
+        });
+
+        phantomas.log("charactersCount: analyzing characters done.");
+    });
+};

+ 13 - 13
lib/tools/redownload/contentTypeChecker.js

@@ -18,11 +18,11 @@ var ContentTypeChecker = function() {
         debug('Entering contentTypeChecker');
         debug('Entering contentTypeChecker');
         
         
         // Ignore very small files as they are generally tracking pixels
         // Ignore very small files as they are generally tracking pixels
-        if (entry.weightCheck && entry.weightCheck.body && entry.weightCheck.bodySize > 100) {
+        if (entry.weightCheck && entry.weightCheck.bodyBuffer && entry.weightCheck.bodySize > 100) {
             var foundType;
             var foundType;
 
 
             try {
             try {
-                foundType = findContentType(entry.weightCheck.body);
+                foundType = findContentType(entry.weightCheck.bodyBuffer);
             
             
                 if (!entry.contentType || entry.contentType === '') {
                 if (!entry.contentType || entry.contentType === '') {
                     if (foundType === null) {
                     if (foundType === null) {
@@ -51,43 +51,43 @@ var ContentTypeChecker = function() {
         return deferred.promise;
         return deferred.promise;
     }
     }
 
 
-    function findContentType(body) {
-        var buffer = new Buffer(body, 'binary');
+    function findContentType(bodyBuffer) {
+        var bodyStr = bodyBuffer.toString();
 
 
-        if (isJpg(buffer)) {
+        if (isJpg(bodyBuffer)) {
             return contentTypes.jpeg;
             return contentTypes.jpeg;
         }
         }
 
 
-        if (isPng(buffer)) {
+        if (isPng(bodyBuffer)) {
             return contentTypes.png;
             return contentTypes.png;
         }
         }
 
 
         // https://github.com/sindresorhus/is-svg/issues/7
         // https://github.com/sindresorhus/is-svg/issues/7
-        if (/<svg/.test(body) && isSvg(body)) {
+        if (/<svg/.test(bodyStr) && isSvg(bodyStr)) {
             return contentTypes.svg;
             return contentTypes.svg;
         }
         }
 
 
-        if (isGif(buffer)) {
+        if (isGif(bodyBuffer)) {
             return contentTypes.gif;
             return contentTypes.gif;
         }
         }
 
 
-        if (isWoff(buffer)) {
+        if (isWoff(bodyBuffer)) {
             return contentTypes.woff;
             return contentTypes.woff;
         }
         }
 
 
-        if (isWoff2(buffer)) {
+        if (isWoff2(bodyBuffer)) {
             return contentTypes.woff2;
             return contentTypes.woff2;
         }
         }
 
 
-        if (isOtf(buffer)) {
+        if (isOtf(bodyBuffer)) {
             return contentTypes.otf;
             return contentTypes.otf;
         }
         }
 
 
-        if (isTtf(buffer)) {
+        if (isTtf(bodyBuffer)) {
             return contentTypes.ttf;
             return contentTypes.ttf;
         }
         }
 
 
-        if (isEot(buffer)) {
+        if (isEot(bodyBuffer)) {
             return contentTypes.eot;
             return contentTypes.eot;
         }
         }
 
 

+ 7 - 5
lib/tools/redownload/fileMinifier.js

@@ -11,22 +11,24 @@ var FileMinifier = function() {
     function minifyFile(entry) {
     function minifyFile(entry) {
         var deferred = Q.defer();
         var deferred = Q.defer();
 
 
-        if (!entry.weightCheck || !entry.weightCheck.body) {
+        if (!entry.weightCheck || !entry.weightCheck.bodyBuffer) {
             // No valid file available
             // No valid file available
             deferred.resolve(entry);
             deferred.resolve(entry);
             return deferred.promise;
             return deferred.promise;
         }
         }
 
 
         var fileSize = entry.weightCheck.uncompressedSize;
         var fileSize = entry.weightCheck.uncompressedSize;
+        var bodyString = entry.weightCheck.bodyBuffer.toString();
+
         debug('Let\'s try to optimize %s', entry.url);
         debug('Let\'s try to optimize %s', entry.url);
         debug('Current file size is %d', fileSize);
         debug('Current file size is %d', fileSize);
         var startTime = Date.now();
         var startTime = Date.now();
 
 
-        if (entry.isJS && !isKnownAsMinified(entry.url) && !looksAlreadyMinified(entry.weightCheck.body)) {
+        if (entry.isJS && !isKnownAsMinified(entry.url) && !looksAlreadyMinified(bodyString)) {
 
 
             debug('File is a JS');
             debug('File is a JS');
 
 
-            return minifyJs(entry.weightCheck.body)
+            return minifyJs(bodyString)
 
 
             .then(function(newFile) {
             .then(function(newFile) {
                 if (!newFile) {
                 if (!newFile) {
@@ -58,7 +60,7 @@ var FileMinifier = function() {
 
 
             debug('File is a CSS');
             debug('File is a CSS');
 
 
-            return minifyCss(entry.weightCheck.body)
+            return minifyCss(entry.weightCheck.bodyBuffer.toString())
 
 
             .then(function(newFile) {
             .then(function(newFile) {
                 if (!newFile) {
                 if (!newFile) {
@@ -91,7 +93,7 @@ var FileMinifier = function() {
 
 
             debug('File is an HTML');
             debug('File is an HTML');
 
 
-            return minifyHtml(entry.weightCheck.body)
+            return minifyHtml(entry.weightCheck.bodyBuffer.toString())
 
 
             .then(function(newFile) {
             .then(function(newFile) {
                 if (!newFile) {
                 if (!newFile) {

+ 379 - 0
lib/tools/redownload/fontAnalyzer.js

@@ -0,0 +1,379 @@
+var debug = require('debug')('ylt:fontAnalyzer');
+
+var Q           = require('q');
+var fontkit     = require('fontkit');
+
+var FontAnalyzer = function() {
+
+    function analyzeFont(entry, charsListOnPage) {
+        var deferred = Q.defer();
+        
+        if (!entry.weightCheck || !entry.weightCheck.bodyBuffer) {
+            // No valid file available
+            deferred.resolve(entry);
+            return deferred.promise;
+        }
+
+        var fileSize = entry.weightCheck.uncompressedSize;
+
+        if (entry.isWebFont) {
+            debug('File %s is a font. Let\'s have a look inside!', entry.url);
+            
+            getMetricsFromFont(entry, charsListOnPage)
+
+            .then(function(fontMetrics) {
+                entry.fontMetrics = fontMetrics;
+                deferred.resolve(entry);
+            })
+
+            .fail(function(error) {
+                debug('Could not open the font: %s', error);
+                deferred.resolve(entry);
+            });
+
+        } else {
+            deferred.resolve(entry);
+        }
+        
+        return deferred.promise;
+    }
+
+    function getMetricsFromFont(entry, charsListOnPage) {
+        var deferred = Q.defer();
+        
+        try {
+            var startTime = Date.now();
+            var font = fontkit.create(entry.weightCheck.bodyBuffer);
+
+            var result = {
+                name: font.fullName || font.postscriptName || font.familyName,
+                numGlyphs: font.numGlyphs,
+                averageGlyphComplexity: getAverageGlyphComplexity(font),
+                compressedWeight: entry.weightCheck.afterCompression || entry.weightCheck.bodySize,
+                unicodeRanges: readUnicodeRanges(font.characterSet, charsListOnPage),
+                numGlyphsInCommonWithPageContent: countPossiblyUsedGlyphs(getCharacterSetAsString(font.characterSet), charsListOnPage)
+            };
+
+            var endTime = Date.now();
+            debug('Font analysis took %dms', endTime - startTime);
+
+            deferred.resolve(result);
+
+        } catch(error) {
+            deferred.reject(error);
+        }
+
+        return deferred.promise;
+    }
+
+    // Reads the number of vector commands (complexity) needed to render glyphs and
+    // returns the average. Only first 100 glyphes are tested, otherwise it would take tool long;
+    function getAverageGlyphComplexity(font) {
+        var max = Math.min(font.numGlyphs, 100);
+
+        var totalPathsCommands = 0;
+        for (var i = 0; i < max; i++) {
+            totalPathsCommands += font.getGlyph(i).path.commands.length;
+        }
+        return Math.round(totalPathsCommands / max * 10) / 10;
+    }
+
+    function readUnicodeRanges(charsetInFont, charsListOnPage) {
+        var ranges = {};
+
+        // Assign a range to each char found in the font
+        charsetInFont.forEach(function(char) {
+            
+            var currentRange = getUnicodeRangeFromChar(char);
+            var currentRangeName = currentRange.name;
+
+            if (!ranges[currentRangeName]) {
+                // Cloning the object
+                ranges[currentRangeName] = Object.assign({}, currentRange);
+            }
+
+            if (!ranges[currentRangeName].charset) {
+                ranges[currentRangeName].charset = '';
+            }
+
+            ranges[currentRangeName].charset += String.fromCharCode(char);
+        });
+
+        var range;
+        var expectedLength;
+        var actualLength;
+
+        for (var rangeName in ranges) {
+            /*jshint loopfunc: true */
+
+            range = ranges[rangeName];
+
+            // Estimate if range is used, based on the characters found in the page
+            range.numGlyphsInCommonWithPageContent = countPossiblyUsedGlyphs(range.charset, charsListOnPage);
+
+            // Calculate coverage
+            if (rangeName !== 'Others') {
+                expectedLength = range.rangeEnd - range.rangeStart + 1;
+                actualLength = range.charset.length;
+                range.coverage = Math.min(actualLength / expectedLength, 1);
+            }
+        }
+
+        return ranges;
+    }
+
+    function countPossiblyUsedGlyphs(charsetInFont, charsListOnPage) {
+        var count = 0;
+        charsListOnPage.split('').forEach(function(char) {
+            if (charsetInFont.indexOf(char) >= 0) {
+                count ++;
+            }
+        });
+        return count;
+    }
+
+    function getCharacterSetAsString(characterSet) {
+        var str = '';
+        characterSet.forEach(function(charCode) {
+            str += String.fromCharCode(charCode);
+        });
+        return str;
+    }
+
+    function getUnicodeRangeFromChar(char) {
+        return UNICODE_RANGES.find(function(range) {
+            return (char >= range.rangeStart && char <= range.rangeEnd);
+        }) || {name: 'Others'};
+    }
+
+
+    var UNICODE_RANGES = [
+        {
+            name: 'Basic Latin',
+            rangeStart: 0x0020,
+            rangeEnd: 0x007F
+        },
+        {
+            name: 'Latin-1 Supplement',
+            rangeStart: 0x00A0,
+            rangeEnd: 0x00FF
+        },
+        {
+            name: 'Latin Extended',
+            rangeStart: 0x0100,
+            rangeEnd: 0x024F
+        },
+        {
+            name: 'IPA Extensions',
+            rangeStart: 0x0250,
+            rangeEnd: 0x02AF
+        },
+        {
+            name: 'Greek and Coptic',
+            rangeStart: 0x0370,
+            rangeEnd: 0x03FF
+        },
+        {
+            name: 'Cyrillic',
+            rangeStart: 0x0400,
+            rangeEnd: 0x052F
+        },
+        {
+            name: 'Armenian',
+            rangeStart: 0x0530,
+            rangeEnd: 0x058F
+        },
+        {
+            name: 'Hebrew',
+            rangeStart: 0x0590,
+            rangeEnd: 0x05FF
+        },
+        {
+            name: 'Arabic',
+            rangeStart: 0x0600,
+            rangeEnd: 0x06FF
+        },
+        {
+            name: 'Syriac',
+            rangeStart: 0x0700,
+            rangeEnd: 0x074F
+        },
+        {
+            name: 'Thaana',
+            rangeStart: 0x0780,
+            rangeEnd: 0x07BF
+        },
+        {
+            name: 'Devanagari',
+            rangeStart: 0x0900,
+            rangeEnd: 0x097F
+        },
+        {
+            name: 'Bengali',
+            rangeStart: 0x0980,
+            rangeEnd: 0x09FF
+        },
+        {
+            name: 'Gurmukhi',
+            rangeStart: 0x0A00,
+            rangeEnd: 0x0A7F
+        },
+        {
+            name: 'Gujarati',
+            rangeStart: 0x0A80,
+            rangeEnd: 0x0AFF
+        },
+        {
+            name: 'Oriya',
+            rangeStart: 0x0B00,
+            rangeEnd: 0x0B7F
+        },
+        {
+            name: 'Tamil',
+            rangeStart: 0x0B80,
+            rangeEnd: 0x0BFF
+        },
+        {
+            name: 'Telugu',
+            rangeStart: 0x0C00,
+            rangeEnd: 0x0C7F
+        },
+        {
+            name: 'Kannada',
+            rangeStart: 0x0C80,
+            rangeEnd: 0x0CFF
+        },
+        {
+            name: 'Malayalam',
+            rangeStart: 0x0D00,
+            rangeEnd: 0x0D7F
+        },
+        {
+            name: 'Sinhala',
+            rangeStart: 0x0D80,
+            rangeEnd: 0x0DFF
+        },
+        {
+            name: 'Thai',
+            rangeStart: 0x0E00,
+            rangeEnd: 0x0E7F
+        },
+        {
+            name: 'Lao',
+            rangeStart: 0x0E80,
+            rangeEnd: 0x0EFF
+        },
+        {
+            name: 'Tibetan',
+            rangeStart: 0x0F00,
+            rangeEnd: 0x0FFF
+        },
+        {
+            name: 'Myanmar',
+            rangeStart: 0x1000,
+            rangeEnd: 0x109F
+        },
+        {
+            name: 'Georgian',
+            rangeStart: 0x10A0,
+            rangeEnd: 0x10FF
+        },
+        {
+            name: 'Hangul Jamo',
+            rangeStart: 0x1100,
+            rangeEnd: 0x11FF
+        },
+        {
+            name: 'Ethiopic',
+            rangeStart: 0x1200,
+            rangeEnd: 0x137F
+        },
+        {
+            name: 'Cherokee',
+            rangeStart: 0x13A0,
+            rangeEnd: 0x13FF
+        },
+        {
+            name: 'Unified Canadian Aboriginal Syllabics',
+            rangeStart: 0x1400,
+            rangeEnd: 0x167F
+        },
+        {
+            name: 'Ogham',
+            rangeStart: 0x1680,
+            rangeEnd: 0x169F
+        },
+        {
+            name: 'Runic',
+            rangeStart: 0x16A0,
+            rangeEnd: 0x16FF
+        },
+        {
+            name: 'Tagalog',
+            rangeStart: 0x1700,
+            rangeEnd: 0x171F
+        },
+        {
+            name: 'Hanunoo',
+            rangeStart: 0x1720,
+            rangeEnd: 0x173F
+        },
+        {
+            name: 'Buhid',
+            rangeStart: 0x1740,
+            rangeEnd: 0x175F
+        },
+        {
+            name: 'Tagbanwa',
+            rangeStart: 0x1760,
+            rangeEnd: 0x177F
+        },
+        {
+            name: 'Khmer',
+            rangeStart: 0x1780,
+            rangeEnd: 0x17FF
+        },
+        {
+            name: 'Mongolian',
+            rangeStart: 0x1800,
+            rangeEnd: 0x18AF
+        },
+        {
+            name: 'Limbu',
+            rangeStart: 0x1900,
+            rangeEnd: 0x194F
+        },
+        {
+            name: 'Tai Le',
+            rangeStart: 0x1950,
+            rangeEnd: 0x197F
+        },
+        {
+            name: 'Hiragana',
+            rangeStart: 0x3040,
+            rangeEnd: 0x309F
+        },
+        {
+            name: 'Katakana',
+            rangeStart: 0x30A0,
+            rangeEnd: 0x30FF
+        },
+        {
+            name: 'Bopomofo',
+            rangeStart: 0x3100,
+            rangeEnd: 0x312F
+        }
+    ];
+
+    return {
+        analyzeFont: analyzeFont,
+        getMetricsFromFont: getMetricsFromFont,
+        readUnicodeRanges: readUnicodeRanges,
+        getAverageGlyphComplexity: getAverageGlyphComplexity,
+        countPossiblyUsedGlyphs: countPossiblyUsedGlyphs,
+        getCharacterSetAsString: getCharacterSetAsString,
+        getUnicodeRangeFromChar: getUnicodeRangeFromChar
+    };
+};
+
+module.exports = new FontAnalyzer();

+ 2 - 2
lib/tools/redownload/gzipCompressor.js

@@ -16,12 +16,12 @@ var GzipCompressor = function() {
     function gzipUncompressedFile(entry) {
     function gzipUncompressedFile(entry) {
         var deferred = Q.defer();
         var deferred = Q.defer();
 
 
-        if (entryTypeCanBeGzipped(entry) && entry.weightCheck && !entry.weightCheck.isCompressed && entry.weightCheck.body) {
+        if (entryTypeCanBeGzipped(entry) && entry.weightCheck && !entry.weightCheck.isCompressed && entry.weightCheck.bodyBuffer) {
             debug('Compression missing, trying to gzip file %s', entry.url);
             debug('Compression missing, trying to gzip file %s', entry.url);
 
 
             var uncompressedSize = entry.weightCheck.uncompressedSize;
             var uncompressedSize = entry.weightCheck.uncompressedSize;
 
 
-            zlib.gzip(new Buffer(entry.weightCheck.body, 'utf8'), function(err, buffer) {
+            zlib.gzip(entry.weightCheck.bodyBuffer, function(err, buffer) {
                 if (err) {
                 if (err) {
                     debug('Could not compress uncompressed file with gzip');
                     debug('Could not compress uncompressed file with gzip');
                     debug(err);
                     debug(err);

+ 5 - 5
lib/tools/redownload/imageOptimizer.js

@@ -14,7 +14,7 @@ var ImageOptimizer = function() {
     function optimizeImage(entry) {
     function optimizeImage(entry) {
         var deferred = Q.defer();
         var deferred = Q.defer();
 
 
-        if (!entry.weightCheck || !entry.weightCheck.body) {
+        if (!entry.weightCheck || !entry.weightCheck.bodyBuffer) {
             // No valid file available
             // No valid file available
             deferred.resolve(entry);
             deferred.resolve(entry);
             return deferred.promise;
             return deferred.promise;
@@ -28,7 +28,7 @@ var ImageOptimizer = function() {
             debug('File is a JPEG');
             debug('File is a JPEG');
 
 
             // Starting softly with a lossless compression
             // Starting softly with a lossless compression
-            return compressJpegLosslessly(new Buffer(entry.weightCheck.body, 'binary'))
+            return compressJpegLosslessly(entry.weightCheck.bodyBuffer)
 
 
             .then(function(newFile) {
             .then(function(newFile) {
                 if (!newFile) {
                 if (!newFile) {
@@ -48,7 +48,7 @@ var ImageOptimizer = function() {
 
 
 
 
                 // Now let's compress lossy to MAX_JPEG_QUALITY
                 // Now let's compress lossy to MAX_JPEG_QUALITY
-                return compressJpegLossly(new Buffer(entry.weightCheck.body, 'binary'));
+                return compressJpegLossly(entry.weightCheck.bodyBuffer);
             })
             })
             
             
             .then(function(newFile) {
             .then(function(newFile) {
@@ -86,7 +86,7 @@ var ImageOptimizer = function() {
             debug('File is a PNG');
             debug('File is a PNG');
 
 
             // Starting softly with a lossless compression
             // Starting softly with a lossless compression
-            return compressPngLosslessly(new Buffer(entry.weightCheck.body, 'binary'))
+            return compressPngLosslessly(entry.weightCheck.bodyBuffer)
 
 
             .then(function(newFile) {
             .then(function(newFile) {
                 if (!newFile) {
                 if (!newFile) {
@@ -120,7 +120,7 @@ var ImageOptimizer = function() {
             debug('File is an SVG');
             debug('File is an SVG');
 
 
             // Starting softly with a lossless compression
             // Starting softly with a lossless compression
-            return compressSvgLosslessly(new Buffer(entry.weightCheck.body, 'utf8'))
+            return compressSvgLosslessly(entry.weightCheck.bodyBuffer)
 
 
             .then(function(newFile) {
             .then(function(newFile) {
                 if (!newFile) {
                 if (!newFile) {

+ 153 - 11
lib/tools/redownload/redownload.js

@@ -18,6 +18,7 @@ var imageOptimizer      = require('./imageOptimizer');
 var fileMinifier        = require('./fileMinifier');
 var fileMinifier        = require('./fileMinifier');
 var gzipCompressor      = require('./gzipCompressor');
 var gzipCompressor      = require('./gzipCompressor');
 var contentTypeChecker  = require('./contentTypeChecker');
 var contentTypeChecker  = require('./contentTypeChecker');
+var fontAnalyzer        = require('./fontAnalyzer');
 
 
 
 
 var Redownload = function() {
 var Redownload = function() {
@@ -44,6 +45,12 @@ var Redownload = function() {
             };
             };
         }
         }
 
 
+        // Prevent a bug with the font analyzer on empty pages
+        var differentCharacters = '';
+        if (data.toolsResults.phantomas.offenders.differentCharacters && data.toolsResults.phantomas.offenders.differentCharacters.length > 0) {
+            differentCharacters = data.toolsResults.phantomas.offenders.differentCharacters[0];
+        }
+
         // Transform every request into a download function with a callback when done
         // Transform every request into a download function with a callback when done
         var redownloadList = requestsList.map(function(entry) {
         var redownloadList = requestsList.map(function(entry) {
             return function(callback) {
             return function(callback) {
@@ -58,8 +65,12 @@ var Redownload = function() {
 
 
                 .then(gzipCompressor.compressFile)
                 .then(gzipCompressor.compressFile)
 
 
+                .then(function(entry) {
+                    return fontAnalyzer.analyzeFont(entry, differentCharacters);
+                })
+
                 .then(function(newEntry) {
                 .then(function(newEntry) {
-                    debug('File %s - Redownloaded, optimized, minified, compressed: done', entry.url);
+                    debug('File %s - Redownloaded, optimized, minified, compressed, analyzed: done', entry.url);
                     callback(null, newEntry);
                     callback(null, newEntry);
                 })
                 })
 
 
@@ -134,6 +145,18 @@ var Redownload = function() {
                 offenders.identicalFiles = listIdenticalFiles(results);
                 offenders.identicalFiles = listIdenticalFiles(results);
                 metrics.identicalFiles = offenders.identicalFiles.avoidableRequests;
                 metrics.identicalFiles = offenders.identicalFiles.avoidableRequests;
 
 
+                // Fonts count
+                offenders.fontsCount = listFonts(results);
+                metrics.fontsCount = offenders.fontsCount.count;
+
+                // Heavy fonts
+                offenders.heavyFonts = listHeavyFonts(results);
+                metrics.heavyFonts = offenders.heavyFonts.totalGain;
+
+                // Unused Unicode ranges
+                offenders.unusedUnicodeRanges = listUnusedUnicodeRanges(results);
+                metrics.unusedUnicodeRanges = offenders.unusedUnicodeRanges.count;
+
 
 
                 data.toolsResults.redownload = {
                 data.toolsResults.redownload = {
                     metrics: metrics,
                     metrics: metrics,
@@ -381,7 +404,7 @@ var Redownload = function() {
         var avoidableRequestsCount = 0;
         var avoidableRequestsCount = 0;
 
 
         requests.forEach(function(req) {
         requests.forEach(function(req) {
-            var requestHash = md5(req.weightCheck.body);
+            var requestHash = md5(req.weightCheck.bodyBuffer);
             
             
             // Try to exclude tracking pixels
             // Try to exclude tracking pixels
             if (req.weightCheck.bodySize < 80 && req.type === 'image') {
             if (req.weightCheck.bodySize < 80 && req.type === 'image') {
@@ -414,6 +437,126 @@ var Redownload = function() {
         };
         };
     }
     }
 
 
+    function listFonts(requests) {
+        var list = [];
+
+        requests.forEach(function(req) {
+            if (req.isWebFont) {
+                list.push({
+                    url: req.url,
+                    size: req.weightCheck.bodySize
+                });
+            }
+        });
+
+        return {
+            count: list.length,
+            list: list
+        };
+    }
+
+    function listHeavyFonts(requests) {
+        var list = [];
+        var totalGain = 0;
+        var heavyFontsCount = 0;
+        var MAX_FONT_WEIGHT = 40 * 1024;
+
+        requests.forEach(function(req) {
+            if (req.isWebFont && req.fontMetrics) {
+                list.push({
+                    url: req.url,
+                    weight: req.weightCheck.bodySize,
+                    numGlyphs: req.fontMetrics.numGlyphs,
+                    averageGlyphComplexity: req.fontMetrics.averageGlyphComplexity
+                });
+                if (req.weightCheck.bodySize > MAX_FONT_WEIGHT) {
+                    totalGain += req.weightCheck.bodySize - MAX_FONT_WEIGHT;
+                    heavyFontsCount ++;
+                }
+            }
+        });
+
+        return {
+            count: heavyFontsCount,
+            fonts: list,
+            totalGain: totalGain
+        };
+    }
+
+    function listUnusedUnicodeRanges(requests) {
+        var list = [];
+        var unusedUnicodeRanges = 0;
+
+        requests.forEach(function(req) {
+            if (req.isWebFont && req.fontMetrics && req.fontMetrics.unicodeRanges) {
+                var ranges = [];
+                var others = null;
+                var rangeNames = Object.keys(req.fontMetrics.unicodeRanges);
+                
+                rangeNames.forEach(function(rangeName) {
+                    var range = req.fontMetrics.unicodeRanges[rangeName];
+
+                    // Exclude "Others"
+                    if (rangeName === 'Others') {
+                        if (range.numGlyphsInCommonWithPageContent === 0 && range.charset.length > 50) {
+                            range.underused = true;
+                            unusedUnicodeRanges ++;
+                        }
+
+                        others = range;
+                    } else if (range.charset.length > 0) {
+                        // Now lets detect if the current Unicode range is unused.
+                        // Reminder: range.coverage = glyphs declared in this range, divided by the range size
+                        if (range.coverage > 0.25 && range.numGlyphsInCommonWithPageContent === 0) {
+                            range.underused = true;
+                            unusedUnicodeRanges ++;
+                        }
+
+                        ranges.push(range);
+                    }
+                });
+
+
+                // Detect if it's a icons font : if more than 90% of the icons are
+                // in the "Others", it looks like one.
+                if (others && others.charset.length / req.fontMetrics.numGlyphs > 0.9) {
+                    
+                    list.push({
+                        url: req.url,
+                        weight: req.weightCheck.bodySize,
+                        isIconFont: true,
+                        glyphs: req.fontMetrics.numGlyphs,
+                        numGlyphsInCommonWithPageContent: req.fontMetrics.numGlyphsInCommonWithPageContent
+                    });
+
+                    // And if less than 5% of the icons are used, let's report it as underused
+                    if (others && others.numGlyphsInCommonWithPageContent / others.charset.length <= 0.05) {
+                        unusedUnicodeRanges ++;
+                    }
+
+                // Not an icons font
+                } else {
+                    if (others) {
+                        // Insert back "Others" at the end of the list
+                        ranges.push(others);
+                    }
+
+                    list.push({
+                        url: req.url,
+                        weight: req.weightCheck.bodySize,
+                        isIconFont: false,
+                        unicodeRanges: ranges
+                    });
+                }
+            }
+        });
+
+        return {
+            count: unusedUnicodeRanges,
+            fonts: list
+        };
+    }
+
 
 
     function redownloadEntry(entry, httpAuth) {
     function redownloadEntry(entry, httpAuth) {
         var deferred = Q.defer();
         var deferred = Q.defer();
@@ -521,7 +664,7 @@ var Redownload = function() {
 
 
                 var uncompressedSize = 0;  // size after uncompression
                 var uncompressedSize = 0;  // size after uncompression
                 var bodySize = 0;  // bytes size over the wire
                 var bodySize = 0;  // bytes size over the wire
-                var body = '';  // plain text body (after uncompressing gzip/deflate)
+                var bodyChunks = [];  // an array of buffers
                 var isCompressed = false;
                 var isCompressed = false;
 
 
                 function tally() {
                 function tally() {
@@ -531,8 +674,10 @@ var Redownload = function() {
                         return;
                         return;
                     }
                     }
 
 
+                    var body = Buffer.concat(bodyChunks);
+
                     var result = {
                     var result = {
-                        body: body,
+                        bodyBuffer: body,
                         headersSize: Buffer.byteLength(rawHeaders, 'utf8'),
                         headersSize: Buffer.byteLength(rawHeaders, 'utf8'),
                         bodySize: bodySize,
                         bodySize: bodySize,
                         isCompressed: isCompressed,
                         isCompressed: isCompressed,
@@ -548,7 +693,8 @@ var Redownload = function() {
                         var gzip = zlib.createGunzip();
                         var gzip = zlib.createGunzip();
 
 
                         gzip.on('data', function (data) {
                         gzip.on('data', function (data) {
-                            body += data;
+
+                            bodyChunks.push(data);
                             uncompressedSize += data.length;
                             uncompressedSize += data.length;
                         }).on('end', function () {
                         }).on('end', function () {
                             isCompressed = true;
                             isCompressed = true;
@@ -570,7 +716,7 @@ var Redownload = function() {
                         var deflate = zlib.createInflate();
                         var deflate = zlib.createInflate();
 
 
                         deflate.on('data', function (data) {
                         deflate.on('data', function (data) {
-                            body += data;
+                            bodyChunks.push(data);
                             uncompressedSize += data.length;
                             uncompressedSize += data.length;
                         }).on('end', function () {
                         }).on('end', function () {
                             isCompressed = true;
                             isCompressed = true;
@@ -587,12 +733,8 @@ var Redownload = function() {
 
 
                         break;
                         break;
                     default:
                     default:
-                        if (contentType === 'image/jpeg' || contentType === 'image/png') {
-                            res.setEncoding('binary');
-                        }
-
                         res.on('data', function (data) {
                         res.on('data', function (data) {
-                            body += data;
+                            bodyChunks.push(data);
                             uncompressedSize += data.length;
                             uncompressedSize += data.length;
                             bodySize += data.length;
                             bodySize += data.length;
                         }).on('end', function () {
                         }).on('end', function () {

+ 1 - 0
package.json

@@ -39,6 +39,7 @@
     "easyxml": "2.0.1",
     "easyxml": "2.0.1",
     "ejs": "^2.5.1",
     "ejs": "^2.5.1",
     "express": "4.14.0",
     "express": "4.14.0",
+    "fontkit": "1.4.2",
     "imagemin": "5.2.2",
     "imagemin": "5.2.2",
     "imagemin-jpegoptim": "5.0.0",
     "imagemin-jpegoptim": "5.0.0",
     "imagemin-jpegtran": "5.0.2",
     "imagemin-jpegtran": "5.0.2",

+ 1 - 1
server_config/server_install.sh

@@ -3,7 +3,7 @@
 # APT-GET
 # APT-GET
 sudo apt-get update
 sudo apt-get update
 sudo apt-get install lsb-release libfontconfig1 libfreetype6 libjpeg-dev -y --force-yes > /dev/null 2>&1
 sudo apt-get install lsb-release libfontconfig1 libfreetype6 libjpeg-dev -y --force-yes > /dev/null 2>&1
-sudo apt-get install curl git python-software-properties build-essential make g++ -y --force-yes > /dev/null 2>&1
+sudo apt-get install curl git software-properties-common build-essential make g++ -y --force-yes > /dev/null 2>&1
 
 
 # Installation of NodeJS
 # Installation of NodeJS
 curl -sL https://deb.nodesource.com/setup_0.12 | sudo -E bash -
 curl -sL https://deb.nodesource.com/setup_0.12 | sudo -E bash -

+ 2 - 2
test/core/fileMinifierTest.js

@@ -37,7 +37,7 @@ describe('fileMinifier', function() {
             type: 'js',
             type: 'js',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -179,7 +179,7 @@ describe('fileMinifier', function() {
             type: 'css',
             type: 'css',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,

+ 112 - 0
test/core/fontAnalyzerTest.js

@@ -0,0 +1,112 @@
+var should = require('chai').should();
+var fontAnalyzer = require('../../lib/tools/redownload/fontAnalyzer');
+var fs = require('fs');
+var path = require('path');
+
+describe('fontAnalyzer', function() {
+    
+    it('should extract metrics from a font', function(done) {
+        var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/SourceSansPro/SourceSansPro-Regular.woff'));
+        var fileSize = fileContent.length;
+
+        var entry = {
+            method: 'GET',
+            url: 'http://localhost:8388/SourceSansPro/SourceSansPro-Regular.woff',
+            requestHeaders: {
+                'User-Agent': 'something',
+                Referer: 'http://www.google.fr/',
+                Accept: '*/*',
+                'Accept-Encoding': 'gzip, deflate'
+            },
+            status: 200,
+            isWebFont: true,
+            type: 'webfont',
+            contentType: 'image/jpeg',
+            contentLength: 999,
+            weightCheck: {
+                bodyBuffer: fileContent,
+                totalWeight: fileSize + 200,
+                headersSize: 200,
+                bodySize: fileSize,
+                isCompressed: false,
+                uncompressedSize: fileSize
+            }
+        };
+
+        fontAnalyzer.getMetricsFromFont(entry, 'ABCD')
+
+        .then(function(metrics) {
+
+            metrics.should.be.an('Object');
+            metrics.should.have.a.property('name').that.equals('Source Sans Pro');
+            metrics.should.have.a.property('numGlyphs').that.equals(1944);
+            metrics.should.have.a.property('averageGlyphComplexity').that.equals(26.6);
+            metrics.should.have.a.property('compressedWeight').that.equals(fileSize);
+
+            metrics.should.have.a.property('unicodeRanges').that.is.an('Object');
+            metrics.unicodeRanges.should.have.a.property('Basic Latin');
+            metrics.unicodeRanges['Basic Latin'].should.have.a.property('charset').that.is.a('String');
+            metrics.unicodeRanges['Basic Latin'].charset.length.should.equal(95);
+            metrics.unicodeRanges['Basic Latin'].name.should.equal('Basic Latin');
+            metrics.unicodeRanges['Basic Latin'].rangeStart.should.equal(0x0020);
+            metrics.unicodeRanges['Basic Latin'].rangeEnd.should.equal(0x007F);
+            metrics.unicodeRanges['Basic Latin'].coverage.should.equal(95 / 96);
+            metrics.unicodeRanges['Basic Latin'].numGlyphsInCommonWithPageContent.should.equal(4);
+
+            metrics.unicodeRanges.Cyrillic.numGlyphsInCommonWithPageContent.should.equal(0);
+
+            metrics.should.have.a.property('numGlyphsInCommonWithPageContent').that.equals(4);
+
+            should.equal(metrics.unicodeRanges.Others.coverage, undefined);
+
+            done();
+        })
+
+        .fail(function(err) {
+            done(err);
+        });
+    });
+
+    it('should sort glyphes by unicode ranges', function() {
+        var ranges = fontAnalyzer.readUnicodeRanges([0x0041, 0x0042, 0x0043, 0x0044, 0x0416], '0123AMZ');
+        
+        ranges.should.deep.equal({ 
+            'Basic Latin': {
+                name: 'Basic Latin',
+                rangeStart: 32,
+                rangeEnd: 127,
+                charset: 'ABCD',
+                coverage: 0.041666666666666664,
+                numGlyphsInCommonWithPageContent: 1
+            },
+            'Cyrillic': {
+                name: 'Cyrillic',
+                rangeStart: 1024,
+                rangeEnd: 1327,
+                charset: 'Ж',
+                coverage: 0.003289473684210526,
+                numGlyphsInCommonWithPageContent: 0
+            }
+        });
+    });
+
+    it('should transform an array of char codes into a string', function() {
+        var str = fontAnalyzer.getCharacterSetAsString([0x0041, 0x0042, 0x0043, 0x0044, 0x0416]);
+        str.should.equal('ABCDЖ');
+    });
+
+    it('should find the right unicode range for a char', function() {
+        fontAnalyzer.getUnicodeRangeFromChar(0x0020).should.deep.equal({
+            name: 'Basic Latin',
+            rangeStart: 0x0020,
+            rangeEnd: 0x007F
+        });
+
+        fontAnalyzer.getUnicodeRangeFromChar(0x0021).name.should.equal('Basic Latin');
+        fontAnalyzer.getUnicodeRangeFromChar(0x007F).name.should.equal('Basic Latin');
+        fontAnalyzer.getUnicodeRangeFromChar(0x007E).name.should.equal('Basic Latin');
+        fontAnalyzer.getUnicodeRangeFromChar(0x0000).name.should.equal('Others');
+        fontAnalyzer.getUnicodeRangeFromChar(0xFFFFFFFFF).name.should.equal('Others');
+    });
+
+});

+ 15 - 15
test/core/gzipCompressorTest.js

@@ -23,7 +23,7 @@ describe('gzipCompressor', function() {
             type: 'js',
             type: 'js',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -64,7 +64,7 @@ describe('gzipCompressor', function() {
             type: 'js',
             type: 'js',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 bodyAfterOptimization: minifiedContent.toString('utf8'),
                 bodyAfterOptimization: minifiedContent.toString('utf8'),
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
@@ -108,7 +108,7 @@ describe('gzipCompressor', function() {
             type: 'js',
             type: 'js',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 bodyAfterOptimization: minifiedContent.toString('utf8'),
                 bodyAfterOptimization: minifiedContent.toString('utf8'),
                 totalWeight: gzipedSize + 200,
                 totalWeight: gzipedSize + 200,
                 headersSize: 200,
                 headersSize: 200,
@@ -150,7 +150,7 @@ describe('gzipCompressor', function() {
             type: 'js',
             type: 'js',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: gzipedSize + 200,
                 totalWeight: gzipedSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: gzipedSize,
                 bodySize: gzipedSize,
@@ -188,7 +188,7 @@ describe('gzipCompressor', function() {
             type: 'css',
             type: 'css',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -224,7 +224,7 @@ describe('gzipCompressor', function() {
             type: 'html',
             type: 'html',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -261,7 +261,7 @@ describe('gzipCompressor', function() {
             type: 'image',
             type: 'image',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -297,7 +297,7 @@ describe('gzipCompressor', function() {
             type: 'xml',
             type: 'xml',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -333,7 +333,7 @@ describe('gzipCompressor', function() {
             type: 'json',
             type: 'json',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -370,7 +370,7 @@ describe('gzipCompressor', function() {
             type: 'webfont',
             type: 'webfont',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -407,7 +407,7 @@ describe('gzipCompressor', function() {
             type: 'favicon',
             type: 'favicon',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -444,7 +444,7 @@ describe('gzipCompressor', function() {
             contentType: 'image/jpeg',
             contentType: 'image/jpeg',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -482,7 +482,7 @@ describe('gzipCompressor', function() {
             contentType: 'image/png',
             contentType: 'image/png',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -519,7 +519,7 @@ describe('gzipCompressor', function() {
             contentType: 'image/gif',
             contentType: 'image/gif',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -556,7 +556,7 @@ describe('gzipCompressor', function() {
             contentType: 'image/webp',
             contentType: 'image/webp',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent.toString('utf8'),
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,

+ 5 - 5
test/core/imageOptimizerTest.js

@@ -52,7 +52,7 @@ describe('imageOptimizer', function() {
             contentType: 'image/jpeg',
             contentType: 'image/jpeg',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent,
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -137,7 +137,7 @@ describe('imageOptimizer', function() {
             contentType: 'image/png',
             contentType: 'image/png',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent,
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -194,7 +194,7 @@ describe('imageOptimizer', function() {
             contentType: 'image/svg+xml',
             contentType: 'image/svg+xml',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent,
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -242,7 +242,7 @@ describe('imageOptimizer', function() {
             contentType: 'image/jpeg',
             contentType: 'image/jpeg',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent,
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,
@@ -285,7 +285,7 @@ describe('imageOptimizer', function() {
             contentType: 'image/png',
             contentType: 'image/png',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: fileContent,
+                bodyBuffer: fileContent,
                 totalWeight: fileSize + 200,
                 totalWeight: fileSize + 200,
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: fileSize,
                 bodySize: fileSize,

+ 4 - 5
test/core/redownloadTest.js

@@ -195,7 +195,7 @@ describe('redownload', function() {
             newEntry.weightCheck.uncompressedSize.should.equal(newEntry.weightCheck.bodySize);
             newEntry.weightCheck.uncompressedSize.should.equal(newEntry.weightCheck.bodySize);
             newEntry.weightCheck.isCompressed.should.equal(false);
             newEntry.weightCheck.isCompressed.should.equal(false);
             newEntry.weightCheck.headersSize.should.be.above(200).and.below(400);
             newEntry.weightCheck.headersSize.should.be.above(200).and.below(400);
-            newEntry.weightCheck.body.toString().should.have.string('1.8.3');
+            newEntry.weightCheck.bodyBuffer.toString().should.have.string('1.8.3');
 
 
             done();
             done();
         })
         })
@@ -226,12 +226,11 @@ describe('redownload', function() {
         .then(function(newEntry) {
         .then(function(newEntry) {
 
 
             newEntry.weightCheck.bodySize.should.equal(4193);
             newEntry.weightCheck.bodySize.should.equal(4193);
-            newEntry.weightCheck.body.should.equal(fileContent.toString('binary'));
+            newEntry.weightCheck.bodyBuffer.should.deep.equal(fileContent);
 
 
             // Opening the image in lwip to check if the format is good
             // Opening the image in lwip to check if the format is good
             var lwip = require('lwip');
             var lwip = require('lwip');
-            var buffer = new Buffer(newEntry.weightCheck.body, 'binary');
-            lwip.open(buffer, 'png', function(err, image) {
+            lwip.open(newEntry.weightCheck.bodyBuffer, 'png', function(err, image) {
                 image.width().should.equal(620);
                 image.width().should.equal(620);
                 image.height().should.equal(104);
                 image.height().should.equal(104);
                 done(err);
                 done(err);
@@ -312,7 +311,7 @@ describe('redownload', function() {
             type: 'html',
             type: 'html',
             contentLength: 999,
             contentLength: 999,
             weightCheck: {
             weightCheck: {
-                body: 'blabla',
+                bodyBuffer: 'blabla',
                 headersSize: 200,
                 headersSize: 200,
                 bodySize: 500,
                 bodySize: 500,
                 isCompressed: false,
                 isCompressed: false,

+ 93 - 0
test/www/SourceSansPro/OFL.txt

@@ -0,0 +1,93 @@
+Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
test/www/SourceSansPro/SourceSansPro-Regular.woff


+ 23 - 0
test/www/font-page.html

@@ -0,0 +1,23 @@
+<html>
+<head>
+<style>
+    body {
+        font-size: 4em;
+    }
+    script, style {
+        display: block;
+        background: #F66;
+    }
+    @font-face {
+        font-family: "SourceSansPro";
+        src: url("SourceSansPro/SourceSansPro-Regular.woff");
+    }
+    .foo {
+        font-family: SourceSansPro;
+    }
+</style>
+</head>
+<body>
+<div class="foo">Some text</div>
+</body>
+</html>