summaryrefslogtreecommitdiff
path: root/wfs.py
blob: e67efbe5fec770b7e5a2afc01a603649a0a9687c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
#!/usr/bin/env python3

import argparse
import logging
import multiprocessing
import os
import pathlib
import sys

from fontTools.subset import Options, Subsetter, load_font
from selenium import webdriver

logging.basicConfig(format='%(levelname)s: %(message)s')
logger = logging.getLogger('wfs')
logger.setLevel(logging.INFO)

def make_uri(path):
    if ':' in path:
        return path
    return pathlib.Path(path).resolve().as_uri()

def start_driver(driver_name):
    if driver_name == 'chrome':
        chrome_options = webdriver.chrome.options.Options()
        chrome_options.headless = True
        chrome_options.experimental_options["prefs"] = {
            "profile.default_content_setting_values.images": 2
        }
        return webdriver.Chrome(options=chrome_options)
    if driver_name == 'firefox':
        firefox_profile = webdriver.FirefoxProfile()
        firefox_profile.set_preference('permissions.default.image', 2)
        firefox_options = webdriver.firefox.options.Options()
        firefox_options.headless = True
        return webdriver.Firefox(firefox_profile=firefox_profile, options=firefox_options)
    raise Exception('unknown driver name')

def subset(fontfile, text, fts_opts):
    logger.info('subsetting %s', fontfile)
    font = load_font(fontfile, fts_opts, dontLoadGlyphNames=True)
    subsetter = Subsetter(options=fts_opts)
    subsetter.populate(text=text)
    subsetter.subset(font)
    font.flavor = 'woff2'
    outfile = fontfile[:fontfile.rindex('.')] + '.subset.' + font.flavor
    logger.info('writing %s', outfile)
    font.save(outfile)

def main(argv):
    parser = argparse.ArgumentParser(description='Web Font Subsetter',
            epilog='see pyftsubset --help for additional options')
    parser.add_argument('--driver',
            help='selenium driver name (chrome or firefox)', default='chrome')
    parser.add_argument('--no-screenshots', help='skip screenshot validation', action='store_true')
    parser.add_argument('--font', help='add font (fontfile:family:weight:style)', action='append')
    parser.add_argument('file', help='html files', nargs='+')
    args, leftover = parser.parse_known_intermixed_args(argv)
    options = Options()
    files = args.file + options.parse_opts(leftover)
    if any(file[0] == '-' for file in files):
        parser.print_usage()
        raise Exception('bad arguments')
    if not args.no_screenshots:
        from io import BytesIO
        from PIL import Image, ImageChops
    fonts = {}
    for font in args.font:
        fontlst = font.split(':')
        fonts[(fontlst[1], fontlst[2] or '400', fontlst[3] or 'normal')] = fontlst[0]

    # clamp selenium wasteful sleeps
    import time
    sleep = time.sleep
    time.sleep = lambda secs: sleep(min(secs, 0.1))

    with start_driver(args.driver) as driver:
        font_texts = {}
        screenshots = []
        for path in args.file:
            logger.info('fetching %s', path)
            driver.get(make_uri(path))
            if not args.no_screenshots:
                logger.info('replacing fonts for %s', path)
                height = driver.execute_script("""
                    let style = document.createElement('style');
                    style.innerHTML = arguments[0];
                    document.body.appendChild(style);
                    return document.documentElement.scrollHeight;
                """, ''.join(f'''
                    @font-face{{
                        font-family: "{fontdesc[0]}";
                        font-weight: {fontdesc[1]};
                        font-style: {fontdesc[2]};
                        src: url({fontfile});
                    }}''' for fontdesc, fontfile in fonts.items()))
                logger.info('taking pre-screenshot for %s', path)
                driver.set_window_size(1920, height)
                screenshots.append((path, driver.get_screenshot_as_png()))
            logger.info('extracting text from %s', path)
            for fontstr, text in driver.execute_script(r'''
                    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
                    let node, dict = {};
                    while (node = walker.nextNode()) {
                        const cs = getComputedStyle(node.parentNode);
                        const css = k => cs.getPropertyValue(k);
                        if (css('display') == 'none') continue;
                        const k = css('font-family').replace(/"/g, '').replace(/,.*/, '') + ';' +
                                  css('font-weight') + ';' + css('font-style');
                        if (k in dict) dict[k] += node.nodeValue;
                        else dict[k] = node.nodeValue;
                    }
                    return dict;
                ''').items():

                fontspec = tuple(fontstr.split(';'))
                if fontspec in font_texts:
                    font_texts[fontspec] |= set(text)
                else:
                    font_texts[fontspec] = set(text)
        if args.no_screenshots:
            logger.info('shutting down driver')
            driver.close()
        with multiprocessing.Pool(min(len(fonts), len(os.sched_getaffinity(0)))) as fpool:
            jobs = []
            for fontspec, text in font_texts.items():
                try:
                    jobs.append((fonts[fontspec], ''.join(text), options))
                except KeyError:
                    logger.warning('missing font %s', fontspec)
            fpool.starmap(subset, jobs)
        while screenshots:
            path, start_png = screenshots.pop()
            start = Image.open(BytesIO(start_png), formats=('PNG',))
            logger.info('checking %dx%d screenshot for %s', *start.size, path)
            driver.get(make_uri(path))

            logger.info('taking post-screenshot for %s', path)
            driver.set_window_size(*start.size)

            end = Image.open(BytesIO(driver.get_screenshot_as_png()), formats=('PNG',))
            if ImageChops.difference(start.convert('RGB'), end.convert('RGB')).getbbox():
                raise Exception(f'screenshots do not match for {path}')
    logger.info('exiting successfully')

if __name__ == '__main__':
    main(sys.argv[1:])