#!/usr/bin/env python
#
# Copyright 2014 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Finds potential problems in Noto fonts."""

__author__ = (
    "roozbeh@google.com (Roozbeh Pournader), "
    "cibu@google.com (Cibu Johny), "
    "behdad@google.com (Behdad Esfahbod), and "
    "stuartg@google.com (Stuart Gill)"
)

import argparse
import collections
import itertools
import json
import math
from os import path
import re
import subprocess
import sys
import tempfile

from fontTools import subset
from fontTools import ttLib
from fontTools.ttLib.tables import otTables
from fontTools.misc import arrayTools
from fontTools.misc import bezierTools
from fontTools.pens import basePen

from nototools import cmap_data
from nototools import font_data
from nototools import lint_config
from nototools import notoconfig
from nototools import noto_data
from nototools import noto_fonts
from nototools import noto_names
from nototools import opentype_data
from nototools import render
from nototools import tool_utils
from nototools import unicode_data
from nototools.py23 import unichr

try:
    from future_builtins import filter
except ImportError:
    pass

# from wikipedia windows 1252 page.  As of windows 98.
WIN_ANSI_CODEPOINTS = (
    "0000-007f 00A0-00ff 20ac 201a 0192 201e 2026 2020 2021 02c6 2030 0160 2039 0152 017d"
    "2018 2019 201c 201d 2022 2013 2014 02dc 2122 0161 203a 0153 017e 0178"
)


def all_scripts():
    """Extends unicode scripts with pseudo-script 'Urdu'."""
    result = set(unicode_data.all_scripts())
    result.add("Urdu")
    return frozenset(result)


def printable_unicode_range(input_char_set):
    char_set = set(input_char_set)  # copy
    parts_list = []
    while char_set:
        last = first = min(char_set)
        while last in char_set:
            char_set.remove(last)
            last += 1
        if last == first + 1:
            part = "%04X" % first
        else:
            part = "%04X..%04X" % (first, last - 1)
        parts_list.append(part)
    return ", ".join(parts_list)


def next_circular_point(current_point, start_of_range, end_of_range):
    if current_point == end_of_range:
        return start_of_range
    else:
        return current_point + 1


def curve_between(coordinates, start_at, end_at, start_of_contour, end_of_contour):
    """Returns indices of a part of a contour between start and end of a curve.

    The contour is the cycle between start_of_contour and end_of_contour,
    and start_at and end_at are on-curve points, and the return value
    is the part of the curve between them.
    Args:
      coordinates: An slicable object containing the data.
      start_at: The index of on-curve beginning of the range.
      end_at: The index of on-curve end of the range.
      start_of_contour: The index of beginning point of the contour.
      end_of_contour: The index of ending point of the contour.
    Returns:
      A list of coordinates, including both start_at and end_at. Will go around
      the contour if necessary.
    """
    if end_at > start_at:
        return list(coordinates[start_at : end_at + 1])
    elif start_of_contour == end_of_contour:  # single-point contour
        assert start_at == end_at == start_of_contour
        return [coordinates[start_at]]
    else:  # the curve goes around the range
        return list(coordinates[start_at : end_of_contour + 1]) + list(
            coordinates[start_of_contour : end_at + 1]
        )


def curve_has_off_curve_extrema(curve):
    """Checks if a curve has off-curve extrema.

    Args:
      curve: a list of coordinates for the curve, where the first and the last
        coordinates are on-curve points, and the rest are off-curve.
    Returns:
      A boolean value, True if there are off-curve extrema,
      False if there are none.
    """
    if len(curve) <= 2:  # It's a straight line
        return False

    angles = []
    prev_x, prev_y = curve[0]
    for curr_x, curr_y in curve[1:]:
        angle = math.atan2(curr_y - prev_y, curr_x - prev_x)
        angles.append(angle)
        prev_x, prev_y = curr_x, curr_y

    # For the curve to have no local extrema, the angles must all fall in the
    # same quartet of the plane (e.g. all being between pi/2 and pi).
    #
    # There's a painful edge case, where an angle is equal to pi, and is
    # acceptable as both +pi and -pi for the logic that comes after. But since
    # the return value of math.atan2 is always in (-pi, +pi], we'll miss the -pi
    # case, resulting in false positives.
    #
    # For these cases, we check for the curve being proper once with all of
    # these set to +pi and then with all set to -pi. If the curve is proper in
    # at least one case, we assume the curve has no missing extrema.

    ninety_deg = math.pi / 2
    score = 0
    for sign in [-1, +1]:
        angles = [
            sign * math.pi if math.fabs(angle) == math.pi else angle for angle in angles
        ]
        min_quarter = math.floor(min(angles) / ninety_deg)
        max_quarter = math.ceil(max(angles) / ninety_deg)
        if math.fabs(max_quarter - min_quarter) > ninety_deg:
            score += 1

    if score == 2:  # The curve failed the test in both cases
        return out_of_box_size(curve)

    return 0


# Finds out the how far away the off-curve extrema lies from the on-curve
# points. This is done by comparing the bounding box of the endpoints with that
# of the bezier curve. If there are implicit on-curve points, the curve is
# split up into a sequence of simple 3-point curves by inserting those implicit
# points.
def out_of_box_size(curve):
    if len(curve) < 3:
        return 0

    if len(curve) > 3:
        # If curve has more than 3 points, then it has implicit on-curve points.
        # First two off-curve points.
        ax, ay = curve[1]
        bx, by = curve[2]
        # Implicit point is the mid point of first two off-curve points.
        implicit_point = ((ax + bx) / 2, (ay + by) / 2)
        first_curve = curve[:2] + [implicit_point]
        remaining_curve = [implicit_point] + curve[2:]
    else:
        # Curve with exact 3 points has no implicit on-curve point.
        first_curve = curve
        remaining_curve = []

    # Endpoints of the first curve.
    ax, ay = first_curve[0]
    bx, by = first_curve[-1]
    # Bounding box for just the endpoints.
    ex1, ey1, ex2, ey2 = min(ax, bx), min(ay, by), max(ax, bx), max(ay, by)
    # Bounding box for the bezier curve.
    bx1, by1, bx2, by2 = bezierTools.calcQuadraticBounds(*first_curve)

    # Bounding box of the bezier will contain that of the endpoints.
    # The out-of-box size for the entire curve will be maximum of the deviation
    # for the first curve and that of the remaining curve.
    delta = max(
        ex1 - bx1, ey1 - by1, bx2 - ex2, by2 - ey2, out_of_box_size(remaining_curve)
    )
    # ignore very small deviations
    return 0 if delta < 1 else delta


def calc_bounds(piece):
    if len(piece) == 2:
        return arrayTools.normRect(piece[0] + piece[1])
    else:
        return bezierTools.calcQuadraticBounds(piece[0], piece[1], piece[2])


def interpolate(start, end, amount):
    return start + amount * (end - start)


def interpolate_segment(segment, amount):
    return (
        interpolate(segment[0][0], segment[1][0], amount),
        interpolate(segment[0][1], segment[1][1], amount),
    )


def cut_piece_in_half(piece):
    if len(piece) == 2:
        mid_point = interpolate_segment(piece, 0.5)
        return (piece[0], mid_point), (mid_point, piece[1])
    else:
        return bezierTools.splitQuadraticAtT(piece[0], piece[1], piece[2], 0.5)


def cut_ends(piece, cut_amount):
    if len(piece) == 2:
        return (
            interpolate_segment(piece, cut_amount),
            interpolate_segment(piece, 1 - cut_amount),
        )
    else:
        return bezierTools.splitQuadraticAtT(
            piece[0], piece[1], piece[2], cut_amount, 1 - cut_amount
        )[1]


def probably_intersect(piece1, piece2):
    bounds1 = calc_bounds(piece1)
    bounds2 = calc_bounds(piece2)
    return arrayTools.sectRect(bounds1, bounds2)[0]


_EPSILON = 1.0 / (2**14)
_MAX_DEPTH = 30


def curve_pieces_intersect(piece1, piece2, ignore_ends):
    if ignore_ends:
        piece1 = cut_ends(piece1, _EPSILON)
        piece2 = cut_ends(piece2, _EPSILON)

    # If we are not ignoring end points, let's quickly check for end point
    # collision
    if not ignore_ends and {piece1[0], piece1[-1]} & {piece2[0], piece2[-1]}:
        return True

    pairs_to_investigate = collections.deque()
    if probably_intersect(piece1, piece2):
        pairs_to_investigate.append((piece1, piece2, 0))

    while True:
        if not pairs_to_investigate:
            return False
        section1, section2, level = pairs_to_investigate.popleft()
        section11, section12 = cut_piece_in_half(section1)
        section21, section22 = cut_piece_in_half(section2)
        for first_section in [section11, section12]:
            for second_section in [section21, section22]:
                if probably_intersect(first_section, second_section):
                    if level > _MAX_DEPTH:
                        return True
                    else:
                        pairs_to_investigate.append(
                            (first_section, second_section, level + 1)
                        )


def to_float_tuples(curve):
    coord_list = []
    for coords in curve:
        coord_list.append((float(coords[0]), float(coords[1])))
    return tuple(coord_list)


def curves_intersect(contour_list):
    """Takes a list of contours and tells if any two curves in them intersect.
    Returns a message string with an error, or None if ok.
    """
    all_contours = []
    for contour in contour_list:
        contour_pieces = []
        for curve in contour:
            if len(curve) == 2:
                contour_pieces.append(to_float_tuples(curve))
            elif len(curve) > 2:
                last_point = curve[0]
                for curve_part in basePen.decomposeQuadraticSegment(curve[1:]):
                    contour_pieces.append(to_float_tuples((last_point,) + curve_part))
                    last_point = curve_part[1]
        all_contours.append(contour_pieces)

    for contour_pieces in all_contours:
        for piece in contour_pieces:
            if piece[0] == piece[-1]:
                return "start and end segments match: %s" % str(piece[0])

    all_pieces = sum(all_contours, [])
    if len(set(all_pieces)) != len(all_pieces):
        print("some pieces are duplicates")  # No piece should be repeated

    adjacent_pairs = set()
    for contour_pieces in all_contours:
        for i in range(len(contour_pieces) - 1):
            adjacent_pairs.add(frozenset({contour_pieces[i], contour_pieces[i + 1]}))
        if len(contour_pieces) > 2:
            adjacent_pairs.add(frozenset({contour_pieces[-1], contour_pieces[0]}))

    for piece1, piece2 in itertools.combinations(all_pieces, 2):
        # FIXME(roozbeh): we are ignoring one edge case: where end points
        # of the wrong side of an adjacent pair overlap. For example, if
        # a contour curves from A to B, then immediately back to A, and then
        # on to C, we won't catch it.
        ok_to_intersect_at_ends = frozenset({piece1, piece2}) in adjacent_pairs
        if curve_pieces_intersect(piece1, piece2, ok_to_intersect_at_ends):
            return "intersection %s and %s" % (piece1, piece2)

    return None


