#!/usr/bin/env python3 import argparse import logging import re import os import signal import sys from itertools import chain import multiprocessing 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_sys(name): orig_hook = getattr(sys, name) def my_hook(*args, **kwargs): stop_driver() orig_hook(*args, **kwargs) setattr(sys, name, my_hook) 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): 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) DRIVER.execute_script(''.join([ "let style = document.createElement('style'); style.innerHTML = '", ''.join(gen_font_face(font) for font in fonts), "'; document.body.appendChild(style);"])) logger.info('taking pre-screenshot for %s', path) height = DRIVER.execute_script('return document.body.parentNode.scrollHeight') DRIVER.set_window_size(2000, height) screenshot = DRIVER.find_element_by_tag_name('body').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) if 'extratext' in fontdesc: text += fontdesc['extratext'] subsetter.populate(text=text) subsetter.subset(font) ret = [] for flavor in ['woff', 'woff2']: if 'outfile' in fontdesc and flavor in fontdesc['outfile']: outfile = fontdesc['outfile'][flavor] else: outfile = re.sub(r'\.[ot]tf$', f'.subset.{flavor}', fontfile) if outfile == fontfile: raise Exception('cannot overwrite font file') ret.append((font, flavor, 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)) logger.info('taking post-screenshot for %s', path) height = DRIVER.execute_script('return document.body.parentNode.scrollHeight') DRIVER.set_window_size(2000, height) screenshot_end_png = DRIVER.find_element_by_tag_name('body').screenshot_as_png logger.info('checking screenshot for %s', path) screenshot_begin = Image.open(BytesIO(screenshot_begin_png)) screenshot_end = Image.open(BytesIO(screenshot_end_png)) if ImageChops.difference(screenshot_begin, screenshot_end).getbbox(): raise Exception(f'screenshots do not match for {path}') class LocalPool: def __init__(self, driver_name): start_wworker(driver_name) def starmap(self, func, args, *_): return [func(*arg) for arg in args] def close(self): stop_driver() 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 (family:weight:style:fontfile)', 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(['family', 'weight', 'style', 'fontfile'], font.split(':'))) for font in args.font] nwworkers = min(len(files), ncpus) nfworkers = min(len(fonts) * 2, ncpus) logger.info('starting %d web workers, %d font workers', nwworkers, nfworkers) if not args.no_screenshots: from io import BytesIO from PIL import Image, ImageChops 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:])