#!/usr/bin/env python3 import argparse import logging import multiprocessing import os import signal import sys from itertools import chain from multiprocessing import Pool from multiprocessing.util import Finalize from pathlib import Path from urllib.parse import urlparse from fontTools.subset import Options, Subsetter, load_font from selenium import webdriver logging.basicConfig(format='[%(relativeCreated)d] %(message)s') logger = logging.getLogger('websubset') logger.setLevel(logging.INFO) EXTRACT_SCRIPT = r''' let whitelist = new Set(arguments[0]); let walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node, dict = {}; while (node = walker.nextNode()) { let cs = getComputedStyle(node.parentNode); let css = k => cs.getPropertyValue(k); if (css('display') == 'none') continue; let k = css('font-family').replace(/"/g, '').replace(/,.*/, '') + ';' + css('font-weight') + ';' + css('font-style'); if (!whitelist.has(k)) continue; if (!(k in dict)) dict[k] = ''; dict[k] += node.nodeValue; } return dict; ''' def gen_font_face(font): if 'fontfile' not in font: return '' return ''.join([ '@font-face{', 'font-family:"', font['family'], '";', 'font-weight:', font['weight'], ';', 'font-style:', font['style'], ';', 'src: url("', font["fontfile"], '");', '}']) DRIVER = None def stop_driver(): global DRIVER if DRIVER: DRIVER.quit() DRIVER = None def hook_sig(signum): orig_handler = signal.getsignal(signum) if orig_handler is None: raise Exception('{signum} handler is None') def term_handler(*_): stop_driver() signal.signal(signum, orig_handler) os.kill(os.getpid(), signum) signal.signal(signum, term_handler) def start_wworker(driver_name): # clamp selenium wasteful sleeps to 0.1s import time from time import sleep time.sleep = lambda secs: sleep(min(secs, 0.1)) hook_sig(signal.SIGTERM) global DRIVER 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 } DRIVER = webdriver.Chrome(options=chrome_options, desired_capabilities={'detach': True}) elif 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 DRIVER = webdriver.Firefox(firefox_profile=firefox_profile, options=firefox_options) else: raise Exception('unknown driver name') Finalize(DRIVER, stop_driver, exitpriority=16) def is_uri(path): parsed = urlparse(path) return parsed.scheme and parsed.netloc def make_uri(path): if is_uri(path): return path else: return Path(path).resolve().as_uri() def extract(path, fonts, screenshots): logger.info('fetching %s', path) DRIVER.get(make_uri(path)) if screenshots: logger.info('replacing fonts for %s', path) height = DRIVER.execute_script(''.join([ "let style = document.createElement('style'); style.innerHTML = '", ''.join(gen_font_face(font) for font in fonts), "'; document.body.appendChild(style); return document.documentElement.scrollHeight"])) logger.info('taking pre-screenshot for %s', path) DRIVER.set_window_size(1920, height) screenshot = DRIVER.get_screenshot_as_png() else: screenshot = None logger.info('extracting text from %s', path) whitelist = [';'.join((f['family'], f['weight'], f['style'])) for f in fonts] return (path, DRIVER.execute_script(EXTRACT_SCRIPT, whitelist), screenshot) def get_fontdesc(fonts, fontspec): font_match = dict(zip(('family', 'weight', 'style'), fontspec.split(';'))) for font in fonts: if font_match.items() <= font.items(): return font return None def subset(fontdesc, text, fts_opts): fontfile = fontdesc['fontfile'] 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) ret = [] outfile = fontfile[:fontfile.rindex('.')] + '.subset.woff2' ret.append((font, 'woff2', outfile)) return ret def write_subset(font, flavor, outfile): logger.info('writing %s', outfile) font.flavor = flavor font.save(outfile) def verify(path, screenshot_begin_png): logger.info('refetching %s', path) DRIVER.get(make_uri(path)) from io import BytesIO from PIL import Image, ImageChops screenshot_begin = Image.open(BytesIO(screenshot_begin_png), formats=('PNG',)).convert('RGB') logger.info('taking post-screenshot for %s', path) DRIVER.set_window_size(*screenshot_begin.size) screenshot_end_png = DRIVER.get_screenshot_as_png() screenshot_end = Image.open(BytesIO(screenshot_end_png), formats=('PNG',)).convert('RGB') logger.info('checking %s screenshots for %s', 'x'.join(map(str, screenshot_end.size)), path) if ImageChops.difference(screenshot_begin, screenshot_end).getbbox(): raise Exception(f'screenshots do not match for {path}') 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 options.with_zopfli: from fontTools.ttLib import sfnt sfnt.USE_ZOPFLI = True ncpus = len(os.sched_getaffinity(0)) fonts = [dict(zip(['fontfile', 'family', 'weight', 'style'], font.split(':'))) for font in args.font] nwworkers = min(len(files), ncpus) nfworkers = min(len(fonts), ncpus) logger.info('using %d web workers, %d font workers', nwworkers, nfworkers) with Pool(nfworkers) as fpool, \ Pool(nwworkers, start_wworker, (args.driver,)) as wpool: all_font_texts = {} screenshots = [] extract_args = ((file, fonts, not args.no_screenshots) for file in args.file) extracted = wpool.starmap(extract, extract_args) for path, font_texts, screenshot in extracted: if not args.no_screenshots: screenshots.append((path, screenshot)) for fontspec, text in font_texts.items(): if fontspec in all_font_texts: all_font_texts[fontspec] |= set(text) else: all_font_texts[fontspec] = set(text) if args.no_screenshots: logger.info('shutting down web workers early') wpool.close() subset_args = ( (get_fontdesc(fonts, fontspec), ''.join(text), options) for fontspec, text in all_font_texts.items()) subsetted = fpool.starmap(subset, subset_args) fpool.starmap(write_subset, chain(*subsetted)) if not args.no_screenshots: wpool.starmap(verify, screenshots, 1) if multiprocessing.active_children(): logger.info('waiting for workers') for proc in multiprocessing.active_children(): proc.join() logger.info('exiting successfully') if __name__ == '__main__': main(sys.argv[1:])