Presented at TypeLab 2023.
A much better version is now available at https://gist.github.com/arrowtype/47937ba868b0b2a49e80684319e56037
Presented at TypeLab 2023.
A much better version is now available at https://gist.github.com/arrowtype/47937ba868b0b2a49e80684319e56037
| """ | |
| A script to make it slightly less painful to work with support sources. | |
| Checks the spacing of glyphs in support sources, by re-interpolating | |
| those glyphs from main sources, and checking the support glyphs against that. | |
| Also places interpolated versions in the background, for visual reference. | |
| Fixes small spacing discrepancies, but leaves bigger ones, as they might be intentional. | |
| Can be run either in RoboFont or as a standalone script, if you adjust 'mainDSpath' | |
| and 'generatorDSpath' variables. | |
| Assumptions / prequesites: | |
| - You have two designspaces: one that is a "generator" with only main (full) sources, | |
| and another that is a "full" designspace that includes sparse sources. | |
| - The generator designspace has instances at intended support locations | |
| - Support sources include the substring "sparse" or "support" in their filenames | |
| - Full sources *don’t* include the substring "sparse" or "support" in their filenames | |
| - You want to clear glyph color marks in support sources, and use red marks to | |
| indicate glyphs to check (this can be configured below) | |
| TODO: | |
| - [ ] round dimensions for glyph2? | |
| - [ ] generate "generator" DS on the fly | |
| - [ ] update to latest UFOprocessor for designspace5 support | |
| """ | |
| from fontTools.designspaceLib import DesignSpaceDocument | |
| from fontParts.fontshell import RFont as Font | |
| import ufoProcessor | |
| import os | |
| from ufonormalizer import normalizeUFO | |
| import subprocess | |
| # ------------------------------------------------------------------- | |
| # CONFIGURATION | |
| ## main DS, with sparse/support sources – update path as needed. Switch the comment in the next two lines to instead use RoboFont. | |
| mainDSpath = "/Users/stephennixon/type-repos/name-sans/source/masters/name_sans-wght_1_1000-opsz_12_96--w_sparse_supports.designspace" | |
| # mainDSpath = "/Users/stephennixon/type-repos/name-sans/source/masters/italic/name_sans_italic-wght_1_1000-opsz_12_96--w_sparse_supports.designspace" | |
| ## "generator" DS, with main sources only – update path as needed. Switch the comment in the next two lines to instead use RoboFont. | |
| generatorDSpath = "/Users/stephennixon/type-repos/name-sans/source/masters/name_sans-wght_1_1000-opsz_12_96--generator.designspace" | |
| # generatorDSpath = "/Users/stephennixon/type-repos/name-sans/source/masters/italic/name_sans_italic-wght_1_1000-opsz_12_96--generator.designspace" | |
| # set to True if you want to unmark all glyphs in support sources, then add a certain color to glyphs to check | |
| markGlyphsToFix = True | |
| markColor = (1.0, 0.65, 0.65, 1) # (1.0, 0.65, 0.65, 1) is a nice pinkish red | |
| # set to True if you intend to run this in RoboFont rather than in a terminal | |
| runInRobofont = True | |
| # CONFIGURATION | |
| # ------------------------------------------------------------------- | |
| # open & clear output window if running in robofont | |
| if runInRobofont: | |
| from vanilla.dialogs import getFile | |
| from mojo.UI import OutputWindow | |
| OutputWindow().show() | |
| OutputWindow().clear() | |
| mainDSpath = getFile("Select Designspace with sparse/support sources", allowsMultipleSelection=False, fileTypes=["designspace"])[0] | |
| generatorDSpath = getFile("Select Designspace WITHOUT sparse/support sources", allowsMultipleSelection=False, fileTypes=["designspace"])[0] | |
| # set up a place to store information to print out later | |
| report = {} | |
| # open main DS & fonts with ufoProcessor | |
| mainDS = DesignSpaceDocument.fromfile(mainDSpath) | |
| openedMainDS = ufoProcessor.DesignSpaceProcessor() | |
| openedMainDS.read(mainDSpath) | |
| # opens generator DS with FontTools | |
| generatorDS = DesignSpaceDocument.fromfile(generatorDSpath) | |
| # open generator DS & fonts with ufoProcessor | |
| openedGeneratorDS = ufoProcessor.DesignSpaceProcessor() | |
| openedGeneratorDS.read(generatorDSpath) | |
| openedGeneratorDS.loadFonts() | |
| def computeItalicOffset(font, offsetBasisGlyph="H", roundOffset=True): | |
| """ | |
| https://robofont.com/RF4.1/documentation/tutorials/making-italic-fonts/#applying-the-italic-slant-offset-after-drawing | |
| """ | |
| if offsetBasisGlyph not in font.keys(): | |
| if "o" in font.keys(): | |
| offsetBasisGlyph = "o" | |
| elif "e" in font.keys(): | |
| offsetBasisGlyph = "e" | |
| elif "period" in font.keys(): | |
| offsetBasisGlyph = "period" | |
| else: | |
| print(f"Can’t correct italic offset of {font.path}") | |
| return | |
| # calculate offset value with offsetBasisGlyph | |
| baseLeftMargin = (font[offsetBasisGlyph].angledLeftMargin + font[offsetBasisGlyph].angledRightMargin) / 2.0 | |
| offset = -font[offsetBasisGlyph].angledLeftMargin + baseLeftMargin | |
| # round offset value | |
| if roundOffset and offset != 0: | |
| offset = round(offset) | |
| return offset | |
| def fixAnchors(glyph1, glyph2): | |
| """ | |
| Clears anchors in the support glyph, and copies over new ones with rounded positioning | |
| """ | |
| # clear any existing anchors | |
| glyph1.clearAnchors() | |
| # # attempt to position anchors better | |
| # glyph2.moveBy((italicOffset, 0)) | |
| # copy anchors (may need a loop instead...?) | |
| if len(glyph2.anchors) > 0: | |
| for a in glyph2.anchors: | |
| glyph1.appendAnchor(a.name, (a.x, a.y)) | |
| # apply italic offset from interpolated font | |
| # for anchor in glyph1.anchors: | |
| # anchor.x += -1 * glyph2.font.info.italicOffset | |
| def copyG2toG1bg(glyph1, glyph2): | |
| """ | |
| Copies the outline of glyph2 to the background layer of glyph1. Also places guidelines in the glyph1 foreground to show margins of glyph2. | |
| Args: | |
| - glyph1: a manually edited glyph in the "suppoort" source of a buildable designspace | |
| - glyph2: a newly-generated glyph, interpolated from the main/non-support sources of that designspace | |
| """ | |
| supportSourceFont = glyph1.font | |
| italicOffset = supportSourceFont.lib["com.typemytype.robofont.italicSlantOffset"] | |
| # let’s also add a single guide for the interpolated width (TODO: check if italic angle is working) | |
| glyph1.clearGuidelines() | |
| glyph1.appendGuideline((glyph2.width + italicOffset, 0), 90 + supportSourceFont.info.italicAngle) | |
| # also just add a guideline at the left edge, to make the point more obvious | |
| glyph1.appendGuideline((0 + italicOffset, 0), 90 + supportSourceFont.info.italicAngle) | |
| # get background layer name | |
| supportBg = supportSourceFont.layers[1] | |
| supportFontBgLayerName = supportBg.name | |
| # check if g1 has background layer (really, if layer has g1); if not, add it | |
| if glyph1.name not in supportBg: | |
| supportBg.newGlyph(glyph1.name) | |
| # get glyph1 bg, then clear it | |
| glyph1Bg = supportSourceFont[glyph1.name].getLayer(supportFontBgLayerName) | |
| glyph1Bg.clear() | |
| # # move glyph2 so we can position it better in background | |
| # glyph2.moveBy((italicOffset, 0)) | |
| # get the point pen of the layer glyph | |
| penToDrawWith = glyph1Bg.getPointPen() | |
| # draw the points of the imported glyph into the layered glyph | |
| glyph2.drawPoints(penToDrawWith) | |
| def fixSpacing(glyph1, glyph2): | |
| """ | |
| - if glyph is empty (like /space), just update the width | |
| - if g1 with g2.leftMargin and g2.rightMargin would equal g2.width, update g2.leftMargin and g2.width to match | |
| - if not, add the glyph diffs to the report | |
| - in practice, no glyphs were auto-fixed with this... it will be more important to flag what issues are | |
| """ | |
| if glyph1.isEmpty(): | |
| glyph1.width = round(glyph2.width) | |
| else: | |
| # # get width of drawn glyph | |
| # glyph1BoundsWidth = glyph1.bounds[0] - glyph1.bounds[2] | |
| # # if g1 with g2.leftMargin and g2.rightMargin would equal g2.width, update g2.leftMargin and g2.width to match | |
| # if round(glyph1BoundsWidth + glyph2.angledLeftMargin + glyph2.angledRightMargin) == round(glyph2.width): | |
| # glyph1.angledLeftMargin = glyph2.angledLeftMargin | |
| # glyph1.width = glyph2.width | |
| # TODO? figure out a better heuristic to not mess up spacing in dot glyphs... | |
| dotGlyphs = "dotbelowcmb quoteleft quoteright quotedblleft quotedblright quotesinglbase quotedblbase period comma colon semicolon exclam exclamdown question questiondown dotcomb ldotcomb comma.brut semicolon.brut exclam.brut exclamdown.brut colon.tnum semicolon.tnum exclamdown.case_brut exclamdown.case questiondown.case".split(" ") | |
| if glyph1.name not in dotGlyphs: | |
| glyph1.angledLeftMargin = glyph2.angledLeftMargin | |
| glyph1.width = glyph2.width | |
| def checkForSpacingDiffs(glyph1, glyph2): | |
| """ | |
| Fixes spacing discrepancies in glyph1 to match glyph2, so long as that can be done without modifying the contours of glyph1. | |
| Args: | |
| - glyph1: a manually edited glyph in the "suppoort" source of a buildable designspace | |
| - glyph2: a newly-generated glyph, interpolated from the main/non-support sources of that designspace | |
| """ | |
| # get basic dimensions of g1 | |
| supportGlyphDimensions = (round(glyph1.width), round(glyph1.leftMargin), round(glyph1.rightMargin)) | |
| # get basic dimensions of g2 | |
| generatedGlyphDimensions = (round(glyph2.width), round(glyph2.leftMargin), round(glyph2.rightMargin)) | |
| spacing = (supportGlyphDimensions, generatedGlyphDimensions) | |
| # save differences to logger (TODO? save as file? put in markdown todo list format?) | |
| if supportGlyphDimensions != generatedGlyphDimensions: | |
| return spacing | |
| def fuzzyReport(glyph1, spacingDiff, marginOfError=2): | |
| """ | |
| Check spacing differences. If they exceed margin of error, add them to the report for fixing. | |
| Spacing diffs arg is a tuple of (glyph1dimensions, glyph2dimensions) if they don’t | |
| have exactly matched (width, leftMargin, rightMargin): | |
| > ( | |
| > (589, 164, 189), # from support source | |
| > (502, 159, 189) # from generated interpolation | |
| > ), | |
| """ | |
| reportSection = "" | |
| widthDiff = abs(spacingDiff[1][0] - spacingDiff[0][0]) >= marginOfError | |
| leftMarginDiff = abs(spacingDiff[1][1] - spacingDiff[0][1]) >= marginOfError | |
| rightMarginDiff = abs(spacingDiff[1][2] - spacingDiff[0][2]) >= marginOfError | |
| if widthDiff or leftMarginDiff or rightMarginDiff: | |
| reportSection += "\n" + "- [ ] " + glyph1.name + "\n" | |
| # defined in top configuration | |
| if markGlyphsToFix: | |
| glyph1.markColor = markColor | |
| if widthDiff: | |
| reportSection += " - width difference is " + str(abs(spacingDiff[0][0] - spacingDiff[1][0])) + "\n" | |
| if leftMarginDiff: | |
| reportSection += " - leftMargin difference is " + str(abs(spacingDiff[0][1] - spacingDiff[1][1])) + "\n" | |
| if rightMarginDiff: | |
| reportSection += " - rightMargin difference is " + str(abs(spacingDiff[0][2] - spacingDiff[1][2])) + "\n" | |
| report[glyph1.font.path] += reportSection | |
| # a list of locations to check | |
| supportLocations = {} | |
| # first, we make a dict of sparse sources and their locations | |
| for dsSource in openedMainDS.sources: | |
| if "sparse" in dsSource.filename or "support" in dsSource.filename: | |
| supportLocations[dsSource] = dsSource.location | |
| # then we loop through the instances of the "generator" designspace | |
| for dsInstance in generatorDS.instances: | |
| # and check if it matches a support source | |
| for supportSource, location in supportLocations.items(): | |
| if dsInstance.location == location: | |
| report[supportSource.path] = "" | |
| # # open support source as RFont ... should we use RF’s OpenFont() to access angledLeftMargin, etc? | |
| supportSourceFont = OpenFont(supportSource.path, showInterface=False) | |
| print(supportSourceFont) | |
| # save, then open generated font | |
| generatedInstance = openedGeneratorDS.makeInstance(dsInstance) | |
| ## open font with robofont | |
| generatedFont = OpenFont(generatedInstance, showInterface=False) | |
| # sort generatedFont | |
| newGlyphOrder = generatedFont.naked().unicodeData.sortGlyphNames(generatedFont.glyphOrder, sortDescriptors=[ | |
| dict(type="cannedDesign", ascending=True, allowPseudoUnicode=True)]) | |
| generatedFont.glyphOrder = newGlyphOrder | |
| # sort supportSourceFont | |
| newGlyphOrder = supportSourceFont.naked().unicodeData.sortGlyphNames(supportSourceFont.glyphOrder, sortDescriptors=[ | |
| dict(type="cannedDesign", ascending=True, allowPseudoUnicode=True)]) | |
| supportSourceFont.glyphOrder = newGlyphOrder | |
| # compute italic offset, then apply to both fonts | |
| # TODO? should this instead be computed simply from the italic offset of fonts, using a factor derived from the designspace? Probably... | |
| italicOffset = computeItalicOffset(generatedFont, offsetBasisGlyph="H", roundOffset=True) | |
| # generatedFont.info.italicOffset = italicOffset | |
| # supportSourceFont.info.italicOffset = italicOffset | |
| generatedFont.lib["com.typemytype.robofont.italicSlantOffset"] = -italicOffset | |
| supportSourceFont.lib["com.typemytype.robofont.italicSlantOffset"] = -italicOffset | |
| # go through each glyph in manually edited support source | |
| for g1 in supportSourceFont: | |
| g2 = generatedFont[g1.name] | |
| # copy new glyphs to background of main support glyphs, for reference | |
| copyG2toG1bg(g1, generatedFont[g1.name]) | |
| # fix some things automatically if possible | |
| fixSpacing(g1, g2) | |
| # clear existing anchors from support, then add new ones | |
| fixAnchors(g1, g2) | |
| # compare glyphs and report differences in spacing | |
| spacingDiffs = checkForSpacingDiffs(g1, g2) | |
| # if spacingDiffs not "None" | |
| if spacingDiffs: | |
| if markGlyphsToFix: | |
| # set mark to None, to overwrite it with fuzzy report | |
| g1.markColor = None | |
| # make report if things are more than marginOfError off | |
| fuzzyReport(g1, spacingDiffs, marginOfError=2) | |
| supportSourceFont.save() | |
| normalizeUFO(supportSourceFont.path, writeModTimes=False) | |
| print(".", end=" ") | |
| continue | |
| finalReport = "" | |
| # print the spacing report in a readable way | |
| for fontpath, reportSection in report.items(): | |
| finalReport += "\n" | |
| finalReport += "<details><summary><b>" | |
| finalReport += f"{os.path.split(fontpath)[1]}" | |
| finalReport += "</b> (Click to expand)</summary>" | |
| finalReport += "\n" | |
| finalReport += reportSection | |
| finalReport += "\n" | |
| finalReport += "</details>" | |
| finalReport += "\n" | |
| print(finalReport) | |
| subprocess.run("pbcopy", text=True, input=finalReport) | |
| print("GitHub-ready markdown report copied to clipboard!") |