def font_version(font):
    return font_data.get_name_records(font)[5]


def printable_font_revision(font, accuracy=2):
    font_revision = font["head"].fontRevision
    font_revision_int = int(font_revision)
    font_revision_frac = int(
        round((font_revision - font_revision_int) * 10**accuracy)
    )

    font_revision_int = str(font_revision_int)
    font_revision_frac = str(font_revision_frac).zfill(accuracy)
    return font_revision_int + "." + font_revision_frac


def printable_font_versions(font):
    version = font_version(font)
    match = re.match(r"Version (\d{1,5})\.(\d{1,5})", version)
    if match:
        major_version = match.group(1)
        minor_version = match.group(2)
        accuracy = len(minor_version)
        font_revision = printable_font_revision(font, accuracy)
        if font_revision == major_version + "." + minor_version:
            return version
    else:
        font_revision = printable_font_revision(font, 3)
    return "%s (head fontRevision: %s)" % (version, font_revision)


def _build_cmap_dict(filename):
    filename = tool_utils.resolve_path(filename)
    data = cmap_data.read_cmap_data_file(filename)
    script_to_rowdata = cmap_data.create_map_from_table(data.table)
    return {
        script: frozenset(tool_utils.parse_int_ranges(rd.ranges))
        for script, rd in script_to_rowdata.items()
    }


_phase_2_map = None
_phase_3_map = None


def _get_cmap_data_for_phase(phase):
    global _phase_2_map, _phase_3_map
    if phase < 3:
        if not _phase_2_map:
            _phase_2_map = _build_cmap_dict(
                "[tools]/nototools/data/noto_cmap_phase2.xml"
            )
        return _phase_2_map
    else:
        if not _phase_3_map:
            _phase_3_map = _build_cmap_dict(
                "[tools]/nototools/data/noto_cmap_phase3.xml"
            )
        return _phase_3_map


HARD_CODED_FONT_INFO = {
    "AndroidEmoji.ttf": ("Sans", "Zsye", None, "Regular"),
    "DroidEmoji.ttf": ("Sans", "Zsye", None, "Regular"),
    "NotoEmoji-Regular.ttf": ("", "Zsye", None, "Regular"),
    "NotoNaskh-Regular.ttf": ("Naskh", "Arab", None, "Regular"),
    "NotoNaskh-Bold.ttf": ("Naskh", "Arab", None, "Bold"),
    "NotoNaskhUI-Regular.ttf": ("Naskh", "Arab", "UI", "Regular"),
    "NotoNaskhUI-Bold.ttf": ("Naskh", "Arab", "UI", "Bold"),
    "NotoSansCypriotSyllabary-Regular.ttf": ("Sans", "Cprt", None, "Regular"),
    "NotoSansEmoji-Regular.ttf": ("Sans", "Zsye", None, "Regular"),
    "NotoSansKufiArabic-Regular.ttf": ("Kufi", "Arab", None, "Regular"),
    "NotoSansKufiArabic-Bold.ttf": ("Kufi", "Arab", None, "Bold"),
    "NotoSansMath-Regular.ttf": ("Sans", "Zmth", None, "Regular"),
    "NotoSansSymbols-Regular.ttf": ("Sans", "Zsym", None, "Regular"),
    "NotoNastaliqUrduDraft.ttf": ("Nastaliq", "Urdu", None, "Regular"),
    "NotoNastaliq-Regular.ttf": ("Nastaliq", "Urdu", None, "Regular"),
}

# these are based on a upem of 2048
MAX_UI_HEIGHT = 2163
MIN_UI_HEIGHT = -555
UI_ASCENT = 2189
UI_DESCENT = -600

_cur_file_name = None
_printed_file_name = False
_processed_files = 0
_processed_files_with_errors = 0
_processed_files_with_warnings = 0

FontProps = collections.namedtuple(
    "FontProps",
    "is_google, vendor, char_version, "
    "filepath, family, style, script, variant, width, weight, slope, fmt, "
    "manufacturer, license_type, is_hinted, is_mono, is_UI, is_UI_metrics, "
    "is_display, is_cjk, subset",
)


def font_properties_from_name(file_path, phase):
    noto_font = noto_fonts.get_noto_font(file_path, phase=phase)
    if not noto_font:
        return None

    is_google = True
    vendor = (
        "Adobe"
        if noto_font.is_cjk
        else "KhmerType"
        if noto_font.script in ["Khmr", "Cham", "Laoo"]
        else "Monotype"
    )

    char_version = 6.0 if noto_font.family == "Noto" else 8.0
    return FontProps(is_google, vendor, char_version, *noto_font)


def get_font_properties_with_fallback(file_path, phase):
    props = font_properties_from_name(file_path, phase)
    if props:
        return props, "" if props.script else "script"

    basename = path.basename(file_path)
    if not basename in HARD_CODED_FONT_INFO:
        return None, None

    style, script, ui, weight = HARD_CODED_FONT_INFO[basename]
    return (
        FontProps(
            True,
            "Monotype",
            6.0,
            file_path,
            "Noto",
            style,
            script,
            "",
            weight,
            None,
            "ttf",
            "sil",
            False,
            False,
            bool(ui),
            bool(ui),
            False,
            "",
        ),
        "name",
    )


def check_font(
    font_props,
    filename_error,
    lint_spec,
    runlog=False,
    skiplog=False,
    csv_flag=False,
    info_flag=False,
    extrema_details=True,
    nowarn=False,
    quiet=False,
    noto_phase=3,
    variable=False,
):
    global _processed_files

    _processed_files += 1

    def _noto_font_from_font_props(font_props):
        fields = """
          filepath,family,style,script,variant,width,weight,slope,fmt,
          manufacturer,license_type,is_hinted,is_mono,is_UI,is_UI_metrics,
          is_display,is_cjk,subset
      """.split(
            ","
        )
        vals = [getattr(font_props, p.strip()) for p in fields]
        return noto_fonts.NotoFont(*vals)

    noto_font = _noto_font_from_font_props(font_props)

    def warn(
        test_name, category_name, message, details=True, is_error=True, check_test=True
    ):
        global _cur_file_name, _printed_file_name
        global _processed_files_with_errors, _processed_files_with_warnings

        def print_file_name():
            global _printed_file_name
            if not _printed_file_name:
                _printed_file_name = True
                print(
                    "---\nAutomatic testing for '%s', %s:"
                    % (_cur_file_name, printable_font_versions(font))
                )

        if check_test and not tests.check(test_name):
            return

        interesting_part_of_file_name = ",".join(font_props.filepath.split("/")[-2:])
        if interesting_part_of_file_name != _cur_file_name:
            _cur_file_name = interesting_part_of_file_name
            _printed_file_name = False

        # Assumes "info" only and always comes at the end of
        # processing a file.
        if category_name == "info":

            def pluralize_errmsg(count, is_error=True):
                msg = "error" if is_error else "warning"
                if count == 0:
                    return "no %ss" % msg
                elif count == 1:
                    return "1 " + msg
                else:
                    return "%d %ss" % (count, msg)

            ec = err_count[0]
            wc = warn_count[0]
            if not csv_flag and (not quiet or ec or (wc and not nowarn)):
                print_file_name()
                se = suppressed_err_count[0]
                if not se:
                    print("Found %s." % pluralize_errmsg(ec))
                else:
                    print(
                        "Found %s (%s hidden)."
                        % (pluralize_errmsg(ec), "all" if se == ec else se)
                    )
                if wc and not nowarn:
                    sw = suppressed_warn_count[0]
                    if not sw and wc:
                        print("Found %s." % pluralize_errmsg(wc, False))
                    elif wc:
                        print(
                            "Found %s (%s hidden)."
                            % (pluralize_errmsg(wc, False), "all" if sw == wc else sw)
                        )

            if ec:
                _processed_files_with_errors += 1
            elif wc:
                _processed_files_with_warnings += 1

            if not info_flag:
                return

        if is_error:
            err_count[0] += 1
        else:
            warn_count[0] += 1

        if is_error:
            if not details:
                suppressed_err_count[0] += 1
        else:
            if nowarn or not details:
                suppressed_warn_count[0] += 1

        if not details:
            return

        if nowarn and not is_error:
            return

        if not csv_flag:
            print_file_name()

        err_type = (
            "Info" if category_name == "info" else "Error" if is_error else "Warning"
        )
        if csv_flag:
            names = []
            if font_props.weight != "Regular" or not font_props.slope:
                names.append(font_props.weight)
            if font_props.slope:
                names.append(font_props.slope)
            subfamily = "".join(names)
            print(
                (
                    '%s,%s,%s,%s,%s,%s,%s,%s,%s,"%s"'
                    % (
                        err_type,
                        noto_fonts.script_name_for_report(font_props.script),
                        font_props.style if font_props.style else "",
                        font_props.variant if font_props.variant else "",
                        subfamily,
                        font_data.get_name_records(font)[8].split()[0],
                        category_name,
                        interesting_part_of_file_name,
                        printable_font_revision(font),
                        message,
                    )
                ).encode("UTF-8")
            )
        else:
            print(
                "%s <%s> %s"
                % (err_type[0], test_name, message.encode("UTF-8").decode("UTF-8"))
            )
        sys.stdout.flush()

    _script_key_to_font_name = {
        "Aran": "Urdu",
        "HST": "Historic",
        "LGC": None,
        "Zsye": None,
    }

    def _check_unused_names():
        # For now, just a warning, and we don't actually check if other tables use it.
        # Add those checks as we need them.  See the GPOS/GSUB checks of name references
        # for an example of how we'd check.
        if not tests.check("name/unused"):
            return
        names = font_data.get_name_records(font)
        for i in names:
            # names 255 and below are reserved for standard names
            # names 256-32767 are for use by font tables
            # names 23 and 24 are for use by CPAL, so it might be considered a mistake if
            # these are present and no CPAL table is present or it doesn't use them.  Not
            # checking this for now.
            if i >= 256:
                warn(
                    "name/unused",
                    "Name",
                    'Name table has record #%d: "%s"' % (i, names[i]),
                    is_error=False,
                )

    def _check_name(actual, expected, keyname, is_re):
        """Set expected to '-' to require any name, set it to None if a name
        entry should not be present."""

        test_key = "name/" + keyname.replace(" ", "_")
        if not tests.check(test_key):
            return

        xre = " it to match" if is_re else ""
        if expected:
            if not actual:
                if expected == "-":
                    warn(test_key, keyname, "The %s is not set." % keyname)
                else:
                    warn(
                        test_key,
                        keyname,
                        "The %s is not set, but was expecting%s '%s'"
                        % (keyname, xre, expected),
                    )
            elif expected != "-":
                if is_re:
                    match = re.match(expected, actual)
                else:
                    match = expected == actual
                if not match:
                    warn(
                        test_key,
                        keyname,
                        "The %s is '%s' but was expecting%s '%s'"
                        % (keyname, actual, xre, expected),
                    )
        elif actual:
            warn(test_key, keyname, "Expected no %s, but got '%s'" % (keyname, actual))

    def check_name_table():
        if not tests.check("name"):
            return

        _check_unused_names()

        family_to_name_info = noto_names.family_to_name_info_for_phase(noto_phase)
        name_data = noto_names.name_table_data(
            noto_font, family_to_name_info, noto_phase
        )
        if not name_data:
            warn(
                "name/unable_to_check",
                "Unable to check",
                "No name data available for this font.",
            )
            return

        names = font_data.get_name_records(font)

        def _check_idx(idx, expected, keyname):
            actual = names.get(idx, None)
            is_re = expected and expected[0] == "^" and expected[-1] == "$"
            _check_name(actual, expected, keyname, is_re=is_re)

        _check_idx(0, name_data.copyright_re, "copyright")
        _check_idx(1, name_data.original_family, "family")
        _check_idx(2, name_data.original_subfamily, "subfamily")
        _check_idx(3, name_data.unique_id, "unique id")
        _check_idx(4, name_data.full_name, "full name")
        _check_idx(5, name_data.version_re, "version")
        _check_idx(6, name_data.postscript_name, "postscript name")
        _check_idx(7, name_data.trademark, "trademark")
        _check_idx(8, name_data.manufacturer, "manufacturer")
        _check_idx(9, name_data.designer, "designer")
        _check_idx(10, name_data.description_re, "description")
        _check_idx(11, name_data.vendor_url, "vendor url")
        _check_idx(12, name_data.designer_url, "designer url")
        _check_idx(13, name_data.license_text, "license")
        _check_idx(14, name_data.license_url, "license_url")

        if noto_font.is_cjk or noto_phase > 2:
            # 15 reserved
            _check_idx(16, name_data.preferred_family, "preferred family")
            _check_idx(17, name_data.preferred_subfamily, "preferred subfamily")
            # 18-20 (compatible full, sample text, postscript cid) - we don't check these
            _check_idx(21, name_data.wws_family, "wws family")
            _check_idx(22, name_data.wws_subfamily, "wws subfamily")
            # 23-24 (light/dark palette) - we don't check these

        if not tests.check("name/version"):
            return

        match = re.match(name_data.version_re, names[5])
        if not match:
            return  # already caught above

        major_version = match.group(1)
        minor_version = match.group(2)
        version_string = "%s.%s" % (major_version, minor_version)
        if (0 <= int(major_version) <= 65535) and (0 <= int(minor_version) <= 65535):
            accuracy = len(minor_version)
            font_revision = printable_font_revision(font, accuracy)
            if font_revision != major_version + "." + minor_version:
                warn(
                    "name/version/match_head",
                    "Font Revision",
                    "fontRevision in 'head' table is %s, "
                    "while font version in 'name' table is %s.%s."
                    % (font_revision, major_version, minor_version),
                )
        else:
            warn(
                "name/version/out_of_range",
                "Version",
                "Version string has numerical parts outside the range "
                "[0, 65535]: '%s'." % version_string,
            )

    def _get_required_chars(noto_font, noto_phase, test_key):
        script_to_chars = _get_cmap_data_for_phase(noto_phase)
        # do we need to map font names/families to scripts differently based
        # on the phase?
        try:
            return script_to_chars[noto_font.script]
        except KeyError:
            warn(
                test_key,
                "Chars",
                "no char data for script %s in %s"
                % (noto_font.script, noto_font.filepath),
            )
        return None

    def _check_needed_chars(cmap, char_filter):
        # TODO(roozbeh): check the glyph requirements for controls specified at
        # https://www.microsoft.com/typography/otspec/recom.htm

        needed_chars = _get_required_chars(
            noto_font, noto_phase, "cmap/script_required"
        )
        if needed_chars is None:
            return

        # TODO: also check character coverage against Unicode blocks for
        # characters of script Common or Inherited

        if char_filter:
            # old_needed_size = len(needed_chars)
            needed_chars = set(filter(char_filter[1].accept, needed_chars))
            # TODO(dougfelt): figure out how to make this info available without messing up output
            # print('filter needed char size: %d -> %d' % (old_needed_size, len(needed_chars))

        missing_chars = needed_chars - set(cmap.keys())
        if missing_chars:
            warn(
                "cmap/script_required",
                "Chars",
                "The following %d characters are missing from the font: %s."
                % (len(missing_chars), printable_unicode_range(missing_chars)),
                check_test=False,
            )

    def _check_unexpected_chars(cmap, char_filter):
        expected_chars = _get_required_chars(noto_font, noto_phase, "cmap/unexpected")
        if expected_chars is None:
            return
        unexpected_chars = set(cmap) - expected_chars
        if char_filter and unexpected_chars:
            unexpected_chars = set(filter(char_filter[1].accept, unexpected_chars))
        if unexpected_chars:
            warn(
                "cmap/script_unexpected",
                "Chars",
                "The following %d chars were not expected in the font: %s"
                % (len(unexpected_chars), printable_unicode_range(unexpected_chars)),
                is_error=False,
                check_test=False,
            )

    def check_cmap_table():
        cmap_table = font["cmap"]
        cmaps = {}
        # Format 14 is variation sequences
        expected_tables = [(4, 3, 1), (12, 3, 10), (14, 0, 5), (4, 0, 3), (12, 0, 4)]
        if font_props.is_cjk:
            expected_tables.extend(
                [
                    # Adobe says historically some programs used these to identify
                    # the script in the font.  The encodingID is the quickdraw script
                    # manager code.  These are dummy tables.
                    (6, 1, 1),  # Japanese
                    (6, 1, 2),  # Traditional Chinese
                    (6, 1, 3),  # Korean
                    (6, 1, 25),  # Simplified Chinese
                ]
            )
        for table in cmap_table.tables:
            if (table.format, table.platformID, table.platEncID) not in expected_tables:
                warn(
                    "cmap/tables/unexpected",
                    "cmap",
                    "'cmap' has a subtable of (format=%d, platform=%d, encoding=%d), "
                    "which it shouldn't have."
                    % (table.format, table.platformID, table.platEncID),
                )
            elif table != (12, 0, 4):
                if table.format in cmaps:
                    # if we have both 4,3,1 and 4,0,3, they should be aliases
                    # similarly if we have both 12,3,10 and 12,0,4
                    if id(table.cmap) != id(cmaps[table.format]):
                        warn(
                            "cmap/tables/notaliased",
                            "cmap",
                            "'cmap' has two format %d subtables that are not"
                            " aliases" % table.format,
                        )
                else:
                    cmaps[table.format] = table.cmap

        if 4 not in cmaps:
            warn(
                "cmap/tables/missing",
                "cmap",
                "'cmap' does not have a format 4 subtable, but it should.",
            )

        if 12 in cmaps:
            cmap = cmaps[12]
            # if there is a format 12 table, it should have non-BMP characters
            if max(cmap.keys()) <= 0xFFFF:
                warn(
                    "cmap/tables/format_12_has_bmp",
                    "cmap",
                    "'cmap' has a format 12 subtable but no " "non-BMP characters.",
                )

            # format 4 table should be a subset of the format 12 one
            if tests.check("cmap/tables/format_4_subset_of_12") and 4 in cmaps:
                for char in cmaps[4]:
                    if char not in cmap:
                        warn(
                            "cmap/tables/format_4_subset_of_12",
                            "cmap",
                            "U+%04X is mapped in cmap's format 4 subtable but "
                            "not in the format 12 one." % char,
                            check_test=False,
                        )
                    elif cmaps[4][char] != cmap[char]:
                        warn(
                            "cmap/tables/format_4_subset_of_12",
                            "cmap",
                            "U+%04X is mapped to %s in cmap's format 4 "
                            "subtable but to %s in the format 12 one."
                            % (char, cmaps[4][char], cmap[char]),
                            check_test=False,
                        )
        else:
            cmap = cmaps[4]

        if tests.check("cmap/required"):
            required_in_all_fonts = [0x0000, 0x000D, 0x0020]  # .null  # CR  # space
            for code in required_in_all_fonts:
                if code not in cmap:
                    warn(
                        "cmap/required",
                        "cmap",
                        "U+%04X is not mapped in cmap, but it should be (see "
                        "https://www.microsoft.com/typography/otspec/recom.htm)."
                        % code,
                        check_test=False,
                    )

        if not font_props.is_cjk and tests.check("cmap/script_required"):
            _check_needed_chars(cmap, tests.get_filter("cmap/script_required"))

        if tests.check("cmap/private_use"):
            needed_chars = _get_required_chars(
                noto_font, noto_phase, "cmap/private_use"
            )
            pua_filter = tests.get_filter("cmap/private_use")
            if pua_filter:
                pua_filter = pua_filter[1].accept

            def is_unwanted_pua(char):
                if not needed_chars:
                    return False
                if char in needed_chars:
                    return False
                if not unicode_data.is_private_use(char):
                    return False
                if not pua_filter:
                    return True
                return pua_filter(char)

            privates_in_cmap = {char for char in cmap if is_unwanted_pua(char)}
            if privates_in_cmap:
                warn(
                    "cmap/private_use",
                    "Chars",
                    "There should be no (non-required) private use characters "
                    "defined in the font, but there are %d: %s."
                    % (
                        len(privates_in_cmap),
                        printable_unicode_range(privates_in_cmap),
                    ),
                    check_test=False,
                )

        if tests.check("cmap/non_characters"):
            non_characters = frozenset(
                list(range(0xFDD0, 0xFDEF + 1))
                + [0xFFFE + plane_no * 0x10000 for plane_no in range(0, 17)]
                + [0xFFFF + plane_no * 0x10000 for plane_no in range(0, 17)]
            )
            non_characters_in_cmap = non_characters & set(cmap.keys())
            if non_characters_in_cmap:
                warn(
                    "cmap/non_characters",
                    "Chars",
                    "There should be no non-characters defined in the font, but "
                    "there are: %s." % printable_unicode_range(non_characters_in_cmap),
                    check_test=False,
                )

        if tests.check("cmap/disallowed_ascii") and not (
            font_props.script == "Zsye"
            or font_props.script == "Latn"
            or font_props.script == "LGC"
            or font_props.is_cjk
        ):
            ascii_letters = noto_data.ascii_letters()
            contained_letters = ascii_letters & set(cmap.keys())
            if contained_letters:
                warn(
                    "cmap/disallowed_ascii",
                    "Chars",
                    "There should not be ASCII letters in the font, but there are: %s."
                    % printable_unicode_range(contained_letters),
                    check_test=False,
                )

        if tests.check("cmap/unexpected"):
            # filter if present should list chars we do not want to warn on.
            _check_unexpected_chars(cmap, tests.get_filter("cmap/unexpected"))

        return cmap

    def check_variants():
        if not tests.check("cmap/variants"):
            return

        cmap = font_data.get_cmap(font)
        variant_cps = unicode_data.variant_data_cps()
        cps_with_variants = set(cmap.keys()) & variant_cps
        if not cps_with_variants:
            return

        vs_cmap = font_data.get_variation_sequence_cmap(font)
        if not vs_cmap:
            num = len(cps_with_variants)
            info = lint_config.write_int_ranges(cps_with_variants, sep=", ")
            if len(info) > 50:
                info = "not shown"
            warn(
                "cmap/variants",
                "Variants",
                "Font contains %d characters with standard variants, but has "
                "no variation selector cmap table (%s)." % (num, info),
            )
            return

        for cp in sorted(cps_with_variants):
            for sel, varcp, _ in sorted(unicode_data.get_variant_data(cp)):
                if not sel in vs_cmap.uvsDict:
                    warn(
                        "cmap/variants",
                        "Variants",
                        "Char %04x has standard variant selector %04x, but "
                        "this selector is not in the variant table." % (cp, sel),
                        check_test=False,
                    )
                    continue
                sel_info = None
                for t in vs_cmap.uvsDict[sel]:
                    if t[0] == cp:
                        sel_info = t
                        break
                if not sel_info:
                    warn(
                        "cmap/variants",
                        "Variants",
                        "Char %04x has no variant for selector %04x." % (cp, sel),
                        check_test=False,
                    )
                    continue

                if varcp != -1:
                    if varcp not in cmap:
                        warn(
                            "cmap/variants",
                            "Variants",
                            "Char %04x and selector %04x should map to the "
                            "same glyph as char %04x, but %04x is not in the "
                            "cmap." % (cp, sel, varcp, varcp),
                            check_test=False,
                        )
                        continue

                    expected_glyphid = cmap[varcp]
                    sel_glyphid = sel_info[1] if sel_info[1] else cmap[cp]
                    if expected_glyphid != sel_glyphid:
                        warn(
                            "cmap/variants",
                            "Variants",
                            "Char %04x and selector %04x map to glyph %s, "
                            "which should be the same glyph as is mapped to "
                            "by %04x, but its glyph is %s."
                            % (cp, sel, sel_glyphid, varcp, expected_glyphid),
                            check_test=False,
                        )

    def check_head_tables(cmap):
        if not tests.check("head"):
            return

        def check_ul_unicode_range():
            if not tests.check("head/os2/unicoderange"):
                return

            bitmap = font_data.get_os2_unicoderange_bitmap(font)
            expected_info = opentype_data.collect_unicoderange_info(cmap)
            expected_bitmap = font_data.unicoderange_info_to_bitmap(expected_info)
            difference = bitmap ^ expected_bitmap
            if not difference:
                return

            for bucket_index in range(128):
                if difference & (1 << bucket_index):
                    bucket_info = opentype_data.unicoderange_bucket_index_to_info(
                        bucket_index
                    )
                    range_name = opentype_data.unicoderange_bucket_info_name(
                        bucket_info
                    )
                    chars_in_bucket = sum(
                        t[0] for t in expected_info if t[1][2] == bucket_index
                    )
                    size_of_bucket = opentype_data.unicoderange_bucket_info_size(
                        bucket_info
                    )
                    set_unset = (
                        "not be set" if bitmap & (1 << bucket_index) else "be set"
                    )
                    warn(
                        "head/os2/unicoderange",
                        "Range bit",
                        "ulUnicodeRange bit %d (%s) should %s (cmap has "
                        "%d of %d codepoints in this range)"
                        % (
                            bucket_index,
                            range_name,
                            set_unset,
                            chars_in_bucket,
                            size_of_bucket,
                        ),
                        check_test=False,
                    )

            # print(printable_unicode_range(set(cmap.keys()))
            # print("expected %s" % font_data.unicoderange_bitmap_to_string(expected_bitmap)
            # print("have %s" % font_data.unicoderange_bitmap_to_string(bitmap)

        hhea_table = font["hhea"]
        upem = font["head"].unitsPerEm
        if upem == 2048:
            ui_ascent = UI_ASCENT
            ui_descent = UI_DESCENT
        else:
            ui_ascent = int(math.ceil(UI_ASCENT * upem / 2048.0))
            ui_descent = int(math.floor(UI_DESCENT * upem / 2048.0))

        if tests.check("head/hhea"):
            if font_props.is_UI_metrics:
                if hhea_table.ascent > ui_ascent:
                    warn(
                        "head/hhea/ascent",
                        "Bounds",
                        "Value of ascent in 'hhea' table is %d, but should be %d."
                        % (hhea_table.ascent, ui_ascent),
                    )
                if hhea_table.descent < ui_descent:
                    warn(
                        "head/hhea/descent",
                        "Bounds",
                        "Value of descent in 'hhea' table is %d, but should be %d."
                        % (hhea_table.descent, ui_descent),
                    )

            if hhea_table.lineGap != 0:
                warn(
                    "head/hhea/linegap",
                    "Line Gap",
                    "Value of lineGap in 'hhea' table is %d, but should be 0."
                    % hhea_table.lineGap,
                )

        vhea_table = font.get("vhea")
        if tests.check("head/vhea") and vhea_table:
            if vhea_table.lineGap != 0:
                warn(
                    "head/vhea/linegap",
                    "Line Gap",
                    "Value of lineGap in 'vhea' table is %d, but should be 0."
                    % vhea_table.lineGap,
                )

        os2_table = font["OS/2"]

        if tests.check("head/os2"):
            if os2_table.fsType != 0:
                warn(
                    "head/os2/fstype",
                    "OS/2",
                    "Value of fsType in the 'OS/2' table is 0x%04X, but should "
                    "be 0." % os2_table.fsType,
                )
            if os2_table.sTypoAscender != hhea_table.ascent:
                warn(
                    "head/os2/ascender",
                    "OS/2",
                    "Value of sTypoAscender in 'OS/2' table (%d) is different "
                    "from the value of Ascent in 'hhea' table (%d), "
                    "but they should be equal."
                    % (os2_table.sTypoAscender, hhea_table.ascent),
                )
            if os2_table.sTypoDescender != hhea_table.descent:
                warn(
                    "head/os2/descender",
                    "OS/2",
                    "Value of sTypoDescender in 'OS/2' table (%d) is different "
                    "from the value of Descent in 'hhea' table (%d), "
                    "but they should be equal."
                    % (os2_table.sTypoDescender, hhea_table.descent),
                )
            if os2_table.sTypoLineGap != 0:
                warn(
                    "head/os2/linegap",
                    "OS/2",
                    "Value of sTypoLineGap in 'OS/2' table is %d, but "
                    "should be 0." % os2_table.sTypoLineGap,
                )

            if os2_table.usWinAscent != hhea_table.ascent:
                warn(
                    "head/os2/winascent",
                    "OS/2",
                    "Value of usWinAscent in 'OS/2' table (%d) is "
                    "different from the value of Ascent in 'hhea' table (%d), "
                    "but they should be equal."
                    % (os2_table.usWinAscent, hhea_table.ascent),
                )
            if os2_table.usWinDescent != -hhea_table.descent:
                warn(
                    "head/os2/windescent",
                    "OS/2",
                    "Value of usWinDescent in 'OS/2' table (%d) is different "
                    "from the opposite of value of Descent in 'hhea' table (%d), "
                    "but they should be opposites."
                    % (os2_table.usWinDescent, hhea_table.descent),
                )
            if font_props.is_google and os2_table.achVendID != "GOOG":
                warn(
                    "head/os2/achvendid",
                    "OS/2",
                    "Value of achVendID in the 'OS/2' table is %s, "
                    "but should be GOOG." % os2_table.achVendID,
                )

            expected_weight = noto_fonts.WEIGHTS.get(font_props.weight, None)
            if not expected_weight:
                raise ValueError("unexpected weight: %s" % font_props.weight)

            # hack for windows GDI
            # remove this for phase 3
            if noto_phase <= 2:
                expected_weight = max(expected_weight, 250)

            if os2_table.usWeightClass != expected_weight:
                warn(
                    "head/os2/weight_class",
                    "OS/2",
                    "Value of usWeightClass in 'OS/2' table is %d, but should "
                    "be %d." % (os2_table.usWeightClass, expected_weight),
                )

            OS2_SEL_ITALIC_MASK = 1
            OS2_SEL_BOLD_MASK = 1 << 5
            OS2_SEL_REGULAR_MASK = 1 << 6
            OS2_SEL_USE_TYPO_METRICS_MASK = 1 << 7
            OS2_SEL_WWS_MASK = 1 << 8
            if os2_table.fsSelection & OS2_SEL_REGULAR_MASK:
                if os2_table.fsSelection & OS2_SEL_ITALIC_MASK:
                    warn(
                        "head/os2/fsselection",
                        "OS/2",
                        "fsSelection Regular bit is set, so the Italic bit should be clear.",
                    )
                if os2_table.fsSelection & OS2_SEL_BOLD_MASK:
                    warn(
                        "head/os2/fsselection",
                        "OS/2",
                        "fsSelection Regular bit is set, so the Bold bit should be clear.",
                    )

            if os2_table.fsSelection & OS2_SEL_USE_TYPO_METRICS_MASK:
                warn(
                    "head/os2/fsselection",
                    "OS/2",
                    "UseTypoMetrics bit in fsSelection is set, but should be clear.",
                )

            check_ul_unicode_range()

            if os2_table.panose.bFamilyType != 2:
                warn(
                    "head/os2/panose/family",
                    "OS/2",
                    "Panose family value is %s but expected 2"
                    % os2_table.panose.bSerifStyle,
                )

            expect_serif = noto_font.style == "Serif" or noto_font.family in [
                "Cousine",
                "Tinos",
            ]
            expected_serif_range_str = "2-10" if expect_serif else "11-15"
            serif_val = os2_table.panose.bSerifStyle
            is_serif = 1 < serif_val < 11
            if serif_val == 1:
                warn(
                    "head/os2/panose/serif",
                    "OS/2",
                    "Panose serif value is 1 (no_fit) but expected 0 or %s"
                    % expected_serif_range_str,
                )
            elif serif_val != 0 and expect_serif != is_serif:
                warn(
                    "head/os2/panose/serif",
                    "OS/2",
                    "Panose serif value is %s but expected %s"
                    % (serif_val, expected_serif_range_str),
                )

            # TODO(dougfelt): check condensed, semicondensed proportions?
            expect_mono = noto_font.is_mono or noto_font.family == "Cousine"
            expect_mono_range_str = "9" if expect_mono else "2-4"
            proportion_val = os2_table.panose.bProportion
            is_mono = proportion_val == 9
            if proportion_val <= 1:

                warn(
                    "head/os2/panose/proportion",
                    "OS/2",
                    "Panose proportion value is %s (%s) but "
                    "expected %s"
                    % (
                        proportion_val,
                        "no_fit" if proportion_val == 1 else "any",
                        expect_mono_range_str,
                    ),
                )
            elif expect_mono != is_mono:
                warn(
                    "head/os2/panose/proportion",
                    "OS/2",
                    "Panose proportion value is %s but expected %s"
                    % (proportion_val, expect_mono_range_str),
                )

    def check_vertical_limits():
        if "glyf" not in font:
            return

        if not tests.check("bounds"):
            return

        upem = font["head"].unitsPerEm
        if upem == 2048:
            max_ui_height = MAX_UI_HEIGHT
            min_ui_height = MIN_UI_HEIGHT
        else:
            max_ui_height = int(math.ceil(MAX_UI_HEIGHT * upem / 2048.0))
            min_ui_height = int(math.floor(MIN_UI_HEIGHT * upem / 2048.0))

        glyf_table = font["glyf"]
        us_win_ascent = font["OS/2"].usWinAscent
        us_win_descent = font["OS/2"].usWinDescent
        typo_ascent = font["OS/2"].sTypoAscender
        typo_descent = font["OS/2"].sTypoDescender

        # Build win ansi glyph set.  These we compare against usWinAscent/Descent, the
        # rest we compare against sTypoAscender/Descender. Of course, these should be
        # the same, and it's still ok for glyphs to exceed the typo ascender/descender--
        # but it should be exceptional.
        tmp_gids = set()
        cmap = font_data.get_cmap(font)
        for cp in lint_config.parse_int_ranges(WIN_ANSI_CODEPOINTS, True):
            if cp in cmap:
                tmp_gids.add(font.getGlyphID(cmap[cp], requireReal=True))
        win_ansi_gids = frozenset(tmp_gids)

        font_ymin = None
        font_ymax = None
        glyph_set = font.getGlyphSet()
        for glyph_index in range(len(glyf_table.glyphOrder)):
            glyph_name = glyf_table.glyphOrder[glyph_index]
            glyph = glyf_table[glyph_name]
            # Compute the ink's yMin and yMax

            ttglyph = glyph_set[glyph_name]
            ymin, ymax = render.get_glyph_cleaned_extents(ttglyph, glyph_set)
            font_ymin = render.min_with_none(font_ymin, ymin)
            font_ymax = render.max_with_none(font_ymax, ymax)

            if not tests.check("bounds/glyph"):
                continue

            is_win_ansi = glyph_index in win_ansi_gids
            if is_win_ansi:
                ascent_limit = us_win_ascent
                ascent_name = "usWinAscent"
                descent_limit = -us_win_descent
                descent_name = "usWinDescent"
            else:
                ascent_limit = typo_ascent
                ascent_name = "sTypoAscent"
                descent_limit = typo_descent
                descent_name = "sTypoDescent"

            if font_props.is_UI_metrics:
                if (
                    tests.checkvalue("bounds/glyph/ui_ymax", glyph_index)
                    and ymax is not None
                    and ymax > max_ui_height
                ):
                    warn(
                        "bounds/glyph/ui_ymax",
                        "UI Bounds",
                        "Real yMax for glyph %d (%s) is %d, which is more than "
                        "max ui height %d."
                        % (glyph_index, glyph_name, ymax, max_ui_height),
                        check_test=False,
                    )
                if (
                    tests.checkvalue("bounds/glyph/ui_ymin", glyph_index)
                    and ymin is not None
                    and ymin < min_ui_height
                ):
                    warn(
                        "bounds/glyph/ui_ymin",
                        "UI Bounds",
                        "Real yMin for glyph %d (%s) is %d, which is less than "
                        "min ui height %d."
                        % (glyph_index, glyph_name, ymin, min_ui_height),
                        check_test=False,
                    )

            if (
                tests.checkvalue("bounds/glyph/ymax", glyph_index)
                and ymax is not None
                and ymax > ascent_limit
            ):
                warn(
                    "bounds/glyph/ymax",
                    "Bounds",
                    "Real yMax for glyph %d (%s) is %d, which is higher than "
                    "the font's %s (%d), resulting in clipping."
                    % (glyph_index, glyph_name, ymax, ascent_name, ascent_limit),
                    check_test=False,
                )

            if (
                tests.checkvalue("bounds/glyph/ymin", glyph_index)
                and ymin is not None
                and ymin < descent_limit
            ):
                warn(
                    "bounds/glyph/ymin",
                    "Bounds",
                    "Real yMin for glyph %d (%s) is %d, which is lower than "
                    "the font's %s (%d), resulting in clipping."
                    % (glyph_index, glyph_name, ymin, descent_name, descent_limit),
                    check_test=False,
                )

        if tests.check("bounds/font"):
            if font_props.is_UI_metrics:
                if font_ymax > max_ui_height:
                    warn(
                        "bounds/font/ui_ymax",
                        "UI Bounds",
                        "Real yMax is %d, but it should be less "
                        "than or equal to %d." % (font_ymax, max_ui_height),
                    )
                if font_ymin < min_ui_height:
                    warn(
                        "bounds/font/ui_ymin",
                        "UI Bounds",
                        "Real yMin is %d, but it should be greater than or equal "
                        "to %d." % (font_ymin, min_ui_height),
                    )
            else:
                hhea_table = font["hhea"]
                if font_ymax > hhea_table.ascent:
                    warn(
                        "bounds/font/ymax",
                        "Bounds",
                        "Real yMax %d, but it should be less "
                        "than or equal to the value of Ascent in 'hhea' table, "
                        "which is %d." % (font_ymax, hhea_table.ascent),
                    )
                if font_ymin < hhea_table.descent:
                    warn(
                        "bounds/font/ymin",
                        "Bounds",
                        "Real yMin is %d, but it should be greater "
                        "than or equal to the value of Descent in 'hhea' table, "
                        "which is %d." % (font_ymin, hhea_table.descent),
                    )

    def check_for_intersections_and_off_curve_extrema():
        if "glyf" not in font:
            return

        if variable:
            # ignore these for variable font mains
            return

        if not tests.check("paths"):
            return

        glyf_table = font["glyf"]
        for glyph_index in range(len(glyf_table.glyphOrder)):
            glyph_name = glyf_table.glyphOrder[glyph_index]
            glyph = glyf_table[glyph_name]
            check_extrema = tests.check("paths/extrema")
            check_intersection = tests.check("paths/intersection")
            if glyph.numberOfContours not in [0, -1]:  # not empty or composite
                all_contours = []
                start_point = 0
                for contour in range(glyph.numberOfContours):
                    end_point = glyph.endPtsOfContours[contour]
                    # TODO(roozbeh): See if this matters, and potentially
                    # re-enable.
                    #
                    # if glyph.flags[start_point] == 0:
                    #   warn("Off-curve start", "The glyph '%s' has an "
                    #        "off-curve starting point in "its contour #%d."
                    #        % (glyph_name, contour+1))
                    curves_in_contour = []
                    for point in range(start_point, end_point + 1):
                        if glyph.flags[point] == 1:  # on-curve
                            next_point = point
                            while True:
                                next_point = next_circular_point(
                                    next_point, start_point, end_point
                                )
                                if glyph.flags[next_point] == 1:  # on-curve
                                    break

                            curve = curve_between(
                                glyph.coordinates,
                                point,
                                next_point,
                                start_point,
                                end_point,
                            )

                            curves_in_contour.append(curve)

                            if not check_extrema:
                                continue
                            out_of_box = curve_has_off_curve_extrema(curve)
                            if out_of_box > 0:
                                warn(
                                    "paths/extrema",
                                    "Extrema",
                                    "The glyph '%s' is missing on-curve extreme points "
                                    "in the segment between point %d=%s and point %d=%s "
                                    "by %f units."
                                    % (
                                        glyph_name,
                                        point,
                                        glyph.coordinates[point],
                                        next_point,
                                        glyph.coordinates[next_point],
                                        out_of_box,
                                    ),
                                    extrema_details,
                                    check_test=False,
                                )
                    start_point = end_point + 1
                    all_contours.append(curves_in_contour)

                if check_intersection:
                    result = curves_intersect(all_contours)
                    if result:
                        warn(
                            "paths/intersection",
                            "Intersection",
                            "The glyph '%s' has intersecting "
                            "outlines: %s" % (glyph_name, result),
                            check_test=False,
                        )

    def check_gdef_table(cmap):
        """Validate the GDEF table."""
        if not tests.check("gdef"):
            return

        mark_glyphs = [code for code in cmap if unicode_data.category(code) == "Mn"]
        try:
            class_def = font["GDEF"].table.GlyphClassDef.classDefs
        except (KeyError, AttributeError):
            class_def = None

        if tests.check("gdef/classdef"):
            names_of_classes = [
                "default class",
                "base glyph",
                "ligature glyph",
                "mark glyph",
                "component glyph",
            ]
            if mark_glyphs and not class_def:
                warn(
                    "gdef/classdef/not_present",
                    "Glyph Class",
                    "There is no GlyphClassDef subtable of GDEF table in the "
                    "font, while there are non-spacing combining characters: %s."
                    % printable_unicode_range(mark_glyphs),
                    is_error=False,
                )
            elif mark_glyphs and not is_indic:
                for code in mark_glyphs:
                    glyph = cmap[code]
                    if glyph not in class_def:
                        if tests.checkvalue("gdef/classdef/unlisted", code):
                            warn(
                                "gdef/classdef/unlisted",
                                "Glyph Class",
                                "Glyph %s (U+%04X %s) is a combining mark, but is not "
                                "assigned a value in the GDEF/GlyphClassDef table."
                                % (glyph, code, unicode_data.name(code)),
                                is_error=False,
                                check_test=False,
                            )
                    elif (
                        tests.checkvalue("gdef/classdef/combining_mismatch", code)
                        and class_def[glyph] != 3
                    ):
                        warn(
                            "gdef/classdef/combining_mismatch",
                            "Glyph Class",
                            "Glyph %s (U+%04X %s) is a combining mark, but is "
                            "defined as class %d (%s) in the GDEF/GlyphClassDef "
                            "table."
                            % (
                                glyph,
                                code,
                                unicode_data.name(code),
                                class_def[glyph],
                                names_of_classes[class_def[glyph]],
                            ),
                            is_error=False,
                            check_test=False,
                        )

            if class_def and not is_indic:
                for code in cmap:
                    glyph = cmap[code]
                    if glyph in class_def:
                        klass = class_def[glyph]
                        if (
                            tests.checkvalue(
                                "gdef/classdef/not_combining_mismatch", code
                            )
                            and klass == 3
                            and unicode_data.category(code) != "Mn"
                            and code not in noto_data.ACCEPTABLE_AS_COMBINING
                        ):
                            warn(
                                "gdef/classdef/not_combining_mismatch",
                                "Glyph Class",
                                "Glyph %s (U+%04X %s) is defined as class 3 "
                                "(non-spacing) in the GDEF/GlyphClassDef table, "
                                "but is of general category %s."
                                % (
                                    cmap[code],
                                    code,
                                    unicode_data.name(code),
                                    unicode_data.category(code),
                                ),
                                is_error=False,
                                check_test=False,
                            )

        if tests.check("gdef/attachlist"):
            # check for duplicate attachment points in AttachList table
            # See https://code.google.com/p/noto/issues/detail?id=128#c20

            try:
                attach_point_list = font["GDEF"].table.AttachList.AttachPoint
            except (KeyError, AttributeError):
                attach_point_list = []

            for index, attach_point in enumerate(attach_point_list):
                points = attach_point.PointIndex
                if len(set(points)) != len(points):
                    warn(
                        "gdef/attachlist/duplicates",
                        "Attach List",
                        "Entry #%d in GDEF.AttachList has duplicate points,"
                        "resulting in being rejected as a web font." % index,
                    )
                elif sorted(points) != points:
                    warn(
                        "gdef/attachlists/out_of_order",
                        "Attach List",
                        "Points in entry #%d in GDEF.AttachList are not in "
                        "increasing order." % index,
                    )

        if tests.check("gdef/ligcaretlist"):
            # check that every ligature has a ligature caret in GDEF
            ligatures = []
            if class_def:
                for glyph in class_def:
                    if class_def[glyph] == 2:
                        ligatures.append(glyph)
            if ligatures:
                try:
                    lig_caret_list_coverage = font["GDEF"].table.LigCaretList.Coverage
                except (KeyError, AttributeError):
                    lig_caret_list_coverage = None

                if not lig_caret_list_coverage:
                    if not is_indic:
                        warn(
                            "gdef/ligcaretlist/not_present",
                            "Ligature Class",
                            "There is no LigCaretList data in the GDEF table, but "
                            "there are ligatures defined in GDEF: %s."
                            % ", ".join(ligatures),
                        )
                else:
                    if set(lig_caret_list_coverage.glyphs) - set(ligatures):
                        warn(
                            "gdef/ligcaretlist/not_ligature",
                            "Ligature Class",
                            "Some glyphs are defined to have ligature carets in "
                            "GDEF table, but are not defined as ligatures in the "
                            "table: %s."
                            % ", ".join(
                                sorted(
                                    set(lig_caret_list_coverage.glyphs) - set(ligatures)
                                )
                            ),
                        )
                    elif set(ligatures) - set(lig_caret_list_coverage.glyphs):
                        if not is_indic:
                            warn(
                                "gdef/ligcaretlist/unlisted",
                                "Ligature Class",
                                "Some glyphs are defined as ligatures in "
                                "the GDEF table, but don't have ligature carets: %s."
                                % ", ".join(
                                    sorted(
                                        set(ligatures)
                                        - set(lig_caret_list_coverage.glyphs)
                                    )
                                ),
                            )

    def check_complex_stylistic_set_name_ids(gsub_or_gpos):
        GSUB_OR_GPOS = gsub_or_gpos.upper()
        table = font[GSUB_OR_GPOS].table
        if not table.FeatureList:
            return

        name_id_set = None
        for index in range(table.FeatureList.FeatureCount):
            record = table.FeatureList.FeatureRecord[index]
            params = record.Feature.FeatureParams
            if isinstance(params, otTables.FeatureParamsStylisticSet):
                if not name_id_set:
                    name_id_set = {r.nameID for r in font["name"].names}
                if not params.UINameID in name_id_set:
                    warn(
                        "complex/%s/ui_name_id" % gsub_or_gpos,
                        GSUB_OR_GPOS,
                        "Feature index %s (%s) has UINameID %d but it is not in the name table"
                        % (index, record.FeatureTag, params.UINameID),
                    )

    def check_shaping(font_file, strs, context, errors):
        text = "\n".join(strs)
        temp_file = tempfile.NamedTemporaryFile(suffix=".txt")
        try:
            temp_file.write(text.encode("utf-8"))
            temp_file.flush()
            command = [
                "hb-shape",
                "--font-file=%s" % font_file,
                "--text-file=%s" % temp_file.name,
            ]
            features = []
            if context != "isol":
                features.append("-isol")
            if context:
                features.append(context)
            command.append("--features=%s" % ",".join(features))
            # print("command: %s" % ' '.join(command)
            result = subprocess.check_output(command)
            for src, res in zip(strs, result.splitlines()):
                if res.find(b"|") != -1:
                    errors.append((src, context, res))
        finally:
            temp_file.close()

    def check_gsub_variants():
        """Checks if harfbuzz can use GSUB to generate standard variants"""
        if not tests.check("complex/gsub/variants"):
            return

        cmap = font_data.get_cmap(font)
        variant_cps = unicode_data.variant_data_cps()
        cps_with_variants = set(cmap.keys()) & variant_cps
        any_strs = []
        isolate_strs = []
        initial_strs = []
        medial_strs = []
        final_strs = []
        for cp in sorted(cps_with_variants):
            data = unicode_data.get_variant_data(cp)
            for sel, _, ctx in sorted(data):
                line = unichr(cp) + unichr(sel)
                if ctx == 0:
                    any_strs.append(line)
                    continue
                if ctx & 1:
                    isolate_strs.append(line)
                if ctx & 2:
                    initial_strs.append(line)
                if ctx & 4:
                    medial_strs.append(line)
                if ctx & 8:
                    final_strs.append(line)

        errors = []
        font_file = font_props.filepath
        check_shaping(font_file, any_strs, "", errors)
        check_shaping(font_file, isolate_strs, "isol", errors)
        check_shaping(font_file, initial_strs, "init", errors)
        check_shaping(font_file, medial_strs, "medi", errors)
        check_shaping(font_file, final_strs, "fina", errors)
        if errors:
            erroritems = []
            for src, context, res in errors:
                srctext = " ".join("%04x" % ord(cp) for cp in src)
                ctx = (" (%s)" % context) if context else ""
                erroritems.append("%s%s: %s" % (srctext, ctx, res))
            errorinfo = ", ".join(erroritems)
            warn(
                "complex/gsub/variants",
                "GSUB",
                "Expected GSUB to generate all standard variants, but %d "
                "cases did not (%s)" % (len(errors), errorinfo),
            )

    def check_gpos_and_gsub_tables():
        if not tests.check("complex"):
            return

        allowlist = [
            "Hluw",  # Anatolian Hieroglyphs
            "Cari",  # Carian
            "Xsux",  # Cuneiform
            "Cprt",  # Cypriot
            "Dsrt",  # Deseret
            "Egyp",  # Egyptian Hieroglyphs
            "Armi",  # Imperial Aramaic
            "Linb",  # Linear B
            "Lisu",  # Lisu
            "Lyci",  # Lycian
            "Lydi",  # Lydian
            "Ogam",  # Ogham
            "Olck",  # Ol Chiki
            "Ital",  # Old Italic
            "Xpeo",  # Old Persian
            "Sarb",  # Old South Arabian
            "Orkh",  # Old Turkic
            "Osma",  # Osmanya
            "Phnx",  # Phoenician
            "Runr",  # Runic
            "Shaw",  # Shavian
            "Ugar",  # Ugaritic
            "Vaii",  # Vai
            "Yiii",  # Yi
        ]
        if font_props.script in allowlist:
            return

        if "GPOS" not in font:
            warn("complex/gpos/missing", "GPOS", "There is no GPOS table in the font.")
        else:
            check_complex_stylistic_set_name_ids("gpos")

        # Assume no GSUB necessary for Adlm
        if font_props.script == "Adlm":
            return

        if "GSUB" not in font:
            warn("complex/gsub/missing", "GSUB", "There is no GSUB table in the font.")
        else:
            check_complex_stylistic_set_name_ids("gsub")
            check_gsub_variants()

        # TODO: Add more script-specific checks

    def check_for_bidi_pairs(cmap):
        """Checks for proper support of bidi mirroring in the font.

        For each bidi mirroring character in the font, we wake sure that: if it
        is in OMPL, its mirror pair should also be in the cmap, and the first
        character should not mapped by 'rtlm'. If the character is not in OMPL,
        it should be mapped by 'rtlm'.

        Only the first 'rtlm' feature in the font is used.
        """
        if not tests.check("bidi"):
            return

        # need to discuss this with Adobe

        rtlm = {}
        if "GSUB" in font:
            try:
                feature_record = font["GSUB"].table.FeatureList.FeatureRecord
            except AttributeError:
                warn(
                    "bidi", "bidi", "GSUB table with no feature record", is_error=False
                )
                feature_record = []
            for record in feature_record:
                if record.FeatureTag == "rtlm":  # FIXME
                    for lookup_number in record.Feature.LookupListIndex:
                        lookup = font["GSUB"].table.LookupList.Lookup[lookup_number]
                        lookup_type = lookup.LookupType
                        if lookup_type == 7:  # GSUB extension
                            assert lookup.SubTableCount == 1
                            lookup_type = lookup.SubTable[0].ExtensionLookupType
                            subtables = [lookup.SubTable[0].ExtSubTable]
                        else:
                            subtables = lookup.SubTable

                        assert lookup_type == 1, (
                            "Don't know how to handle 'rtlm' features with "
                            "lookup type other than 1."
                        )
                        for subtable in subtables:
                            for key in subtable.mapping.keys():
                                rtlm[key] = subtable.mapping[key]
                    break

        ompl = opentype_data.OMPL
        for code in sorted(cmap):
            if unicode_data.is_private_use(code) or not unicode_data.mirrored(code):
                if cmap[code] in rtlm:
                    warn(
                        "bidi/rtlm_non_mirrored",
                        "Bidi",
                        "The 'rtlm' feature in the font applies to the glyph "
                        "for U+%04X (%s), but it shouldn't, since the "
                        "character is not bidi mirroring." % (code, cmap[code]),
                    )
                continue  # skip the rest of the tests

            # The following tests are only applied to bidi mirroring characters
            if code in ompl:
                if cmap[code] in rtlm:
                    warn(
                        "bidi/ompl_rtlm",
                        "Bidi",
                        "The 'rtlm' feature in the font applies to the glyph "
                        "for U+%04X (%s), but it shouldn't, since the "
                        "character is in the OMPL list." % (code, cmap[code]),
                    )

                mirrored_pair = ompl[code]
                if mirrored_pair not in cmap:
                    warn(
                        "bidi/ompl_missing_pair",
                        "Bidi",
                        "The character U+%04X (%s) is supported in the font, "
                        "but its bidi mirrored pair U+%04X (%s) is not."
                        % (
                            code,
                            unicode_data.name(code),
                            mirrored_pair,
                            unicode_data.name(mirrored_pair),
                        ),
                    )
            else:
                if cmap[code] not in rtlm:
                    warn(
                        "bidi/rtlm_unlisted",
                        "Bidi",
                        "No 'rtlm' feature is applied to the glyph "
                        "for U+%04X (%s), but one should be applied, since "
                        "the character is a bidi mirroring character that is "
                        "not in the OMPL list." % (code, cmap[code]),
                    )

    def check_hints():
        if not "glyf" in font:
            return

        if not tests.check("hints"):
            return

        expected_to_be_hinted = font_props.is_hinted
        expected_to_be_unhinted = not expected_to_be_hinted

        # There should be no fpgm, prep, or cvt tables in unhinted fonts
        if expected_to_be_unhinted:
            for table_name in ["fpgm", "prep", "cvt"]:
                if table_name in font:
                    warn(
                        "hints/unexpected_tables",
                        "Hints",
                        "The font is supposed to be unhinted, but it has "
                        "a '%s' table." % table_name,
                    )

        glyf_table = font["glyf"]
        check_unexpected_bytecode = tests.check("hints/unexpected_bytecode")
        for glyph_index in range(len(glyf_table.glyphOrder)):
            glyph_name = glyf_table.glyphOrder[glyph_index]
            glyph = glyf_table[glyph_name]
            if glyph.numberOfContours > 0:
                bytecode = glyph.program.bytecode
                if expected_to_be_unhinted:
                    if check_unexpected_bytecode and bytecode:
                        warn(
                            "hints/unexpected_bytecode",
                            "Hints",
                            "The font is supposed to be unhinted, but "
                            "glyph '%s' has hints." % glyph_name,
                            check_test=False,
                        )
                else:
                    if not bytecode and tests.checkvalue(
                        "hints/missing_bytecode", glyph_index
                    ):
                        warn(
                            "hints/missing_bytecode",
                            "Hints",
                            "The font is supposed to be hinted, but "
                            "glyph '%s' (%d) doesn't have hints."
                            % (glyph_name, glyph_index),
                            check_test=False,
                        )

    def check_explicit_advances():
        """Check some cases where we expect advances to be explicitly related."""
        if not tests.check("advances"):
            return

        cmap = font_data.get_cmap(font)

        def get_horizontal_advance(codepoint):
            return font_data.get_glyph_horizontal_advance(font, cmap[codepoint])

        def expect_width(
            codepoint,
            expected,
            low_divisor=None,
            high_divisor=None,
            label="advances/whitespace",
            is_error=True,
        ):
            # it is ok if the font does not support the tested codepoint
            if codepoint not in cmap:
                return
            # no low divisor means exact match of the expected advance
            if not low_divisor:
                low_divisor = 1
                slop = 0
            else:
                slop = 1
            adv = get_horizontal_advance(codepoint)
            if not high_divisor:
                exp = int(round(float(expected) / low_divisor))
                if abs(adv - exp) > slop:
                    glyph_name = cmap[codepoint]
                    glyph_id = font.getGlyphID(glyph_name)
                    warn(
                        label,
                        "Advances",
                        "The advance of U+%04x (%s, glyph %d) is %d, but expected %d."
                        % (codepoint, glyph_name, glyph_id, adv, exp),
                        check_test=False,
                        is_error=is_error,
                    )
            else:
                # note name switch, since the higher divisor returns the lower value
                high_exp = int(round(float(expected) / low_divisor))
                low_exp = int(round(float(expected) / high_divisor))
                if not (low_exp - slop <= adv <= high_exp + slop):
                    glyph_name = cmap[codepoint]
                    glyph_id = font.getGlyphID(glyph_name)
                    warn(
                        label,
                        "Advances",
                        "The advance of U+%04x (%s, glyph %d) is %d, but expected between "
                        "%d and %d."
                        % (codepoint, glyph_name, glyph_id, adv, low_exp, high_exp),
                        check_test=False,
                        is_error=is_error,
                    )

        digit_char = ord("0")
        period_char = ord(".")
        comma_char = ord(",")
        space_char = ord(" ")
        tab_char = ord("\t")
        nbsp_char = 0x00A0

        if digit_char in cmap:
            digit_width = get_horizontal_advance(digit_char)
            if tests.check("advances/digits"):
                for i in range(10):
                    digit = ord("0") + i
                    width = get_horizontal_advance(digit)
                    if width != digit_width:
                        warn(
                            "advances/digits",
                            "Advances",
                            "The advance of '%s' (%d) is different from that of '0' (%d)."
                            % (chr(digit), width, digit_width),
                            check_test=False,
                        )

        if period_char in cmap:
            period_width = get_horizontal_advance(period_char)
            if tests.check("advances/comma_period") and comma_char in cmap:
                expect_width(
                    comma_char,
                    period_width,
                    label="advances/comma_period",
                    is_error=False,
                )

        if tests.check("advances/whitespace"):
            if font_props.is_mono:
                space_width = get_horizontal_advance(space_char)
                cps = [tab_char, nbsp_char] + list(range(0x2000, 0x200B))
                for cp in cps:
                    if cp in cmap:
                        expect_width(cp, space_width)
            else:
                if tab_char in cmap or nbsp_char in cmap:
                    space_width = get_horizontal_advance(space_char)
                    if tab_char in cmap:
                        # see https://www.microsoft.com/typography/otspec/recom.htm
                        expect_width(tab_char, space_width)
                    if nbsp_char in cmap:
                        expect_width(nbsp_char, space_width)

                em_width = font["head"].unitsPerEm
                expect_width(0x2000, em_width, 2)  # en_quad
                expect_width(0x2001, em_width)  # em_quad
                expect_width(0x2002, em_width, 2)  # en_space
                expect_width(0x2003, em_width)  # em_space
                expect_width(0x2004, em_width, 3)  # three-per-em space
                expect_width(0x2005, em_width, 4)  # four-per-em space
                expect_width(0x2006, em_width, 6)  # six-per-em space
                if digit_char in cmap:
                    expect_width(0x2007, digit_width)  # figure space
                if period_char in cmap:
                    expect_width(0x2008, period_width)  # punctuation space
                # see http://unicode.org/charts/PDF/U2000.pdf, but microsoft (below)
                # says French uses 1/8 em.
                expect_width(0x2009, em_width, 5, 6)  # thin space
                # see http://www.microsoft.com/typography/developers/fdsspec/spaces.htm
                expect_width(0x200A, em_width, 10, 16)  # hair space
                expect_width(0x200B, 0)  # zero width space

        if tests.check("advances/spacing_marks"):
            spacing_marks = lint_config.parse_int_ranges(
                "02C8 02CA-02D7 02DE 02DF 02EC 02ED 02EF-02F2 02F4-02FF", True
            )
            for cp in spacing_marks:
                if cp not in cmap:
                    continue
                if not get_horizontal_advance(cp):
                    warn(
                        "advances/spacing_marks",
                        "Advances",
                        "The spacing mark %s (%04x) should have a non-zero advance."
                        % (unichr(cp), cp),
                    )

    def check_stems(cmap):
        if not "glyf" in font:
            return

        if not tests.check("stem"):
            return

        # Only implemented for Ogham, currently
        # FIXME: Add support for Arabic, Syriac, Mongolian, Phags-Pa,
        # Devanagari, Bengali, etc
        joins_to_right = set(range(0x1680, 0x169B + 1))
        joins_to_left = set(list(range(0x1680, 0x169A + 1)) + [0x169C])
        all_joining = joins_to_right | joins_to_left

        glyf_table = font["glyf"]
        metrics_dict = font["hmtx"].metrics
        check_left_joining = tests.check("stem/left_joining")
        check_right_joining = tests.check("stem/right_joining")
        for code in all_joining & set(cmap):
            glyph_name = cmap[code]
            advance_width, lsb = metrics_dict[glyph_name]
            if check_left_joining and code in joins_to_left:
                if lsb != 0:
                    warn(
                        "stem/left_joining",
                        "Stem",
                        "The glyph for U+%04X (%s) is supposed to have a stem "
                        "connecting to the left, but its left side bearing "
                        "is %d instead of 0." % (code, unicode_data.name(code), lsb),
                        check_test=False,
                    )
            if check_right_joining and code in joins_to_right:
                glyph = glyf_table[glyph_name]
                rsb = advance_width - (lsb + glyph.xMax - glyph.xMin)
                if rsb != -70:
                    warn(
                        "stem/right_joining",
                        "Stem",
                        "The glyph for U+%04X (%s) is supposed to have a stem "
                        "connecting to the right, but it's right side bearing "
                        "is %d instead of -70." % (code, unicode_data.name(code), rsb),
                        check_test=False,
                    )

    def check_accessiblity(cmap):
        """Test if all glyphs are accessible through cmap, decomps, or GSUB.

        This is done using the font subsetter. We ask the subsetter to subset
        for all Unicode characters in the cmap table, and see if every glyph is
        covered after subsetting.
        """
        if not tests.check("reachable"):
            return

        test_filter = tests.get_filter("reachable")
        glyph_name_to_id = font.getReverseGlyphMap().copy()

        all_glyphs = set(font.getGlyphOrder())
        subsetter = subset.Subsetter()
        subsetter.populate(unicodes=cmap.keys())
        try:
            subsetter._closure_glyphs(font)
        except Exception as e:
            warn(
                "reachable",
                "Reachability",
                "Subsetter failure, bad/missing tables?: '%s'" % e,
            )
            return

        unreachable_glyphs = all_glyphs - subsetter.glyphs_retained
        if unreachable_glyphs:
            reported_glyphs = set()
            reported_list = []
            for glyph_name in unreachable_glyphs:
                glyph_id = glyph_name_to_id[glyph_name]
                if not test_filter or test_filter[1].accept(glyph_id):
                    if glyph_name not in reported_glyphs:
                        reported_glyphs.add(glyph_name)
                        reported_list.append((glyph_name, glyph_id))
            if reported_list:
                report_info = ", ".join("%s (%d)" % t for t in sorted(reported_list))
                warn(
                    "reachable",
                    "Reachability",
                    "The following %d glyphs are unreachable in the font: %s."
                    % (len(reported_glyphs), report_info),
                    check_test=False,
                )

    ### actual start of check_font fn

    # python 2.7 does not have nonlocal, so hack around it
    suppressed_err_count = [0]
    err_count = [0]
    suppressed_warn_count = [0]
    warn_count = [0]

    font_path = path.expanduser(font_props.filepath)
    font = ttLib.TTFont(font_path)

    is_indic = font_props.script in {
        "Deva",
        "Beng",
        "Guru",
        "Gujr",
        "Orya",
        "Taml",
        "Telu",
        "Knda",
        "Mlym",
        "Sinh",
        "Khmr",
    }

    fi = lint_config.FontInfo(
        filename=path.basename(font_path),
        name=font_props.family,
        style=font_props.style,
        script=font_props.script,
        variant=font_props.variant,
        weight=font_props.weight,
        monospace=font_props.is_mono,
        hinted=font_props.is_hinted,
        vendor=font_props.vendor,
        version=printable_font_revision(
            font, accuracy=3 if font_props.vendor == "Adobe" else 2
        ),
    )
    tests = lint_spec.get_tests(fi)

    if filename_error:
        if filename_error == "script":
            warn(
                "filename/script",
                "File name",
                "Style %s also needs a script mentioned in the "
                "file name." % font_props.style,
            )
        elif filename_error == "name":
            warn(
                "filename/name",
                "File name",
                "File name '%s' does not match the Noto font naming guidelines."
                % path.basename(font_props.filepath),
            )

    check_name_table()
    cmap = check_cmap_table()
    check_variants()
    check_head_tables(cmap)
    check_vertical_limits()
    check_for_intersections_and_off_curve_extrema()
    check_gdef_table(cmap)
    check_gpos_and_gsub_tables()
    check_for_bidi_pairs(cmap)
    check_hints()
    check_explicit_advances()
    check_stems(cmap)

    # This must be done at the very end, since the subsetter may change the font
    check_accessiblity(cmap)

    warn(
        "info",
        "info",
        "supported characters: " + printable_unicode_range(cmap.keys()),
        check_test=False,
    )

    if runlog:
        log = sorted(tests.runlog())
        count = len(log)
        if count:
            print(
                "Ran %d test%s:\n  %s"
                % (count, "s" if count != 1 else "", "\n  ".join(log))
            )
        else:
            print("Ran no tests.")
    if skiplog:
        log = sorted(tests.skiplog())
        count = len(log)
        if len(log):
            print(
                "Skipped %d test/group%s:\n  %s"
                % (count, "s" if count != 1 else "", "\n  ".join(log))
            )
        else:
            print("Skipped no tests")

    # TODO(roozbeh):
    # * Check that hintedness based on data in the glyf table
    #   matches metadata (file location, header data)
    # * Check GSUB coverage, based on script
    # * Check gasp values
    # * Add support for TTC fonts
    # * Check ulCodePageRange values


