Add 'ttf2otf.py' snippet
This commit is contained in:
parent
705acc994f
commit
42716d5038
464
Snippets/ttf2otf.py
Normal file
464
Snippets/ttf2otf.py
Normal file
@ -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())
|
Loading…
x
Reference in New Issue
Block a user