Replaced Click with argparse. Updated docstring with usage examples. Modified logging to use warnings for conversion failures.
490 lines
15 KiB
Python
490 lines
15 KiB
Python
__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())
|