def get_lint_spec(spec_file, extra_specs):
    """Return a LintSpec from spec_file supplemented with extra_specs.
    If spec_file is None, only use extra_specs."""

    spec = None
    if spec_file != "None":
        spec = lint_config.parse_spec_file(spec_file)
    return lint_config.parse_spec(extra_specs, spec)


def parse_font_props(font_props_file):
    """Return a list of FontProps objects."""
    with open(font_props_file) as f:
        font_spec = f.read()
    spec_data = json.loads(font_spec)
    return [FontProps(**m) for m in spec_data]


def write_font_props(font_props):
    print(json.dumps(font_props._asdict()))


def main():
    default_config_file = notoconfig.get(
        "lint_config", "[tools]/nototools/data/lint_config.txt"
    )

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--csv",
        help="produces csv output to import into a spreadsheet",
        action="store_true",
    )
    parser.add_argument(
        "--info",
        help="includes informational messages in the output",
        action="store_true",
    )
    parser.add_argument(
        "--suppress_extrema_details",
        dest="extrema_details",
        default=True,
        action="store_false",
        help="only summarize extrema issues",
    )
    parser.add_argument(
        "--csv_header",
        help="write header line when generating csv output",
        action="store_true",
    )
    parser.add_argument(
        "font_files",
        metavar="font",
        nargs="*",
        help="a font file to check, can omit if font_spec is provided",
    )
    parser.add_argument(
        "--font_props_file",
        metavar="file",
        help="file containing json describing font file paths and expected properties",
    )
    parser.add_argument(
        "--config_file",
        help="location of config file (default %s, 'None' for none)"
        % default_config_file,
        metavar="file",
        default=default_config_file,
    )
    parser.add_argument(
        "--config",
        help="extra config spec to process after config file",
        metavar="lint_spec",
    )
    parser.add_argument("--runlog", help="show tags of run tests", action="store_true")
    parser.add_argument(
        "--skiplog", help="show tags of skipped tests", action="store_true"
    )
    parser.add_argument(
        "--dump_font_props", help="write font props for files", action="store_true"
    )
    parser.add_argument(
        "-nw", "--nowarn", help="suppress warning messages", action="store_true"
    )
    parser.add_argument(
        "-q",
        "--quiet",
        help="don't print file names unless there are errors or warnings",
        action="store_true",
    )
    parser.add_argument(
        "-p",
        "--phase",
        help="set noto phase for lint compatibility (default 3)",
        metavar="phase",
        type=int,
        default=3,
    )
    parser.add_argument(
        "-v",
        "--variable",
        help="do checks appropriate to mains for variable fonts.",
        action="store_true",
    )

    arguments = parser.parse_args()

    if arguments.dump_font_props:
        for font_file_path in arguments.font_files:
            font_file_path = tool_utils.resolve_path(font_file_path)
            font_props, filename_error = get_font_properties_with_fallback(
                font_file_path, phase=arguments.phase
            )
            if filename_error:
                print("#Error for %s: %s" % (font_file_path, filename_error))
            else:
                write_font_props(font_props)
        return

    config_file = tool_utils.resolve_path(arguments.config_file)
    lint_spec = get_lint_spec(config_file, arguments.config)

    if arguments.csv and arguments.csv_header:
        print(
            "Type,Script,Style,Variant,Subfamily,Manufacturer,Category,"
            "Hint Status,File Name,Revision,Issue"
        )

    for font_file_path in arguments.font_files:
        font_file_path = tool_utils.resolve_path(font_file_path)
        font_props, filename_error = get_font_properties_with_fallback(
            font_file_path, phase=arguments.phase
        )
        if not font_props:
            print("## ERROR: cannot parse %s" % font_file_path)
        else:
            check_font(
                font_props,
                filename_error,
                lint_spec,
                arguments.runlog,
                arguments.skiplog,
                arguments.csv,
                arguments.info,
                arguments.extrema_details,
                arguments.nowarn,
                arguments.quiet,
                arguments.phase,
                arguments.variable,
            )
    if arguments.font_props_file:
        font_props_list = parse_font_props(arguments.font_props_file)
        for font_props in font_props_list:
            check_font(
                font_props,
                "",
                lint_spec,
                arguments.runlog,
                arguments.skiplog,
                arguments.csv,
                arguments.info,
                arguments.extrema_details,
                arguments.nowarn,
                arguments.quiet,
                arguments.phase,
                arguments.variable,
            )

    if not arguments.csv:
        print("------")
        if _processed_files == 1:
            print("Finished linting 1 file.")
        else:
            print("Finished linting %d files." % _processed_files)
        if _processed_files > 1:
            if _processed_files_with_errors:
                print(
                    "%d file%s had errors."
                    % (
                        _processed_files_with_errors,
                        "" if _processed_files_with_errors == 1 else "s",
                    )
                )
            if _processed_files_with_warnings:
                print(
                    "%d file%s had warnings."
                    % (
                        _processed_files_with_warnings,
                        "" if _processed_files_with_warnings == 1 else "s",
                    )
                )


if __name__ == "__main__":
    main()
