Skip to content

Instantly share code, notes, and snippets.

@arrowtype
Last active June 26, 2023 17:03
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save arrowtype/db9562858a56ab45010e390c3f788b84 to your computer and use it in GitHub Desktop.
Save arrowtype/db9562858a56ab45010e390c3f788b84 to your computer and use it in GitHub Desktop.
A command-line tool to produce a "trial font" for input OTF or TTF font files, using Python 3 & FontTools
"""
From
https://github.com/arrowtype/recursive/blob/eb821735e66402b4b485ce97ed32b09a8555341e/src/build-scripts/make-release/compute-remaining-unicodes-in-font.py
The FontTools Subsetter expects unicode ranges for *inclusion*, but
often you just know which ranges you want to *exclude* from a font subset.
So, this script will accept a font, then report what Unicode values
it includes *beyond* given Unicode ranges.
USAGE:
python compute-remaining-unicodes-in-font.py <font> --unicodes <unicode-ranges>
Or see usage information at:
python compute-remaining-unicodes-in-font.py --help
"""
from fontTools.ttLib import TTFont
def listUnicodeRanges(unicodeRanges):
# remove "U+"" from ranges
unicodeRanges = unicodeRanges.replace("U+", "").replace(" ", "")
# create set
unicodesIncluded = set()
# split up separate ranges by commas
for unicodeChunk in unicodeRanges.split(","):
# if it's a range...
if "-" in unicodeChunk:
# get start and end of range
start, end = unicodeChunk.split("-")
# go through range and add each value to the set
for unicodeInteger in range(int(start, 16), int(end, 16) + 1):
unicodesIncluded.add(unicodeInteger)
# if it's a single unicode...
else:
unicodesIncluded.add(int(unicodeChunk, 16))
return unicodesIncluded
def main():
# get arguments from argparse
args = parser.parse_args()
# open font at TTFont object
ttfont = TTFont(str(args.fontPath[0]))
# get set of unicode ints in font
rangeInFont = {x for x in ttfont["cmap"].getBestCmap()}
# get unicode values for ranges given in arg
unicodesGiven = listUnicodeRanges(args.unicodes)
# Find unicodes in the font which aren’t listed, but are in the font
unicodesRemaining = {intUnicode for intUnicode in rangeInFont if intUnicode not in unicodesGiven}
# convert to hex, then join in comma-separated string
unicodesRemaining = [hex(n) for n in unicodesRemaining]
unicodesRemaining = [str(hex).replace("0x", "U+").upper() for hex in unicodesRemaining]
unicodesRemaining = ",".join(unicodesRemaining)
print(unicodesRemaining)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Report what Unicode values a font includes *beyond* given Unicode ranges.')
parser.add_argument('fontPath',
help='Path to a font file',
nargs="+")
parser.add_argument("-u", "--unicodes",
default="U+0020-0039, U+003A-005A, U+0061-007A, U+2018-201D, U+005B, U+005D",
help='String of unicodes or unicode ranges, comma-separated. Example – a basic Latin set would be "U+0020-0039, U+003A-005A, U+0061-007A, U+2018-201D, U+005B, U+005D"')
main()
# !/bin/bash
# A shell script to coordinate the building of a trial font package
#
# USAGE:
#
# 1. Give this file permission to execute (read it first):
#
# chmod +x <path>/make-trial-package.sh
#
# 2. Run it on the command line, and include path to folder of production fonts
#
# <path>/source/scripts/make-trial-package.sh <path>/fonts-v01
source venv/bin/activate
fontdir=$1
trialdir=fonts__trial/$(basename $fontdir)
# get font paths in a list
fonts=$(find $fontdir -name "*.ttf")
# create trial fonts with python script
for font in $fonts; do
# compute unicodes to exclude (basically, most symbols and punctuation)
unicodesToKeep=$(python3 "compute-remaining-unicodes.py" $font -u "U+0023-0026,U+003C-003E,U+005B-005F,U+007B-007D,U+00A2-00BE,U+00F7,U+2018,U+2019,U+201c,U+201d,U+201a,U+201e,U+2039,U+203A,U+2026,U+2032,U+2033,U+20ac,U+2013,U+2014,U+002a")
# for the "replacer" glyph arg, this uses /uni25AE, a filled rectangle character. Won’t work if your font doesn’t have this glyph.
python3 source/03-build-scripts/02-make-trial-font.py $font -r "uni25AE" -u $unicodesToKeep
done
# ------------------------
# move fonts
mkdir -p $trialdir/ttf
# move all trial fonts to a fonts__trial/ttf folder
trialTTFs=$(find $fontdir -name "*.trial.ttf*")
for ttf in $trialTTFs; do
mv $ttf $trialdir/ttf/$(basename $ttf)
done
# move variable TTF back up a level
trialVarTTF=$(find $trialdir/ttf -name "*variable.trial.ttf")
mv $trialVarTTF $trialdir/$(basename $trialVarTTF)
# -----------------
# TODO: add trial license
"""
WARNING: While this script will produce trial fonts from either OTF or TTF outputs, the OTFs produced do not work in Windows.
For this reason, it is strongly advised that you only produce TTF trial fonts with this, unless you are certain that trial users
will be only using them on Mac computers (which is hard to be certain of).
A script to create a trial font from an OpenType font file (OTF or TTF), keeping characters for specified unicodes, while hiding the rest with a "replacer" glyph.
This allows trial-font users to see how a given font looks to type words, but makes it simple to restrict it from full use in documents.
Hiding the extended character set under a "replacer" glyph prevents fallback fonts from easily being subbed in for unintended use.
This script is licensed under Apache 2.0: you are free to use and modify this script for commercial work.
USAGE:
1. On the command line, install python requirement FontTools:
pip install fonttools
2. Run this script on font file(s), either from the command line or from a shell script:
python3 <filepath>/02-make-trial-font.py <filepath>/font.ttf
Or, to see optional arguments, run:
python3 <filepath>/02-make-trial-font.py --help
TODO:
- Add trial suffix to variable postscript name IDs, if relevant. E.g. Recursive VF has nameIDs 275 RecursiveMonoLnr-Light, etc for each instance
- Replacer glyph shouldn't actually need to have a *unicode* value; it just needs to have a glyph. Fix this.
- Maybe: "hideRanges" should be an option. By default (as it works right now), the script justs hide anything that is in the font but not in the "unicodes" arg.
LICENSE:
Copyright 2021 Arrow Type LLC / Stephen Nixon
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import shutil
import os
from fontTools.ttLib import TTFont
from fontTools import subset
# GET / SET NAME HELPER FUNCTIONS
def getFontNameID(font, ID, platformID=3, platEncID=1):
name = str(font['name'].getName(ID, platformID, platEncID))
return name
def setFontNameID(font, ID, newName):
print(f"\n\t• name {ID}:")
macIDs = {"platformID": 3, "platEncID": 1, "langID": 0x409}
winIDs = {"platformID": 1, "platEncID": 0, "langID": 0x0}
oldMacName = font['name'].getName(ID, *macIDs.values())
oldWinName = font['name'].getName(ID, *winIDs.values())
if oldMacName != newName:
print(f"\n\t\t Mac name was '{oldMacName}'")
font['name'].setName(newName, ID, *macIDs.values())
print(f"\n\t\t Mac name now '{newName}'")
if oldWinName != newName:
print(f"\n\t\t Win name was '{oldWinName}'")
font['name'].setName(newName, ID, *winIDs.values())
print(f"\n\t\t Win name now '{newName}'")
def listUnicodeRanges(unicodeRanges):
# remove "U+"" from ranges
unicodeRanges = unicodeRanges.replace("U+", "").replace(" ", "")
# create set
unicodesIncluded = set()
# split up separate ranges by commas
for unicodeChunk in unicodeRanges.split(","):
# if it's a range...
if "-" in unicodeChunk:
# get start and end of range
start, end = unicodeChunk.split("-")
# go through range and add each value to the set
for unicodeInteger in range(int(start,16), int(end,16)+1):
unicodesIncluded.add(unicodeInteger)
# if it's a single unicode...
else:
unicodesIncluded.add(int(unicodeChunk,16))
return unicodesIncluded
def main():
# get arguments from argparse
args = parser.parse_args()
for fontPath in args.fontPaths:
# open font at TTFont object
ttfont = TTFont(fontPath)
filetype = fontPath.split(".")[-1]
# make path of temporary font for subsetting
tempFontPath = fontPath.replace(f".{filetype}",f".temporary.{filetype}")
if args.extended:
shutil.copyfile(fontPath, tempFontPath)
tempFont = TTFont(tempFontPath)
if not args.extended:
# get set of unicode ints in font
rangeInFont = {x for x in ttfont["cmap"].getBestCmap()}
unicodesToKeep = listUnicodeRanges(args.unicodes)
unicodesToHide = {intUnicode for intUnicode in rangeInFont if intUnicode not in unicodesToKeep}
# get cmap of font, find unicode for glyph with name of replacerGlyph
try:
if "U+" in args.replacer:
replacerGlyphUnicode = args.replacer.replace("U+","")
else:
replacerGlyphUnicode = list(ttfont["cmap"].buildReversed()[args.replacer])[0]
unicodesToKeep.add(replacerGlyphUnicode)
if replacerGlyphUnicode in unicodesToHide:
unicodesToHide.remove(replacerGlyphUnicode) # TODO: check if this fails if item not in set
except KeyError:
print("\nReplacer glyph has no unicode; try checking the font file to copy in an exact name.\n")
print("Try checking the font file to copy in an exact glyph name, e.g. 'asterisk' rather than '*'.\n")
print("Stopping execution.\n")
break
unicodesToKeep = [hex(n) for n in unicodesToKeep]
unicodesToKeep = ",".join(unicodesToKeep)
# Subset input font. Keep specified unicodes only. Keep all font name IDs. Keep glyph names as-is. Keep notdef from font. Output to temporary path.
# Note: you may wish to remove '--layout-features=*' from this list to limit opentype features.
subset.main([fontPath, f'--unicodes={unicodesToKeep}', "--name-IDs=*", "--layout-features=*", "--glyph-names", '--notdef-outline', f'--output-file={tempFontPath}'])
tempFont = TTFont(tempFontPath)
# -------------------------------------------------------------------------------------------------
# then, add many additional unicodes to the replacer glyph to cover all diacritics, etc
for table in tempFont['cmap'].tables:
for c in unicodesToHide:
table.cmap[c] = args.replacer
# -------------------------------------------------------------------------------------------------
# update font names
familyName = getFontNameID(ttfont, 16)
nameSuffix = args.suffix
# MUST check if familyName is not 'None', or this doesn't work (e.g. can't just check if None)
if familyName != 'None':
newFamName = familyName + f" {nameSuffix}"
setFontNameID(tempFont, 16, newFamName)
else:
familyName = getFontNameID(ttfont, 1)
newFamName = familyName + f" {nameSuffix}"
print("familyName is", familyName)
# UPDATE NAME ID 6, postscript name
# Format: FamilynameTrial-Stylename
currentPsName = getFontNameID(ttfont, 6)
newPsName = currentPsName.replace('-',f'{nameSuffix}-')
setFontNameID(tempFont, 6, newPsName)
# UPDATE NAME ID 4, full font name
# Format: Familyname Trial Stylename
currentFullName = getFontNameID(ttfont, 4)
newFullName = currentFullName.replace(familyName,f'{familyName} {nameSuffix}')
setFontNameID(tempFont, 4, newFullName)
# UPDATE NAME ID 3, unique font ID
# Format: 1.001;ARRW;FamilynameTrial-Stylename
currentUniqueName = getFontNameID(ttfont, 3)
newUniqueName = currentUniqueName.replace('-',f'{nameSuffix}-')
setFontNameID(tempFont, 3, newUniqueName)
# UPDATE NAME ID 1, unique font ID
# Format: Familyname Trial OR Familyname Trial Style (if not Regular, Italic, Bold, or Bold Italic)
currentFamName = getFontNameID(ttfont, 1)
newFamNameOne = currentFamName.replace(familyName,newFamName)
setFontNameID(tempFont, 1, newFamNameOne)
# -------------------------------------------------------------------------------------------------
# save font with suffix added to name
tempFont.save(tempFontPath.replace(f".temporary.{filetype}",f".{nameSuffix}.{filetype}"))
# clean up temp subset font
os.remove(tempFontPath)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Make a "trial font" from OpenType font files, keeping characters for specified unicodes, while hiding the rest.')
parser.add_argument('fontPaths',
help='Path(s) to font file(s)',
nargs="+")
parser.add_argument("-x", "--extended",
action='store_true',
help='Skips character subsetting to make an "extended" trial font, with a full, unmodified character set.')
parser.add_argument("-u", "--unicodes",
default="U+0020-0039, U+003A-005A, U+0061-007A, U+2018-201D, U+005B, U+005D",
help='String of unicodes or unicode ranges to keep, comma-separated. Default is a basic Latin set: "U+0020-0039, U+003A-005A, U+0061-007A, U+2018-201D, U+005B, U+005D"')
parser.add_argument('-r','--replacer',
default="X",
help='Name of glyph that will replace unicodes to hide. If you wish to use unicode, start with "U+" like "U+0058". Glyph be in the font & cannnot be ".notdef". Default: "X". ')
parser.add_argument('-s','--suffix',
default="Trial",
help='Suffix to add to trial font names. Default: "Trial".')
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment