__doc__ = """ Convert TrueType flavored fonts to CFF flavored fonts. INPUT_PATH argument can be a file or a directory. If it is a directory, all the TrueType flavored fonts found in the directory will be converted. Examples: $ ttf2otf font.ttf $ ttf2otf font.ttf -o output_dir $ ttf2otf fonts_dir $ ttf2otf fonts_dir -r $ ttf2otf fonts_dir -o output_dir $ ttf2otf fonts_dir -o output_dir --no-overwrite $ ttf2otf fonts_dir -o output_dir --max-err 0.5 $ ttf2otf fonts_dir -o output_dir --new-upem 1000 $ ttf2otf fonts_dir -o output_dir --correct-contours """ import argparse import logging import sys import typing as t from pathlib import Path import pathops from cffsubr import subroutinize as subr from fontTools import configLogger from fontTools.cffLib import PrivateDict from fontTools.fontBuilder import FontBuilder from fontTools.misc.cliTools import makeOutputFileName from fontTools.misc.psCharStrings import T2CharString from fontTools.misc.roundTools import otRound from fontTools.pens.cu2quPen import Cu2QuPen from fontTools.pens.qu2cuPen import Qu2CuPen from fontTools.pens.recordingPen import DecomposingRecordingPen from fontTools.pens.t2CharStringPen import T2CharStringPen from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont, TTLibError from fontTools.ttLib.scaleUpem import scale_upem log = logging.getLogger(__name__) def decomponentize_tt(font: TTFont) -> None: """ Decomposes all composite glyphs of a TrueType font. """ if not font.sfntVersion == "\x00\x01\x00\x00": raise NotImplementedError( "Decomponentization is only supported for TrueType fonts." ) glyph_set = font.getGlyphSet() glyf_table = font["glyf"] dr_pen = DecomposingRecordingPen(glyphSet=glyph_set) tt_pen = TTGlyphPen(glyphSet=None) for glyph_name in font.glyphOrder: glyph = glyf_table[glyph_name] if not glyph.isComposite(): continue dr_pen.value = [] tt_pen.init() glyph.draw(dr_pen, glyf_table) dr_pen.replay(tt_pen) glyf_table[glyph_name] = tt_pen.glyph() def skia_path_from_charstring(charstring: T2CharString) -> pathops.Path: """ Get a Skia path from a T2CharString. """ path = pathops.Path() path_pen = path.getPen(glyphSet=None) charstring.draw(path_pen) return path def charstring_from_skia_path(path: pathops.Path, width: int) -> T2CharString: """ Get a T2CharString from a Skia path. """ t2_pen = T2CharStringPen(width=width, glyphSet=None) path.draw(t2_pen) return t2_pen.getCharString() def round_path( path: pathops.Path, rounder: t.Callable[[float], float] = otRound ) -> pathops.Path: """ Rounds the points coordinate of a ``pathops.Path`` Args: path (pathops.Path): The ``pathops.Path`` rounder (Callable[[float], float], optional): The rounding function. Defaults to otRound. Returns: pathops.Path: The rounded path """ rounded_path = pathops.Path() for verb, points in path: rounded_path.add(verb, *((rounder(p[0]), rounder(p[1])) for p in points)) return rounded_path def simplify_path(path: pathops.Path, glyph_name: str, clockwise: bool) -> pathops.Path: """ Simplify a ``pathops.Path by`` removing overlaps, fixing contours direction and, optionally, removing tiny paths Args: path (pathops.Path): The ``pathops.Path`` to simplify glyph_name (str): The glyph name clockwise (bool): The winding direction. Must be ``True`` for TrueType glyphs and ``False`` for OpenType-PS fonts. Returns: pathops.Path: The simplified path """ try: return pathops.simplify(path, fix_winding=True, clockwise=clockwise) except pathops.PathOpsError: pass path = round_path(path) try: path = pathops.simplify(path, fix_winding=True, clockwise=clockwise) return path except pathops.PathOpsError as e: raise pathops.PathOpsError( f"Failed to simplify path for glyph {glyph_name}: {e}" ) def quadratics_to_cubics( font: TTFont, tolerance: float = 1.0, correct_contours: bool = False ) -> t.Dict[str, T2CharString]: """ Get CFF charstrings using Qu2CuPen Args: font (TTFont): The TTFont object. tolerance (float, optional): The tolerance for the conversion. Defaults to 1.0. correct_contours (bool, optional): Whether to correct the contours with pathops. Defaults to False. Returns: tuple: A tuple containing the list of failed glyphs and the T2 charstrings. """ qu2cu_charstrings = {} glyph_set = font.getGlyphSet() for k, v in glyph_set.items(): width = v.width try: t2_pen = T2CharStringPen(width=width, glyphSet={k: v}) qu2cu_pen = Qu2CuPen( t2_pen, max_err=tolerance, all_cubic=True, reverse_direction=True ) glyph_set[k].draw(qu2cu_pen) qu2cu_charstrings[k] = t2_pen.getCharString() except NotImplementedError: # Workaround for "oncurve-less contours with all_cubic not implemented" temp_t2_pen = T2CharStringPen(width=width, glyphSet=None) glyph_set[k].draw(temp_t2_pen) t2_charstring = temp_t2_pen.getCharString() t2_charstring.private = PrivateDict() tt_pen = TTGlyphPen(glyphSet=None) cu2qu_pen = Cu2QuPen( other_pen=tt_pen, max_err=tolerance, reverse_direction=False ) t2_charstring.draw(cu2qu_pen) tt_glyph = tt_pen.glyph() t2_pen = T2CharStringPen(width=width, glyphSet=None) qu2cu_pen = Qu2CuPen( t2_pen, max_err=tolerance, all_cubic=True, reverse_direction=True ) tt_glyph.draw(pen=qu2cu_pen, glyfTable=None) log.warning( f"Failed to convert glyph '{k}' to cubic at first attempt, but succeeded at second " f"one." ) charstring = t2_pen.getCharString() if correct_contours: charstring.private = PrivateDict() path = skia_path_from_charstring(charstring) simplified_path = simplify_path(path, glyph_name=k, clockwise=False) charstring = charstring_from_skia_path(path=simplified_path, width=width) qu2cu_charstrings[k] = charstring return qu2cu_charstrings def build_font_info_dict(font: TTFont) -> t.Dict[str, t.Any]: """ Builds CFF topDict from a TTFont object. Args: font (TTFont): The TTFont object. Returns: dict: The CFF topDict. """ font_revision = str(round(font["head"].fontRevision, 3)).split(".") major_version = str(font_revision[0]) minor_version = str(font_revision[1]).ljust(3, "0") name_table = font["name"] post_table = font["post"] cff_font_info = { "version": ".".join([major_version, str(int(minor_version))]), "FullName": name_table.getBestFullName(), "FamilyName": name_table.getBestFamilyName(), "ItalicAngle": post_table.italicAngle, "UnderlinePosition": post_table.underlinePosition, "UnderlineThickness": post_table.underlineThickness, "isFixedPitch": bool(post_table.isFixedPitch), } return cff_font_info def get_post_values(font: TTFont) -> t.Dict[str, t.Any]: """ Setup CFF post table values Args: font (TTFont): The TTFont object. Returns: dict: The post table values. """ post_table = font["post"] post_info = { "italicAngle": otRound(post_table.italicAngle), "underlinePosition": post_table.underlinePosition, "underlineThickness": post_table.underlineThickness, "isFixedPitch": post_table.isFixedPitch, "minMemType42": post_table.minMemType42, "maxMemType42": post_table.maxMemType42, "minMemType1": post_table.minMemType1, "maxMemType1": post_table.maxMemType1, } return post_info def get_hmtx_values( font: TTFont, charstrings: t.Dict[str, T2CharString] ) -> t.Dict[str, t.Tuple[int, int]]: """ Get the horizontal metrics for a font. Args: font (TTFont): The TTFont object. charstrings (dict): The charstrings dictionary. Returns: dict: The horizontal metrics. """ glyph_set = font.getGlyphSet() advance_widths = {k: v.width for k, v in glyph_set.items()} lsb = {} for gn, cs in charstrings.items(): lsb[gn] = cs.calcBounds(None)[0] if cs.calcBounds(None) is not None else 0 metrics = {} for gn, advance_width in advance_widths.items(): metrics[gn] = (advance_width, lsb[gn]) return metrics def build_otf( font: TTFont, charstrings_dict: t.Dict[str, T2CharString], ps_name: t.Optional[str] = None, font_info: t.Optional[t.Dict[str, t.Any]] = None, private_dict: t.Optional[t.Dict[str, t.Any]] = None, ) -> None: """ Builds an OpenType font with FontBuilder. Args: font (TTFont): The TTFont object. charstrings_dict (dict): The charstrings dictionary. ps_name (str, optional): The PostScript name of the font. Defaults to None. font_info (dict, optional): The font info dictionary. Defaults to None. private_dict (dict, optional): The private dictionary. Defaults to None. """ if not ps_name: ps_name = font["name"].getDebugName(6) if not font_info: font_info = build_font_info_dict(font=font) if not private_dict: private_dict = {} fb = FontBuilder(font=font) fb.isTTF = False ttf_tables = ["glyf", "cvt ", "loca", "fpgm", "prep", "gasp", "LTSH", "hdmx"] for table in ttf_tables: if table in font: del font[table] fb.setupGlyphOrder(font.getGlyphOrder()) fb.setupCFF( psName=ps_name, charStringsDict=charstrings_dict, fontInfo=font_info, privateDict=private_dict, ) metrics = get_hmtx_values(font=fb.font, charstrings=charstrings_dict) fb.setupHorizontalMetrics(metrics) fb.setupDummyDSIG() fb.setupMaxp() post_values = get_post_values(font=fb.font) fb.setupPost(**post_values) def find_fonts( input_path: Path, recursive: bool = False, recalc_timestamp: bool = False ) -> t.List[TTFont]: """ Returns a list of TTFont objects found in the input path. Args: input_path (Path): The input file or directory. recursive (bool): If input_path is a directory, search for fonts recursively in subdirectories. recalc_timestamp (bool): Weather to recalculate the font's timestamp on save. Returns: List[TTFont]: A list of TTFont objects. """ if input_path.is_file(): files = [input_path] elif input_path.is_dir(): if recursive: files = [x for x in input_path.rglob("*") if x.is_file()] else: files = [x for x in input_path.glob("*") if x.is_file()] else: raise ValueError("Input path must be a file or directory.") fonts = [] for file in files: try: font = TTFont(file, recalcTimestamp=recalc_timestamp) # Filter out CFF and variable fonts if font.sfntVersion == "\x00\x01\x00\x00" and "fvar" not in font: fonts.append(font) except (TTLibError, PermissionError): pass return fonts def main(args=None) -> None: """ Convert TrueType flavored fonts to CFF flavored fonts. INPUT_PATH argument can be a file or a directory. If it is a directory, all the TrueType flavored fonts found in the directory will be converted. """ parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "input_path", type=Path, help="The input file or directory.", ) parser.add_argument( "-r", "--recursive", action="store_true", help="Search for fonts recursively in subdirectories.", ) parser.add_argument( "-o", "--output-dir", type=Path, help="Specify a directory where the output files are to be saved.", ) parser.add_argument( "--no-overwrite", dest="overwrite", action="store_false", help="Do not overwrite the output file if it already exists.", ) parser.add_argument( "-rt", "--recalc-timestamp", action="store_true", help="Recalculate the font's modified timestamp on save.", ) parser.add_argument( "--max-err", type=float, default=1.0, help="The maximum error allowed when converting the font to TrueType.", ) parser.add_argument( "--new-upem", type=int, help="The target UPM to scale the font to.", ) parser.add_argument( "--correct-contours", action="store_true", help="Correct contours with pathops.", ) parser.add_argument( "--no-subroutinize", dest="subroutinize", action="store_false", help="Do not subroutinize the converted font.", ) args = parser.parse_args(args) input_path = args.input_path recursive = args.recursive output_dir = args.output_dir overwrite = args.overwrite recalc_timestamp = args.recalc_timestamp max_err = args.max_err new_upem = args.new_upem correct_contours = args.correct_contours subroutinize = args.subroutinize fonts = find_fonts( input_path, recursive=recursive, recalc_timestamp=recalc_timestamp ) if not fonts: log.error("No TrueType flavored fonts found.") return if output_dir and not output_dir.exists(): output_dir.mkdir(parents=True) for font in fonts: with font: if font.sfntVersion != "\x00\x01\x00\x00": log.error(f"Font {font.reader.file.name} is not a TrueType font.") continue in_file = font.reader.file.name log.info(f"Converting {in_file}...") log.info("Decomponentizing source font...") decomponentize_tt(font) if new_upem: log.info(f"Scaling UPM to {new_upem}...") scale_upem(font=font, new_upem=new_upem) log.info("Converting to OTF...") charstrings_dict = quadratics_to_cubics( font, tolerance=max_err, correct_contours=correct_contours ) ps_name = font["name"].getDebugName(6) font_info = build_font_info_dict(font=font) private_dict: t.Dict[str, t.Any] = {} build_otf(font, charstrings_dict, ps_name, font_info, private_dict) os_2_table = font["OS/2"] os_2_table.recalcAvgCharWidth(ttFont=font) if subroutinize: flavor = font.flavor font.flavor = None log.info("Subroutinizing...") subr(otf=font) font.flavor = flavor out_file = makeOutputFileName( input=in_file, outputDir=output_dir, extension=".otf" if font.flavor is None else f".{font.flavor}", suffix=".otf" if font.flavor is not None else "", overWrite=overwrite, ) font.save(out_file) log.info(f"File saved to {out_file}") print() if __name__ == "__main__": configLogger(logger=log, level="INFO") sys.exit(main())