diff --git a/Snippets/ttf2otf.py b/Snippets/ttf2otf.py new file mode 100644 index 000000000..23145221c --- /dev/null +++ b/Snippets/ttf2otf.py @@ -0,0 +1,464 @@ +import logging +import sys +import typing as t +from pathlib import Path + +import click +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 = True +) -> 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: + 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.info( + f"Failed to convert glyph {k} to cubic at first attempt, but succeeded at second " + f"attempt." + ) + + 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. + + 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 + + +@click.command("ttf2otf", no_args_is_help=True) +@click.argument("input_path", type=click.Path(exists=True, resolve_path=True, path_type=Path)) +@click.option( + "-r", + "--recursive", + is_flag=True, + default=False, + help="If INPUT_PATH is a directory, search for fonts recursively in subdirectories.", +) +@click.option( + "-o", + "--output-dir", + type=click.Path(file_okay=False, path_type=Path), + help="Specify a directory where the output files are to be saved. If the output directory " + "doesn't exist, it will be automatically created. If not specified, files will be saved to " + "the source directory.", +) +@click.option( + "--no-overwrite", + "overwrite", + is_flag=True, + default=True, + help="Do not overwrite the output file if it already exists.", +) +@click.option( + "-rt", + "--recalc-timestamp", + is_flag=True, + default=False, + help="Recalculate the font's timestamp.", +) +@click.option( + "--max-err", + type=float, + default=1.0, + help="The maximum error allowed when converting the font to TrueType.", +) +@click.option( + "--new-upem", + type=click.IntRange(min=16, max=16384), + help="The target UPM to scale the font to.", +) +@click.option( + "--correct-contours", + is_flag=True, + default=False, + help=""" + If the TrueType contours fonts have overlaps contours or incorrect directions, set this flag to + correct them with pathops. + """, +) +@click.option( + "--no-subroutinize", + "subroutinize", + is_flag=True, + default=True, + help="Do not subroutinize the converted font.", +) +def main( + input_path: Path, + recursive: bool = False, + output_dir: t.Optional[Path] = None, + overwrite: bool = True, + recalc_timestamp: bool = False, + max_err: float = 1.0, + new_upem: t.Optional[int] = None, + correct_contours: bool = True, + subroutinize: bool = True, +) -> 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. + """ + + fonts = find_fonts(input_path, recursive=recursive, recalc_timestamp=recalc_timestamp) + if not fonts: + log.error("No fonts found.") + return + + if output_dir and not output_dir.exists(): + output_dir.mkdir(parents=True) + + for font in fonts: + with font: + 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())