├── .gitignore ├── LICENSE ├── Lib └── diffbrowsers │ ├── __init__.py │ ├── __main__.py │ ├── browsers.py │ ├── diffbrowsers.py │ ├── gfregression.py │ ├── screenshot.py │ └── utils.py ├── README.md ├── bin ├── test_gf_autohint.py ├── test_gf_exhaustive.py ├── test_gf_vf.py ├── test_roboto.sh ├── test_roboto_hinted_src.py └── viz_diffenator.py ├── demo.gif ├── requirements.txt ├── setup.py └── tests ├── data ├── img_after.jpg └── img_before.jpg ├── test_gfregression.py └── test_imgdiff.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .DS_Store 3 | *.pyc 4 | *.egg-info 5 | build/ 6 | dist/ 7 | setup.cfg 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Lib/diffbrowsers/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.7' 2 | -------------------------------------------------------------------------------- /Lib/diffbrowsers/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Browserdiff 5 | ~~~~~~~~~~~ 6 | 7 | Compare two sets of fonts for regressions 8 | 9 | Caveats, script is incredibly slow due to the Browserstack api. Fonts are 10 | matched by filenames. 11 | 12 | See README.md for further info 13 | 14 | Basic Usage: 15 | gfdiffbrowsers new [fonts_after] -fb [fonts_before] -o ~/Desktop/font_img_dir 16 | 17 | Compare against family hosted on Google Fonts: 18 | gfdiffbrowsers new [fonts_after] -gf -o ~/Desktop/font_img_dir 19 | 20 | Load a previous session: 21 | gfdiffbrowsers load -o ~/Desktop/font_img_dir 22 | """ 23 | from __future__ import print_function, division, absolute_import, unicode_literals 24 | import argparse 25 | import os 26 | 27 | from diffbrowsers.diffbrowsers import DiffBrowsers 28 | from diffbrowsers.browsers import test_browsers 29 | from diffbrowsers.gfregression import GF_PRODUCTION_URL, VIEWS 30 | from diffbrowsers.utils import cli_reporter 31 | import logging 32 | 33 | logging.basicConfig(level=logging.INFO) 34 | 35 | 36 | def main(): 37 | parent_parser = argparse.ArgumentParser(add_help=False) 38 | parent_parser.add_argument('-u', '--gfr-url', default=GF_PRODUCTION_URL, 39 | help="Url to GFR instance") 40 | parent_parser.add_argument('-l', '--gfr-local', action="store_true", default=False, 41 | help="Enable this if GFR is being run locally.") 42 | parent_parser.add_argument('-o', '--output-dir', help="Directory for output images", 43 | required=True) 44 | parent_parser.add_argument('-pt', '--type-point-size', 45 | help="In some views, users can control type sample size") 46 | parent_parser.add_argument('-s', '--filter-styles', nargs="+", 47 | help="Only show included styles") 48 | parent_parser.add_argument('-b', '--browsers', 49 | choices=list(test_browsers.keys()), 50 | default='all_browsers', 51 | help="Which set of browsers to test on") 52 | parent_parser.add_argument('-v', '--view', choices=VIEWS, default='waterfall') 53 | parent_parser.add_argument('-gif', '--output-gifs', action='store_true', default=True, 54 | help="Output before and after gifs") 55 | 56 | parser = argparse.ArgumentParser() 57 | subparsers = parser.add_subparsers(dest='command') 58 | 59 | upload_parser = subparsers.add_parser('new', parents=[parent_parser]) 60 | upload_parser.add_argument('fonts_after', nargs="+", help="Fonts after paths") 61 | before_group = upload_parser.add_argument_group(title="Fonts before input") 62 | before_input_group = before_group.add_mutually_exclusive_group(required=True) 63 | before_input_group.add_argument('-fb', '--fonts-before', nargs="+", 64 | help="Fonts before paths") 65 | before_input_group.add_argument('-gf', '--from-googlefonts', action='store_true', 66 | help="Diff against GoogleFonts instead of fonts_before") 67 | 68 | load_parser = subparsers.add_parser('load', parents=[parent_parser]) 69 | load_parser.add_argument("url") 70 | 71 | args = parser.parse_args() 72 | 73 | browsers_to_test = test_browsers[args.browsers] 74 | 75 | diffbrowsers = DiffBrowsers(gfr_instance_url=args.gfr_url, 76 | gfr_is_local=args.gfr_local, 77 | dst_dir=args.output_dir, 78 | browsers=browsers_to_test) 79 | 80 | if args.command == 'new': 81 | fonts_before = 'from-googlefonts' if args.from_googlefonts \ 82 | else args.fonts_before 83 | diffbrowsers.new_session(fonts_before, args.fonts_after) 84 | elif args.command == 'load': 85 | diffbrowsers.load_session(args.url) 86 | 87 | diffbrowsers.diff_view( 88 | args.view, 89 | args.type_point_size, 90 | args.filter_styles, 91 | args.output_gifs 92 | ) 93 | 94 | report_path = os.path.join(args.output_dir, 'report.txt') 95 | with open(report_path, 'w') as doc: 96 | report = cli_reporter(diffbrowsers.stats) 97 | doc.write(report) 98 | print(report) 99 | 100 | 101 | if __name__ == '__main__': 102 | main() 103 | -------------------------------------------------------------------------------- /Lib/diffbrowsers/browsers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Browser info for Browser Stack api 3 | 4 | """ 5 | 6 | win_7_pc_chrome = {"os":"Windows", 7 | "os_version":"7", 8 | "browser":"chrome", 9 | "device":None, 10 | "browser_version":"50.0", 11 | "real_mobile":None} 12 | 13 | win_7_pc_ie = {"os":"Windows", 14 | "os_version":"7", 15 | "browser":"ie", 16 | "browser_version":"9.0"} 17 | 18 | win_10_pc_edge = {"os":"Windows", 19 | "os_version":"10", 20 | "browser":"edge", 21 | "device":None, 22 | "browser_version":"15.0", 23 | "real_mobile":None} 24 | 25 | win_7_pc_firefox = {"os":"Windows", 26 | "os_version":"7", 27 | "browser":"firefox", 28 | "device":None, 29 | "browser_version":"45.0", 30 | "real_mobile":None} 31 | 32 | osx_yosemite_pc_safari = {"os":"OS X", 33 | "os_version":"El Capitan", 34 | "browser":"safari", 35 | "device":None, 36 | "browser_version":"9.1", 37 | "real_mobile":None} 38 | 39 | android = {"os":"android", 40 | "os_version":"5.0", 41 | "browser":"Android Browser", 42 | "device":"Google Nexus 5", 43 | "browser_version":None, 44 | "real_mobile":None} 45 | 46 | 47 | win_10_pc_firefox = {"os":"Windows", 48 | "os_version":"10", 49 | "browser":"firefox", 50 | "device": None, 51 | "browser_version":"62.0", 52 | "real_mobile": None} 53 | 54 | 55 | win_10_pc_chrome = {"os":"Windows", 56 | "os_version":"10", 57 | "browser":"chrome", 58 | "device": None, 59 | "browser_version":"69.0", 60 | "real_mobile": None} 61 | 62 | win_10_pc_edge = {"os":"Windows", 63 | "os_version":"10", 64 | "browser":"edge", 65 | "device": None, 66 | "browser_version":"17.0", 67 | "real_mobile": None} 68 | 69 | osx_high_sierra_safari = {'os_version': 'High Sierra', 70 | 'os': 'OS X', 71 | 'browser': 'safari', 72 | 'device': None, 73 | 'browser_version': '11.1', 74 | 'real_mobile': None,} 75 | 76 | all_browsers = {'browsers': [ 77 | win_7_pc_chrome, 78 | win_7_pc_ie, 79 | win_10_pc_edge, 80 | win_7_pc_firefox, 81 | osx_yosemite_pc_safari, 82 | android, 83 | ]} 84 | 85 | osx_browser = {'browsers': [osx_yosemite_pc_safari]} 86 | 87 | gdi_browsers = {'browsers': [win_7_pc_ie]} 88 | 89 | android_browsers = {'browsers': [android]} 90 | 91 | vf_browsers = {'browsers': [ 92 | win_10_pc_firefox, 93 | win_10_pc_chrome, 94 | osx_high_sierra_safari 95 | ]} 96 | 97 | safari_latest = {'browsers': [osx_high_sierra_safari]} 98 | 99 | test_browsers = { 100 | 'all_browsers': all_browsers, 101 | 'gdi_browsers': gdi_browsers, 102 | 'osx_browser': osx_browser, 103 | 'android_browsers': android_browsers, 104 | 'vf_browsers': vf_browsers, 105 | 'safari_latest': safari_latest, 106 | } 107 | -------------------------------------------------------------------------------- /Lib/diffbrowsers/diffbrowsers.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, division, absolute_import 2 | from PIL import Image, ImageChops 3 | import requests 4 | import os 5 | from glob import glob 6 | from ntpath import basename 7 | import json 8 | import time 9 | import shutil 10 | import logging 11 | 12 | from diffbrowsers.gfregression import GFRegression, GF_PRODUCTION_URL 13 | from diffbrowsers.browsers import test_browsers 14 | from diffbrowsers.screenshot import ScreenShot 15 | from diffbrowsers.utils import ( 16 | load_browserstack_credentials, 17 | NoBrowserStackAuthFile 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | logger.setLevel(logging.INFO) 22 | 23 | class DiffBrowsers(object): 24 | """Class to control GF Regression and Browser Stack api.""" 25 | def __init__(self, 26 | auth=None, 27 | dst_dir=None, 28 | browsers=test_browsers['all_browsers'], 29 | gfr_instance_url=GF_PRODUCTION_URL, 30 | gfr_is_local=False): 31 | 32 | if not auth: 33 | auth = load_browserstack_credentials() 34 | if not auth: 35 | raise NoBrowserStackAuthFile 36 | 37 | if gfr_instance_url.endswith('/'): 38 | gfr_instance_url = gfr_instance_url[:-1] 39 | 40 | self.gf_regression = GFRegression( 41 | instance_url=gfr_instance_url 42 | ) 43 | self.browserstack_settings = browsers 44 | if gfr_is_local: 45 | self.browserstack_settings['local'] = True 46 | self.screenshot = ScreenShot(auth=auth, config=self.browserstack_settings) 47 | 48 | self.dst_dir = dst_dir if dst_dir else 'out' 49 | self.stats = {'views': {}, 50 | 'fonts': []} 51 | self._mkdir(self.dst_dir) 52 | 53 | def new_session(self, fonts_before, fonts_after): 54 | """Upload fonts to gfregression""" 55 | self.gf_regression.new_session(fonts_before, fonts_after) 56 | logger.info("Posting fonts to GF Regression. Be patient.") 57 | self.stats['fonts'] = self.gf_regression.info['fonts'] 58 | 59 | def load_session(self, url): 60 | """Load a previous gf regression session""" 61 | self.gf_regression.load_session(url) 62 | self.stats['fonts'] = self.gf_regression.info['fonts'] 63 | 64 | def diff_view(self, screenshot_view, pt=None, styles=None, gen_gifs=True): 65 | """Return before and after images from a GF Regression view. 66 | 67 | Use PIL to calculate the amount of different pixels and save 68 | the images.""" 69 | if not self.gf_regression.info['uuid']: 70 | raise Exception("Cannot make diff. Upload or load fonts first") 71 | view_dir = '{}_{}pt'.format(screenshot_view, pt) if pt \ 72 | else screenshot_view 73 | view_path = os.path.join(self.dst_dir, view_dir) 74 | self._mkdir(view_path, overwrite=True) 75 | 76 | if styles: 77 | self.stats['fonts'] = [f for f in self.stats['fonts'] if f in styles] 78 | 79 | logger.info('Generating {} before images'.format(screenshot_view)) 80 | before_url = self.gf_regression.url(screenshot_view, 'before', pt, styles) 81 | before_path = os.path.join(view_path, 'before') 82 | self._mkdir(before_path, overwrite=True) 83 | self.screenshot.take(before_url, before_path) 84 | 85 | logger.info('Generating {} after images'.format(screenshot_view)) 86 | after_url = self.gf_regression.url(screenshot_view, 'after', pt, styles) 87 | after_path = os.path.join(view_path, 'after') 88 | self._mkdir(after_path, overwrite=True) 89 | self.screenshot.take(after_url, after_path) 90 | 91 | diff_dir = os.path.join(view_path, 'diff') 92 | self._mkdir(diff_dir, overwrite=True) 93 | 94 | comparison = self._compare_images(before_path, after_path, diff_dir) 95 | r_view = '{}_{}pt'.format(screenshot_view, pt) if pt else screenshot_view 96 | self.stats['views'][r_view] = comparison 97 | 98 | if gen_gifs: 99 | logger.info('Generating {} gifs'.format(screenshot_view)) 100 | gif_dir = os.path.join(view_path, 'gifs') 101 | self._mkdir(gif_dir, overwrite=True) 102 | self._gen_gifs(before_path, after_path, gif_dir) 103 | return comparison 104 | 105 | def _mkdir(self, path, overwrite=False): 106 | """Create a directory, if overwrite enabled rm -rf the dir""" 107 | if not os.path.isdir(path): 108 | os.mkdir(path) 109 | if overwrite: 110 | shutil.rmtree(path) 111 | os.mkdir(path) 112 | 113 | def _gen_gifs(self, dir1, dir2, out_dir): 114 | shared_imgs = self._matched_filenames_in_dirs(dir1, dir2, 'jpg') 115 | for img in shared_imgs: 116 | gif_filename = img[:-4] + '.gif' 117 | dir1_img_path = os.path.join(dir1, img) 118 | dir2_img_path = os.path.join(dir2, img) 119 | if not self._valid_imgs([dir1_img_path, dir2_img_path]): 120 | logger.warning(("Skipping {}. Before/after images are " 121 | "corrupt").format(gif_filename)) 122 | continue 123 | with Image.open(dir1_img_path) as dir1_img, \ 124 | Image.open(dir2_img_path) as dir2_img: 125 | dir1_img.save( 126 | os.path.join(out_dir, gif_filename), 127 | save_all=True, 128 | append_images=[dir2_img], 129 | loop=10000, 130 | duration=1000 131 | ) 132 | 133 | def _matched_filenames_in_dirs(self, dir1, dir2, ext): 134 | """find matching filenames in two different dirs which have a specific 135 | extension""" 136 | dir1_items = {basename(n): n for n in glob('%s/*.%s' % (dir1, ext))} 137 | dir2_items = {basename(n): n for n in glob('%s/*.%s' % (dir2, ext))} 138 | return set(dir1_items) & set(dir2_items) 139 | 140 | def _compare_images(self, dir1, dir2, diff_dir): 141 | """Compare two folders of images against each other.""" 142 | comparisons = [] 143 | 144 | shared_imgs = self._matched_filenames_in_dirs(dir1, dir2, 'jpg') 145 | for img in shared_imgs: 146 | dir1_img_path = os.path.join(dir1, img) 147 | dir2_img_path = os.path.join(dir2, img) 148 | diff_img_path = os.path.join(diff_dir, img) 149 | if not self._valid_imgs([dir1_img_path, dir2_img_path]): 150 | logger.warning(("Skipping {}. Before/after images are " 151 | "corrupt").format(diff_img_path)) 152 | continue 153 | with Image.open(dir1_img_path) as dir1_img, \ 154 | Image.open(dir2_img_path) as dir2_img: 155 | comparison = compare_image(dir1_img, dir2_img, diff_img_path) 156 | comparisons.append((img, comparison)) 157 | return comparisons 158 | 159 | def update_browsers(self, browsers): 160 | self.screenshot.config['browsers'] = browsers['browsers'] 161 | 162 | def _valid_imgs(self, imgs_paths): 163 | for img_path in imgs_paths: 164 | if os.path.getsize(img_path) == 0: 165 | return False 166 | return True 167 | 168 | 169 | def compare_image(img1, img2, out_img=None, 170 | ignore_first_px_rows=200, ignore_right_px_cols=40): 171 | """Compare two images and return the amount of different pixels. 172 | 173 | ignore_first_px_rows param will ignore the first n pixel rows. This is 174 | useful if the images contain text which shouldn't be diffed and may 175 | change such as a header. 176 | 177 | ignore_right_px_cols param will ignore the last n pixel columns. GF Regression 178 | shows before and after labels in the right hand margin. We don't want these 179 | labels to be included in the pixel count.""" 180 | img_diff = ImageChops.difference(img1, img2) 181 | 182 | pixels = list(img_diff.getdata()) 183 | width, height = img_diff.size 184 | pixels = [pixels[i * width:(i + 1) * width] for i in range(height)] 185 | 186 | px_diff = 0 187 | for line in pixels[ignore_first_px_rows:]: 188 | for px in line[:-ignore_right_px_cols]: 189 | # ignore image alpha channel if exists 190 | r, g, b = px[:3] 191 | if r != 0 or g != 0 or b != 0: 192 | px_diff += 1 193 | if out_img: 194 | img_diff_rgb = img_diff.convert('RGB') 195 | img_diff_rgb.save(out_img[:-4] + '.png') 196 | img_diff.close() 197 | return px_diff 198 | -------------------------------------------------------------------------------- /Lib/diffbrowsers/gfregression.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import 2 | import logging 3 | import requests 4 | import json 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.setLevel(logging.INFO) 9 | 10 | GF_PRODUCTION_URL = 'http://35.238.63.0/' 11 | 12 | VIEWS = ( 13 | 'waterfall', 14 | 'glyphs_all', 'glyphs_new', 'glyphs_missing', 'glyphs_modified', 15 | 'marks_new', 'marks_missing', 'marks_modified', 16 | 'mkmks_new', 'mkmks_missing', 'mkmks_modified', 17 | 'kerns_new', 'kerns_missing', 'kerns_modified', 18 | 'metrics_modified', 19 | ) 20 | 21 | 22 | class UnknownGFRegressionViewError(Exception): 23 | def __init__(self): 24 | super(UnknownGFRegressionViewError, self).__init__( 25 | 'View is not valid. Choose from [%s]' % ', '.join(VIEWS) 26 | ) 27 | 28 | 29 | class GFRegression: 30 | """Simple client for GF Regression""" 31 | def __init__(self, instance_url=GF_PRODUCTION_URL): 32 | self.instance_url = instance_url 33 | self._validate_instance(self.instance_url) 34 | self.info = {} 35 | 36 | def new_session(self, fonts_before, fonts_after): 37 | """Post fonts to GF Regression site using the api. 38 | 39 | If fonts_before == 'from-googlefonts', compare against fonts hosted 40 | on Google Fonts. 41 | 42 | If the fonts uploaded successfully, GF Regression will return a uuid. 43 | This can be used to form urls to view endpoints.""" 44 | logger.info("Posting fonts to GF Regression") 45 | fonts_after = set(fonts_after) 46 | if fonts_before == 'from-googlefonts': 47 | url_upload = self.instance_url + '/api/upload/googlefonts' 48 | payload = [('fonts_after', open(f, 'rb')) for f in fonts_after] 49 | else: 50 | fonts_before = set(fonts_before) 51 | url_upload = self.instance_url + '/api/upload/user' 52 | payload = [('fonts_after', open(f, 'rb')) for f in fonts_after] + \ 53 | [('fonts_before', open(f, 'rb')) for f in fonts_before] 54 | request = requests.post(url_upload, files=payload) 55 | self.info = request.json() 56 | logger.info("Fonts have been uploaded, uuid: %s" % self.info['uuid']) 57 | 58 | def load_session(self, url): 59 | """Load fonts which were previously posted to GF Regression""" 60 | uuid = self._extract_uuid(url) 61 | self.info = self._session_info(uuid) 62 | 63 | def url(self, view, font_type, pt=None, styles=None): 64 | """Return a url from a user's input params.""" 65 | if view not in VIEWS: 66 | raise UnknownGFRegressionViewError() 67 | if not self.info['uuid']: 68 | raise Exception('No fonts uploaded or previous uuid defined') 69 | url = '%s/screenshot/%s/%s/%s' % (self.instance_url, 70 | self.info['uuid'], view, font_type) 71 | if pt: 72 | url = url + '/%s' % pt 73 | if styles: 74 | url = url + '?styles=%s' % ",".join(styles) 75 | return url 76 | 77 | @staticmethod 78 | def _extract_uuid(url): 79 | """Extract a uuid4 subpath from a url. 80 | 81 | http://127.0.0.1:5000/compare/a3ec8a52-690d-4faf-b567-13a488125c62/fonts 82 | --> 83 | a3ec8a52-690d-4faf-b567-13a488125c62 84 | """ 85 | segments = url.split('/') 86 | for idx, segment in enumerate(segments): 87 | dash_count = 0 88 | for char in segment: 89 | if char == '-': 90 | dash_count += 1 91 | if dash_count == 4: 92 | return segments[idx] 93 | raise Exception('Url does not contain a valid uuid4') 94 | 95 | def _session_info(self, uuid): 96 | """Return info about the current session""" 97 | url = "%s/api/info/%s" % (self.instance_url, uuid) 98 | request = requests.get(url) 99 | if request.status_code != 200: 100 | raise Exception('url %s is invalid' % url) 101 | return request.json() 102 | 103 | def _validate_instance(self, url): 104 | """Confirm instance_url is a working instance of GFRegression""" 105 | request = requests.get(url) 106 | if 'Google Fonts Regression' not in request.text and \ 107 | 'Compare fonts' not in request.text: 108 | raise Exception(('instance_url %s is not an instance of ' 109 | 'GF Regression' % url)) 110 | -------------------------------------------------------------------------------- /Lib/diffbrowsers/screenshot.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import 2 | import logging 3 | import browserstack_screenshots 4 | import os 5 | import time 6 | from diffbrowsers.utils import download_file 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | logger.setLevel(logging.INFO) 11 | 12 | class ScreenShot(browserstack_screenshots.Screenshots): 13 | """Expansion for browserstack screenshots Lib. Adds ability to 14 | download files""" 15 | 16 | def take(self, url, dst_dir): 17 | """take a screenshot from a url and save it to the dst_dir""" 18 | self.config['url'] = url 19 | logger.info('Taking screenshot for url: %s' % url) 20 | generate_resp_json = self.generate_screenshots() 21 | job_id = generate_resp_json['job_id'] 22 | 23 | logger.info('Browserstack is processing: ' 24 | 'http://www.browserstack.com/screenshots/%s' % job_id) 25 | screenshots_json = self.get_screenshots(job_id) 26 | while screenshots_json == False: # keep refreshing until browerstack is done 27 | time.sleep(3) 28 | screenshots_json = self.get_screenshots(job_id) 29 | for screenshot in screenshots_json['screenshots']: 30 | filename = self._build_filename_from_browserstack_json(screenshot) 31 | base_image = os.path.join(dst_dir, filename) 32 | try: 33 | download_file(screenshot['image_url'], base_image) 34 | except: 35 | logger.info('Skipping {} BrowserStack timed out'.format( 36 | screenshot['image_url']) 37 | ) 38 | 39 | def _build_filename_from_browserstack_json(self, j): 40 | """Build useful filename for an image from the screenshot json 41 | metadata""" 42 | filename = '' 43 | device = j['device'] if j['device'] else 'Desktop' 44 | if j['state'] == 'done' and j['image_url']: 45 | detail = [device, j['os'], j['os_version'], 46 | j['browser'], j['browser_version'], '.jpg'] 47 | filename = '_'.join(item.replace(" ", "_") for item in detail if item) 48 | else: 49 | logger.info('screenshot timed out, ignoring this result') 50 | return filename 51 | -------------------------------------------------------------------------------- /Lib/diffbrowsers/utils.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import requests 3 | import os 4 | try: 5 | from configparser import ConfigParser 6 | except ImportError: 7 | from ConfigParser import ConfigParser 8 | 9 | 10 | CONFIG_FILE = '~/.browserstack-api-config' 11 | 12 | 13 | class NoBrowserStackAuthFile(Exception): 14 | def __init__(self): 15 | super(NoBrowserStackAuthFile, self).__init__( 16 | "~/.browserstack-api-config file is missing. See README.md" 17 | ) 18 | 19 | 20 | def load_browserstack_credentials(): 21 | """Load the credentials to use Browserstack's screenshot api.""" 22 | config = ConfigParser() 23 | config_filepath = os.path.expanduser(CONFIG_FILE) 24 | 25 | if os.path.isfile(config_filepath): 26 | config.read(config_filepath) 27 | credentials = config.items('Credentials') 28 | return credentials[0][1], credentials[1][1] 29 | return None 30 | 31 | def cli_reporter(report): 32 | """Simple output for report dict 33 | 34 | input: {'view-name' [(platform, px_difference)]} 35 | 36 | output: str 37 | 38 | e.g 39 | 40 | >>> reporter(diffbrowsers.report) 41 | 42 | Regression Report: 43 | Fonts: [fonts] 44 | 45 | View: Glyphs-All 46 | WARNING: Desktop_Windows_7_chrome_50.0 is different by 100px, 47 | WARNING: Desktop_OS_X_Yosemite_safari_8.0 is different by 80px 48 | 49 | View: Waterfall 50 | PASSED: Desktop_Windows_7_chrome_50.0 is different is the same 51 | PASSED: Desktop_OS_X_Yosemite_safari_8.0 is different is the same 52 | 53 | TODO (M Foley) this needs more work 54 | """ 55 | doc = [] 56 | doc.append('Regression Report:\n\n') 57 | doc.append('Fonts: ["{}"]\n'.format('", "'.join(report['fonts']))) 58 | for view in report['views']: 59 | doc.append('\nView: {}\n'.format(view)) 60 | for platform, px_diff in report['views'][view]: 61 | if px_diff != 0: 62 | doc.append('WARNING: {} is different by {} pixels\n'.format( 63 | platform, px_diff 64 | )) 65 | else: 66 | doc.append('PASSED: {} is the same\n'.format(platform)) 67 | return ''.join(doc) 68 | 69 | 70 | def download_file(url, dst_path=None): 71 | """Download a file from a url. If no url is specified, store the file 72 | as a StringIO object""" 73 | try: 74 | request = requests.get(url, stream=True) 75 | if not dst_path: 76 | return StringIO(request.content) 77 | with open(dst_path, 'wb') as downloaded_file: 78 | shutil.copyfileobj(request.raw, downloaded_file) 79 | except requests.exceptions.MissingSchema: 80 | raise Exception("url {} is not a valid file".format(url)) 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Googlefonts Diff Browsers 2 | 3 | Test two sets of fonts for visual regressions on different browsers. 4 | 5 | # ![Font Bakery](demo.gif) 6 | *Output gif of Roboto compared against the version on Google Fonts. Win7 ie11* 7 | 8 | ## How it works 9 | 10 | 1. User executes cli tool 11 | 2. Fonts get posted to [GFRegression's](http://45.55.138.144/) staging area 12 | 3. GFRegression generates a url identifier for the uploaded fonts 13 | 4. Urls are generated from the user's cli params and the url identifier 14 | 5. Browserstack's screenshot api visits the urls 15 | 6. Browserstack generates and saves the screenshots to a user defined directory 16 | 7. Images for both sets are compared against each other, pixel by pixel. 17 | 8. Pixel differences are returned 18 | 19 | 20 | ## Installation 21 | 22 | Important: This tool uses the Browserstack screenshots api. **You must have a subscription** in order to use this. 23 | 24 | 25 | **For non developers:** 26 | 27 | ``` 28 | pip install gfdiffbrowsers 29 | ``` 30 | 31 | **For developers** 32 | 33 | ``` 34 | $ git clone https://github.com/googlefonts/diffbrowsers 35 | $ virtualenv venv 36 | $ source venv/bin/activate 37 | $ pip install -r requirements.txt 38 | $ pip install . (-e for developer mode) 39 | ``` 40 | 41 | **Auth** 42 | 43 | Create a file ~/.browserstack-api-config containing the following data: 44 | 45 | ``` 46 | [Credentials] 47 | username = 48 | access_key = 49 | ``` 50 | 51 | This information can be found in https://www.browserstack.com/accounts/settings 52 | 53 | 54 | 55 | ## Usage 56 | 57 | Important: Font names must match between the two sets. 58 | 59 | **Produce a waterfall** 60 | 61 | ``` 62 | gfdiffbrowsers new [fonts_after] -fb [fonts_before] -o ~/Desktop/font_img 63 | ``` 64 | 65 | **Produce a glyph palette containing all glyphs at 20pt, export gifs as well** 66 | 67 | ``` 68 | gfdiffbrowsers new [fonts_after] -fb [fonts_before] -o ~/Desktop/font_img -v glyphs-all -gif -pt 20 69 | ``` 70 | 71 | **Compare a set of fonts against the same families hosted on Google Fonts** 72 | 73 | ``` 74 | gfdiffbrowsers new [fonts_after] -gf -o ~/Desktop/font_img 75 | ``` 76 | 77 | **Load an existing session** 78 | 79 | ``` 80 | gfdiffbrowsers load -o ~/Desktop/font_img 81 | ``` 82 | 83 | ### Local testing 84 | 85 | Make sure you have a local copy of [GF Regression](https://github.com/googlefonts/gfregression) running and you also have browserstack enabled to run [locally](https://www.browserstack.com/local-testing). Simply add the following. 86 | 87 | `--gfr-url http://localhost-gfr-url`|`-u http://gfr-localhost-url` and `--gfr-local`|`-l` 88 | 89 | e.g 90 | 91 | ``` 92 | gfdiffbrowsers new /path/to/font.ttf -gf -u http://127.0.0.1:5000 -l -o ~/Desktop/fontIMG 93 | ``` 94 | 95 | ## Caveats 96 | 97 | Browserstack's screenshot api isn't fast. Please be patient. 98 | 99 | If you cancel midway through a font comparison, BrowserStack may still be running. There is also no way to terminate the BrowserStack process remotely. Since most users will only have a 1 user license, users must wait for BrowserStack to finish before running again. 100 | 101 | Sometimes BrowserStack will timeout when trying to generate a screenshot. If this happens, it will timeout gracefully, skip the screenshot and get the next browser. Unfortunately, this stability issue makes BrowserStack questionable for CI purposes. hopefully this will improve in the future. 102 | -------------------------------------------------------------------------------- /bin/test_gf_autohint.py: -------------------------------------------------------------------------------- 1 | """ 2 | If a family has been hinted with ttfautohint. The x-height must remain 3 | the same as before. Users do notice changes: 4 | 5 | https://github.com/google/fonts/issues/644 6 | https://github.com/google/fonts/issues/528 7 | """ 8 | from __future__ import print_function, division, absolute_import, unicode_literals 9 | import argparse 10 | import os 11 | import time 12 | import logging 13 | from diffbrowsers.diffbrowsers import DiffBrowsers 14 | from diffbrowsers.utils import load_browserstack_credentials, cli_reporter 15 | from diffbrowsers.browsers import test_browsers 16 | 17 | logging.basicConfig(level=logging.INFO) 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def main(): 22 | parser = argparse.ArgumentParser(description=__doc__) 23 | 24 | parser.add_argument('fonts_after', nargs="+", help="Fonts after paths") 25 | before_group = parser.add_argument_group(title="Fonts before input") 26 | before_input_group = before_group.add_mutually_exclusive_group(required=True) 27 | before_input_group.add_argument('-fb', '--fonts-before', nargs="+", 28 | help="Fonts before paths") 29 | before_input_group.add_argument('-gf', '--from-googlefonts', action='store_true', 30 | help="Diff against GoogleFonts instead of fonts_before") 31 | parser.add_argument('-o', '--output-dir', help="Directory for output images", 32 | required=True) 33 | 34 | args = parser.parse_args() 35 | auth = load_browserstack_credentials() 36 | 37 | browsers_to_test = test_browsers['all_browsers'] 38 | 39 | fonts_before = 'from-googlefonts' if args.from_googlefonts \ 40 | else args.fonts_before 41 | 42 | diffbrowsers = DiffBrowsers(dst_dir=args.output_dir, browsers=browsers_to_test) 43 | diffbrowsers.new_session(fonts_before, args.fonts_after) 44 | 45 | diffbrowsers.diff_view('waterfall', gen_gifs=True) 46 | logger.info("Sleeping for 10 secs. Giving Browserstack api a rest") 47 | time.sleep(10) 48 | 49 | diffbrowsers.update_browsers(test_browsers['osx_browser']) 50 | diffbrowsers.diff_view('glyphs-modified', gen_gifs=True) 51 | 52 | report = cli_reporter(diffbrowsers.stats) 53 | report_path = os.path.join(args.output_dir, 'report.txt') 54 | with open(report_path, 'w') as doc: 55 | doc.write(report) 56 | 57 | print(report) 58 | 59 | 60 | if __name__ == '__main__': 61 | main() 62 | -------------------------------------------------------------------------------- /bin/test_gf_exhaustive.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hardcore tests for families with manual hinting. This should also be run for 3 | popular families. 4 | 5 | """ 6 | from __future__ import print_function, division, absolute_import, unicode_literals 7 | import argparse 8 | import os 9 | import time 10 | import logging 11 | from diffbrowsers.diffbrowsers import DiffBrowsers 12 | from diffbrowsers.utils import load_browserstack_credentials, cli_reporter 13 | from diffbrowsers.browsers import test_browsers 14 | 15 | logging.basicConfig(level=logging.INFO) 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def main(): 20 | parser = argparse.ArgumentParser(description=__doc__) 21 | 22 | parser.add_argument('fonts_after', nargs="+", help="Fonts after paths") 23 | before_group = parser.add_argument_group(title="Fonts before input") 24 | before_input_group = before_group.add_mutually_exclusive_group(required=True) 25 | before_input_group.add_argument('-fb', '--fonts-before', nargs="+", 26 | help="Fonts before paths") 27 | before_input_group.add_argument('-gf', '--from-googlefonts', action='store_true', 28 | help="Diff against GoogleFonts instead of fonts_before") 29 | parser.add_argument('-o', '--output-dir', help="Directory for output images", 30 | required=True) 31 | 32 | args = parser.parse_args() 33 | auth = load_browserstack_credentials() 34 | 35 | browsers_to_test = test_browsers['all_browsers'] 36 | 37 | fonts_before = 'from-googlefonts' if args.from_googlefonts \ 38 | else args.fonts_before 39 | 40 | diffbrowsers = DiffBrowsers(dst_dir=args.output_dir, browsers=browsers_to_test) 41 | diffbrowsers.new_session(fonts_before, args.fonts_after) 42 | 43 | for pt in [7, 12, 24]: 44 | diffbrowsers.diff_view('glyphs-all', pt, gen_gifs=True) 45 | logger.info("Sleeping for 10 secs. Giving Browserstack api a rest") 46 | time.sleep(10) 47 | 48 | diffbrowsers.diff_view('waterfall', gen_gifs=True) 49 | logger.info("Sleeping for 10 secs. Giving Browserstack api a rest") 50 | time.sleep(10) 51 | 52 | diffbrowsers.browsers = test_browsers['osx_browser'] 53 | diffbrowsers.diff_view('glyphs-modified', gen_gifs=True) 54 | 55 | report = cli_reporter(diffbrowsers.stats) 56 | report_path = os.path.join(args.output_dir, 'report.txt') 57 | with open(report_path, 'w') as doc: 58 | doc.write(report) 59 | 60 | print(report) 61 | 62 | 63 | if __name__ == '__main__': 64 | main() 65 | -------------------------------------------------------------------------------- /bin/test_gf_vf.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compare VFs against static family on GF. 3 | 4 | Test all glyphs at text sizes 5 | """ 6 | import argparse 7 | import os 8 | import logging 9 | from diffbrowsers.gfregression import GF_PRODUCTION_URL, VIEWS 10 | from diffbrowsers.diffbrowsers import DiffBrowsers 11 | from diffbrowsers.browsers import test_browsers 12 | 13 | logging.basicConfig(level=logging.INFO) 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def main(): 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument('--fonts', nargs="+", required=True) 20 | parser.add_argument('-u', '--gfr-url', default=GF_PRODUCTION_URL, 21 | help="Url to GFR instance") 22 | parser.add_argument('-l', '--gfr-local', action="store_true", default=False) 23 | parser.add_argument('-o', '--output-dir', help="Directory for output images", 24 | required=True) 25 | args = parser.parse_args() 26 | 27 | browsers_to_test = test_browsers['vf_browsers'] 28 | if not os.path.isdir(args.output_dir): 29 | os.mkdir(args.output_dir) 30 | 31 | diffbrowsers = DiffBrowsers(gfr_instance_url=args.gfr_url, 32 | gfr_is_local=args.gfr_local, 33 | dst_dir=args.output_dir, 34 | browsers=browsers_to_test) 35 | 36 | diffbrowsers.new_session("from-googlefonts", args.fonts) 37 | logger.info("Generating waterfall diff") 38 | diffbrowsers.diff_view("waterfall") 39 | for pt_size in [13, 14, 15, 16]: 40 | logger.info("Generating images for glyphs_all at {}".format(pt_size)) 41 | diffbrowsers.diff_view("glyphs_all", pt=pt_size) 42 | logger.info("Images saved to {}".format(args.output_dir)) 43 | 44 | 45 | if __name__ == '__main__': 46 | main() 47 | -------------------------------------------------------------------------------- /bin/test_roboto.sh: -------------------------------------------------------------------------------- 1 | BEFORE_DIR=$1 2 | AFTER_DIR=$2 3 | OUT_DIR=$3 4 | 5 | set -e 6 | 7 | after_fonts=$(ls $AFTER_DIR/*.ttf); 8 | 9 | for after_font in $after_fonts 10 | do 11 | filename=$(basename $after_font); 12 | before_font=$BEFORE_DIR/$filename; 13 | python ./bin/test_roboto_hinted_src.py $before_font $after_font $OUT_DIR 14 | done -------------------------------------------------------------------------------- /bin/test_roboto_hinted_src.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing Roboto hinted sources from 3rd party supplier. 3 | 4 | WARNING: Run time is astronomical. 5 | 6 | 12 sizes x 18 fonts = ~18 hours 7 | 8 | This should only be run if the 3rd party supplier has updated 9 | cvt, fpgm and prep tables. 10 | 11 | Throw this onto AWS or another cloud platform. 12 | 13 | """ 14 | from __future__ import print_function, division, absolute_import, unicode_literals 15 | import argparse 16 | import requests 17 | import json 18 | import os 19 | import sys 20 | from ntpath import basename 21 | from glob import glob 22 | import shutil 23 | import time 24 | import logging 25 | 26 | from diffbrowsers.diffbrowsers import DiffBrowsers 27 | from diffbrowsers.utils import load_browserstack_credentials, cli_reporter 28 | from diffbrowsers.browsers import test_browsers 29 | from diffbrowsers.utils import download_file 30 | 31 | 32 | logging.basicConfig(level=logging.INFO) 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | def download_current_hinted_fonts(dst): 37 | """Download the latest hinted src fonts from google/roboto/src/hinted 38 | """ 39 | filepaths = [] 40 | api_url = 'https://api.github.com/repos/google/roboto/contents/src/hinted' 41 | 42 | request = requests.get(api_url) 43 | request_json = json.loads(request.content) 44 | logger.info('Downloading existing hinted sources') 45 | for item in request_json: 46 | if item['download_url'].endswith('.ttf'): 47 | filename = basename(item['download_url']) 48 | filepath = os.path.join(dst, filename) 49 | logger.info('Downloading %s to %s' % (filename, dst)) 50 | download_file(item['download_url'], filepath) 51 | filepaths.append(filepath) 52 | return filepaths 53 | 54 | 55 | def main(font_before, font_after, out_dir): 56 | """Due to the fonts containing so many glyphs, style on style 57 | comparisons are needed.""" 58 | auth = load_browserstack_credentials() 59 | logger.info('Comparing %s' % os.path.basename(font_before)) 60 | fullname = font_before[:-4] 61 | font_dir = os.path.join(out_dir, fullname) 62 | diffbrowsers = DiffBrowsers(auth, font_dir, 63 | [font_after], 64 | [font_before], 65 | test_browsers['all_browsers']) 66 | 67 | diffbrowsers.diff_view('waterfall', gen_gifs=True) 68 | logger.info('Resting Browserstack screenshot api') 69 | time.sleep(10) 70 | 71 | # Generate glyph plots for Material Design sp sizes 72 | for n in [11, 12, 13, 14, 16, 15, 20, 24, 34, 45, 56, 112]: 73 | diffbrowsers.diff_view('glyphs-all', pt=n, gen_gifs=True) 74 | logger.info('Resting Browserstack screenshot api') 75 | time.sleep(10) 76 | 77 | report = cli_reporter(diffbrowsers.report) 78 | report_path = os.path.join(font_dir, 'report.txt') 79 | with open(report_path, 'w') as doc: 80 | doc.write(report) 81 | 82 | 83 | if __name__ == '__main__': 84 | main(sys.argv[1], sys.argv[2], sys.argv[3]) 85 | -------------------------------------------------------------------------------- /bin/viz_diffenator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Visualize any differences found with fontdiffenator 3 | """ 4 | import argparse 5 | from diffbrowsers.gfregression import GF_PRODUCTION_URL, VIEWS 6 | from diffbrowsers.diffbrowsers import DiffBrowsers 7 | from diffbrowsers.browsers import test_browsers 8 | import os 9 | import logging 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def main(): 16 | parser = argparse.ArgumentParser() 17 | 18 | parser.add_argument('fonts_after', nargs="+", help="Fonts after paths") 19 | before_group = parser.add_argument_group(title="Fonts before input") 20 | before_input_group = before_group.add_mutually_exclusive_group(required=True) 21 | before_input_group.add_argument('-fb', '--fonts-before', nargs="+", 22 | help="Fonts before paths") 23 | before_input_group.add_argument('-gf', '--from-googlefonts', action='store_true', 24 | help="Diff against GoogleFonts instead of fonts_before") 25 | 26 | 27 | parser.add_argument('-u', '--gfr-url', default=GF_PRODUCTION_URL, 28 | help="Url to GFR instance") 29 | parser.add_argument('-l', '--gfr-local', action="store_true", default=False) 30 | parser.add_argument('-o', '--output-dir', help="Directory for output images", 31 | required=True) 32 | args = parser.parse_args() 33 | 34 | browsers_to_test = test_browsers['safari_latest'] 35 | 36 | diffbrowsers = DiffBrowsers(gfr_instance_url=args.gfr_url, 37 | gfr_is_local=args.gfr_local, 38 | dst_dir=args.output_dir, 39 | browsers=browsers_to_test) 40 | 41 | fonts_before = 'from-googlefonts' if args.from_googlefonts \ 42 | else args.fonts_before 43 | 44 | diffbrowsers.new_session(fonts_before, args.fonts_after) 45 | 46 | views_to_diff = diffbrowsers.gf_regression.info['diffs'] 47 | logger.info("Following diffs have been found [%s]. Genning images." % ', '.join(views_to_diff)) 48 | for view in views_to_diff: 49 | logger.info("Generating images for {}".format(view)) 50 | if view not in VIEWS: 51 | logger.info("Skipping view {}".format(view)) 52 | else: 53 | diffbrowsers.diff_view(view, pt=32) 54 | 55 | logger.info("Images saved to {}".format(args.output_dir)) 56 | 57 | 58 | if __name__ == '__main__': 59 | main() 60 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googlefonts/diffbrowsers/94bfe028915ee2b8d5b8308a2a66bb28baea334f/demo.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pybrowserstack-screenshots==0.1 2 | Pillow 3 | requests 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import sys 16 | import os 17 | from setuptools import setup, find_packages, Command 18 | from distutils import log 19 | 20 | setup( 21 | name='gfdiffbrowsers', 22 | version='0.1.7', 23 | author="Marc Foley", 24 | author_email="marc@mfoley.uk", 25 | description="Diff two sets of fonts in different browsers", 26 | url="https://github.com/googlefonts/diffbrowsers", 27 | license="Apache Software License 2.0", 28 | package_dir={"": "Lib"}, 29 | packages=find_packages("Lib"), 30 | entry_points={ 31 | "console_scripts": [ 32 | "gfdiffbrowsers = diffbrowsers.__main__:main" 33 | ], 34 | }, 35 | scripts=[ 36 | os.path.join('bin', 'test_gf_vf.py'), 37 | os.path.join('bin', 'test_gf_autohint.py'), 38 | os.path.join('bin', 'test_gf_exhaustive.py'), 39 | os.path.join('bin', 'viz_diffenator.py') 40 | ], 41 | classifiers=[ 42 | 'Development Status :: 4 - Beta', 43 | "Environment :: Console", 44 | "Environment :: Other Environment", 45 | 'Intended Audience :: Developers', 46 | 'Intended Audience :: End Users/Desktop', 47 | 'License :: OSI Approved :: Apache Software License', 48 | 'Natural Language :: English', 49 | 'Operating System :: OS Independent', 50 | 'Programming Language :: Python', 51 | 'Programming Language :: Python :: 2', 52 | 'Programming Language :: Python :: 3', 53 | 'Topic :: Multimedia :: Graphics', 54 | 'Topic :: Multimedia :: Graphics :: Graphics Conversion', 55 | 'Topic :: Multimedia :: Graphics :: Editors :: Vector-Based', 56 | ], 57 | install_requires=[ 58 | "Pillow>=5.4.1", 59 | "pybrowserstack-screenshots==0.1", 60 | "requests", 61 | ], 62 | ) 63 | -------------------------------------------------------------------------------- /tests/data/img_after.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googlefonts/diffbrowsers/94bfe028915ee2b8d5b8308a2a66bb28baea334f/tests/data/img_after.jpg -------------------------------------------------------------------------------- /tests/data/img_before.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/googlefonts/diffbrowsers/94bfe028915ee2b8d5b8308a2a66bb28baea334f/tests/data/img_before.jpg -------------------------------------------------------------------------------- /tests/test_gfregression.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import requests 3 | from diffbrowsers.gfregression import ( 4 | GF_PRODUCTION_URL, 5 | GFRegression, 6 | ) 7 | import uuid 8 | 9 | 10 | class TestGFRegressionViews(unittest.TestCase): 11 | 12 | def setUp(self): 13 | self.gf_regression = GFRegression() 14 | self.uuid = str(uuid.uuid4()) 15 | self.gf_regression.info['uuid'] = self.uuid 16 | 17 | def test_is_gfregression_running(self): 18 | request = requests.get(GF_PRODUCTION_URL) 19 | self.assertEqual(request.status_code, 200) 20 | 21 | def test_url_builder(self): 22 | """Test DiffBrowsers can build urls for GF Regression""" 23 | before_url = self.gf_regression.url('waterfall', 'before') 24 | after_url = self.gf_regression.url('waterfall', 'after') 25 | self.assertEqual('%s/screenshot/%s/waterfall/after' % (GF_PRODUCTION_URL, self.uuid), 26 | after_url) 27 | self.assertEqual('%s/screenshot/%s/waterfall/before' % (GF_PRODUCTION_URL, self.uuid), 28 | before_url) 29 | 30 | def test_url_builder_with_pt_size(self): 31 | before_url = self.gf_regression.url('glyphs_all', 'before', pt=20) 32 | after_url = self.gf_regression.url('glyphs_all', 'after', pt=20) 33 | self.assertEqual('%s/screenshot/%s/glyphs_all/after/20' % (GF_PRODUCTION_URL, self.uuid), 34 | after_url) 35 | self.assertEqual('%s/screenshot/%s/glyphs_all/before/20' % (GF_PRODUCTION_URL, self.uuid), 36 | before_url) 37 | 38 | def test_url_builder_with_style_filter(self): 39 | before_url = self.gf_regression.url('waterfall', 'before', styles=["Regular", "Bold"]) 40 | self.assertEqual('%s/screenshot/%s/waterfall/before?styles=Regular,Bold' % (GF_PRODUCTION_URL, self.uuid), 41 | before_url) 42 | after_url = self.gf_regression.url('waterfall', 'after', styles=["Regular", "Bold"]) 43 | self.assertEqual('%s/screenshot/%s/waterfall/after?styles=Regular,Bold' % (GF_PRODUCTION_URL, self.uuid), 44 | after_url) 45 | 46 | def test_extract_uuid_from_url(self): 47 | url = '%s/screenshot/%s/waterfall/after' % (GF_PRODUCTION_URL, self.uuid) 48 | self.assertEqual(self.uuid, self.gf_regression._extract_uuid(url)) 49 | 50 | url = '%s/compare/%s' % (GF_PRODUCTION_URL, self.uuid) 51 | self.assertEqual(self.uuid, self.gf_regression._extract_uuid(url)) 52 | 53 | 54 | if __name__ == '__main__': 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /tests/test_imgdiff.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from PIL import Image 4 | from diffbrowsers.diffbrowsers import compare_image 5 | 6 | class TestImgDiff(unittest.TestCase): 7 | 8 | def setUp(self): 9 | 10 | data_dir = os.path.join(os.path.dirname(__file__), 'data') 11 | self.img_before_path = os.path.join(data_dir, 'img_before.jpg') 12 | self.img_after_path = os.path.join(data_dir, 'img_after.jpg') 13 | self.img_before = Image.open(self.img_before_path) 14 | self.img_after = Image.open(self.img_after_path) 15 | 16 | def test_compare_image_are_different(self): 17 | px_diff = compare_image(self.img_before, self.img_after) 18 | self.assertNotEqual(0, px_diff) 19 | 20 | def test_compare_image_are_same(self): 21 | px_diff = compare_image(self.img_before, self.img_before) 22 | self.assertEqual(0, px_diff) 23 | 24 | def test_compare_image_ignore_first_px_rows(self): 25 | px_diff1 = compare_image(self.img_before, self.img_after) 26 | px_diff2 = compare_image(self.img_before, self.img_after, ignore_first_px_rows=None) 27 | self.assertNotEqual(px_diff1, px_diff2) 28 | 29 | 30 | if __name__ == '__main__': 31 | unittest.main() 32 | --------------------------------------------------------------------------------