| __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()) |