You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			389 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			389 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
"""Helpers for instantiating name table records."""
 | 
						|
 | 
						|
from contextlib import contextmanager
 | 
						|
from copy import deepcopy
 | 
						|
from enum import IntEnum
 | 
						|
import re
 | 
						|
 | 
						|
 | 
						|
class NameID(IntEnum):
 | 
						|
    FAMILY_NAME = 1
 | 
						|
    SUBFAMILY_NAME = 2
 | 
						|
    UNIQUE_FONT_IDENTIFIER = 3
 | 
						|
    FULL_FONT_NAME = 4
 | 
						|
    VERSION_STRING = 5
 | 
						|
    POSTSCRIPT_NAME = 6
 | 
						|
    TYPOGRAPHIC_FAMILY_NAME = 16
 | 
						|
    TYPOGRAPHIC_SUBFAMILY_NAME = 17
 | 
						|
    VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25
 | 
						|
 | 
						|
 | 
						|
ELIDABLE_AXIS_VALUE_NAME = 2
 | 
						|
 | 
						|
 | 
						|
def getVariationNameIDs(varfont):
 | 
						|
    used = []
 | 
						|
    if "fvar" in varfont:
 | 
						|
        fvar = varfont["fvar"]
 | 
						|
        for axis in fvar.axes:
 | 
						|
            used.append(axis.axisNameID)
 | 
						|
        for instance in fvar.instances:
 | 
						|
            used.append(instance.subfamilyNameID)
 | 
						|
            if instance.postscriptNameID != 0xFFFF:
 | 
						|
                used.append(instance.postscriptNameID)
 | 
						|
    if "STAT" in varfont:
 | 
						|
        stat = varfont["STAT"].table
 | 
						|
        for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else ():
 | 
						|
            used.append(axis.AxisNameID)
 | 
						|
        for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else ():
 | 
						|
            used.append(value.ValueNameID)
 | 
						|
        elidedFallbackNameID = getattr(stat, "ElidedFallbackNameID", None)
 | 
						|
        if elidedFallbackNameID is not None:
 | 
						|
            used.append(elidedFallbackNameID)
 | 
						|
    # nameIDs <= 255 are reserved by OT spec so we don't touch them
 | 
						|
    return {nameID for nameID in used if nameID > 255}
 | 
						|
 | 
						|
 | 
						|
@contextmanager
 | 
						|
def pruningUnusedNames(varfont):
 | 
						|
    from . import log
 | 
						|
 | 
						|
    origNameIDs = getVariationNameIDs(varfont)
 | 
						|
 | 
						|
    yield
 | 
						|
 | 
						|
    log.info("Pruning name table")
 | 
						|
    exclude = origNameIDs - getVariationNameIDs(varfont)
 | 
						|
    varfont["name"].names[:] = [
 | 
						|
        record for record in varfont["name"].names if record.nameID not in exclude
 | 
						|
    ]
 | 
						|
    if "ltag" in varfont:
 | 
						|
        # Drop the whole 'ltag' table if all the language-dependent Unicode name
 | 
						|
        # records that reference it have been dropped.
 | 
						|
        # TODO: Only prune unused ltag tags, renumerating langIDs accordingly.
 | 
						|
        # Note ltag can also be used by feat or morx tables, so check those too.
 | 
						|
        if not any(
 | 
						|
            record
 | 
						|
            for record in varfont["name"].names
 | 
						|
            if record.platformID == 0 and record.langID != 0xFFFF
 | 
						|
        ):
 | 
						|
            del varfont["ltag"]
 | 
						|
 | 
						|
 | 
						|
def updateNameTable(varfont, axisLimits):
 | 
						|
    """Update instatiated variable font's name table using STAT AxisValues.
 | 
						|
 | 
						|
    Raises ValueError if the STAT table is missing or an Axis Value table is
 | 
						|
    missing for requested axis locations.
 | 
						|
 | 
						|
    First, collect all STAT AxisValues that match the new default axis locations
 | 
						|
    (excluding "elided" ones); concatenate the strings in design axis order,
 | 
						|
    while giving priority to "synthetic" values (Format 4), to form the
 | 
						|
    typographic subfamily name associated with the new default instance.
 | 
						|
    Finally, update all related records in the name table, making sure that
 | 
						|
    legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic,
 | 
						|
    Bold, Bold Italic) naming model.
 | 
						|
 | 
						|
    Example: Updating a partial variable font:
 | 
						|
    | >>> ttFont = TTFont("OpenSans[wdth,wght].ttf")
 | 
						|
    | >>> updateNameTable(ttFont, {"wght": (400, 900), "wdth": 75})
 | 
						|
 | 
						|
    The name table records will be updated in the following manner:
 | 
						|
    NameID 1 familyName: "Open Sans" --> "Open Sans Condensed"
 | 
						|
    NameID 2 subFamilyName: "Regular" --> "Regular"
 | 
						|
    NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \
 | 
						|
        "3.000;GOOG;OpenSans-Condensed"
 | 
						|
    NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed"
 | 
						|
    NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed"
 | 
						|
    NameID 16 Typographic Family name: None --> "Open Sans"
 | 
						|
    NameID 17 Typographic Subfamily name: None --> "Condensed"
 | 
						|
 | 
						|
    References:
 | 
						|
    https://docs.microsoft.com/en-us/typography/opentype/spec/stat
 | 
						|
    https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids
 | 
						|
    """
 | 
						|
    from . import AxisLimits, axisValuesFromAxisLimits
 | 
						|
 | 
						|
    if "STAT" not in varfont:
 | 
						|
        raise ValueError("Cannot update name table since there is no STAT table.")
 | 
						|
    stat = varfont["STAT"].table
 | 
						|
    if not stat.AxisValueArray:
 | 
						|
        raise ValueError("Cannot update name table since there are no STAT Axis Values")
 | 
						|
    fvar = varfont["fvar"]
 | 
						|
 | 
						|
    # The updated name table will reflect the new 'zero origin' of the font.
 | 
						|
    # If we're instantiating a partial font, we will populate the unpinned
 | 
						|
    # axes with their default axis values from fvar.
 | 
						|
    axisLimits = AxisLimits(axisLimits).limitAxesAndPopulateDefaults(varfont)
 | 
						|
    partialDefaults = axisLimits.defaultLocation()
 | 
						|
    fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes}
 | 
						|
    defaultAxisCoords = AxisLimits({**fvarDefaults, **partialDefaults})
 | 
						|
    assert all(v.minimum == v.maximum for v in defaultAxisCoords.values())
 | 
						|
 | 
						|
    axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords)
 | 
						|
    checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords.pinnedLocation())
 | 
						|
 | 
						|
    # ignore "elidable" axis values, should be omitted in application font menus.
 | 
						|
    axisValueTables = [
 | 
						|
        v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME
 | 
						|
    ]
 | 
						|
    axisValueTables = _sortAxisValues(axisValueTables)
 | 
						|
    _updateNameRecords(varfont, axisValueTables)
 | 
						|
 | 
						|
 | 
						|
def checkAxisValuesExist(stat, axisValues, axisCoords):
 | 
						|
    seen = set()
 | 
						|
    designAxes = stat.DesignAxisRecord.Axis
 | 
						|
    hasValues = set()
 | 
						|
    for value in stat.AxisValueArray.AxisValue:
 | 
						|
        if value.Format in (1, 2, 3):
 | 
						|
            hasValues.add(designAxes[value.AxisIndex].AxisTag)
 | 
						|
        elif value.Format == 4:
 | 
						|
            for rec in value.AxisValueRecord:
 | 
						|
                hasValues.add(designAxes[rec.AxisIndex].AxisTag)
 | 
						|
 | 
						|
    for axisValueTable in axisValues:
 | 
						|
        axisValueFormat = axisValueTable.Format
 | 
						|
        if axisValueTable.Format in (1, 2, 3):
 | 
						|
            axisTag = designAxes[axisValueTable.AxisIndex].AxisTag
 | 
						|
            if axisValueFormat == 2:
 | 
						|
                axisValue = axisValueTable.NominalValue
 | 
						|
            else:
 | 
						|
                axisValue = axisValueTable.Value
 | 
						|
            if axisTag in axisCoords and axisValue == axisCoords[axisTag]:
 | 
						|
                seen.add(axisTag)
 | 
						|
        elif axisValueTable.Format == 4:
 | 
						|
            for rec in axisValueTable.AxisValueRecord:
 | 
						|
                axisTag = designAxes[rec.AxisIndex].AxisTag
 | 
						|
                if axisTag in axisCoords and rec.Value == axisCoords[axisTag]:
 | 
						|
                    seen.add(axisTag)
 | 
						|
 | 
						|
    missingAxes = (set(axisCoords) - seen) & hasValues
 | 
						|
    if missingAxes:
 | 
						|
        missing = ", ".join(f"'{i}': {axisCoords[i]}" for i in missingAxes)
 | 
						|
        raise ValueError(f"Cannot find Axis Values {{{missing}}}")
 | 
						|
 | 
						|
 | 
						|
def _sortAxisValues(axisValues):
 | 
						|
    # Sort by axis index, remove duplicates and ensure that format 4 AxisValues
 | 
						|
    # are dominant.
 | 
						|
    # The MS Spec states: "if a format 1, format 2 or format 3 table has a
 | 
						|
    # (nominal) value used in a format 4 table that also has values for
 | 
						|
    # other axes, the format 4 table, being the more specific match, is used",
 | 
						|
    # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4
 | 
						|
    results = []
 | 
						|
    seenAxes = set()
 | 
						|
    # Sort format 4 axes so the tables with the most AxisValueRecords are first
 | 
						|
    format4 = sorted(
 | 
						|
        [v for v in axisValues if v.Format == 4],
 | 
						|
        key=lambda v: len(v.AxisValueRecord),
 | 
						|
        reverse=True,
 | 
						|
    )
 | 
						|
 | 
						|
    for val in format4:
 | 
						|
        axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord)
 | 
						|
        minIndex = min(axisIndexes)
 | 
						|
        if not seenAxes & axisIndexes:
 | 
						|
            seenAxes |= axisIndexes
 | 
						|
            results.append((minIndex, val))
 | 
						|
 | 
						|
    for val in axisValues:
 | 
						|
        if val in format4:
 | 
						|
            continue
 | 
						|
        axisIndex = val.AxisIndex
 | 
						|
        if axisIndex not in seenAxes:
 | 
						|
            seenAxes.add(axisIndex)
 | 
						|
            results.append((axisIndex, val))
 | 
						|
 | 
						|
    return [axisValue for _, axisValue in sorted(results)]
 | 
						|
 | 
						|
 | 
						|
def _updateNameRecords(varfont, axisValues):
 | 
						|
    # Update nametable based on the axisValues using the R/I/B/BI model.
 | 
						|
    nametable = varfont["name"]
 | 
						|
    stat = varfont["STAT"].table
 | 
						|
 | 
						|
    axisValueNameIDs = [a.ValueNameID for a in axisValues]
 | 
						|
    ribbiNameIDs = [n for n in axisValueNameIDs if _isRibbi(nametable, n)]
 | 
						|
    nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs]
 | 
						|
    elidedNameID = stat.ElidedFallbackNameID
 | 
						|
    elidedNameIsRibbi = _isRibbi(nametable, elidedNameID)
 | 
						|
 | 
						|
    getName = nametable.getName
 | 
						|
    platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names)
 | 
						|
    for platform in platforms:
 | 
						|
        if not all(getName(i, *platform) for i in (1, 2, elidedNameID)):
 | 
						|
            # Since no family name and subfamily name records were found,
 | 
						|
            # we cannot update this set of name Records.
 | 
						|
            continue
 | 
						|
 | 
						|
        subFamilyName = " ".join(
 | 
						|
            getName(n, *platform).toUnicode() for n in ribbiNameIDs
 | 
						|
        )
 | 
						|
        if nonRibbiNameIDs:
 | 
						|
            typoSubFamilyName = " ".join(
 | 
						|
                getName(n, *platform).toUnicode() for n in axisValueNameIDs
 | 
						|
            )
 | 
						|
        else:
 | 
						|
            typoSubFamilyName = None
 | 
						|
 | 
						|
        # If neither subFamilyName and typographic SubFamilyName exist,
 | 
						|
        # we will use the STAT's elidedFallbackName
 | 
						|
        if not typoSubFamilyName and not subFamilyName:
 | 
						|
            if elidedNameIsRibbi:
 | 
						|
                subFamilyName = getName(elidedNameID, *platform).toUnicode()
 | 
						|
            else:
 | 
						|
                typoSubFamilyName = getName(elidedNameID, *platform).toUnicode()
 | 
						|
 | 
						|
        familyNameSuffix = " ".join(
 | 
						|
            getName(n, *platform).toUnicode() for n in nonRibbiNameIDs
 | 
						|
        )
 | 
						|
 | 
						|
        _updateNameTableStyleRecords(
 | 
						|
            varfont,
 | 
						|
            familyNameSuffix,
 | 
						|
            subFamilyName,
 | 
						|
            typoSubFamilyName,
 | 
						|
            *platform,
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
def _isRibbi(nametable, nameID):
 | 
						|
    englishRecord = nametable.getName(nameID, 3, 1, 0x409)
 | 
						|
    return (
 | 
						|
        True
 | 
						|
        if englishRecord is not None
 | 
						|
        and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic")
 | 
						|
        else False
 | 
						|
    )
 | 
						|
 | 
						|
 | 
						|
def _updateNameTableStyleRecords(
 | 
						|
    varfont,
 | 
						|
    familyNameSuffix,
 | 
						|
    subFamilyName,
 | 
						|
    typoSubFamilyName,
 | 
						|
    platformID=3,
 | 
						|
    platEncID=1,
 | 
						|
    langID=0x409,
 | 
						|
):
 | 
						|
    # TODO (Marc F) It may be nice to make this part a standalone
 | 
						|
    # font renamer in the future.
 | 
						|
    nametable = varfont["name"]
 | 
						|
    platform = (platformID, platEncID, langID)
 | 
						|
 | 
						|
    currentFamilyName = nametable.getName(
 | 
						|
        NameID.TYPOGRAPHIC_FAMILY_NAME, *platform
 | 
						|
    ) or nametable.getName(NameID.FAMILY_NAME, *platform)
 | 
						|
 | 
						|
    currentStyleName = nametable.getName(
 | 
						|
        NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform
 | 
						|
    ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform)
 | 
						|
 | 
						|
    if not all([currentFamilyName, currentStyleName]):
 | 
						|
        raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}")
 | 
						|
 | 
						|
    currentFamilyName = currentFamilyName.toUnicode()
 | 
						|
    currentStyleName = currentStyleName.toUnicode()
 | 
						|
 | 
						|
    nameIDs = {
 | 
						|
        NameID.FAMILY_NAME: currentFamilyName,
 | 
						|
        NameID.SUBFAMILY_NAME: subFamilyName or "Regular",
 | 
						|
    }
 | 
						|
    if typoSubFamilyName:
 | 
						|
        nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip()
 | 
						|
        nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName
 | 
						|
        nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName
 | 
						|
    else:
 | 
						|
        # Remove previous Typographic Family and SubFamily names since they're
 | 
						|
        # no longer required
 | 
						|
        for nameID in (
 | 
						|
            NameID.TYPOGRAPHIC_FAMILY_NAME,
 | 
						|
            NameID.TYPOGRAPHIC_SUBFAMILY_NAME,
 | 
						|
        ):
 | 
						|
            nametable.removeNames(nameID=nameID)
 | 
						|
 | 
						|
    newFamilyName = (
 | 
						|
        nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME]
 | 
						|
    )
 | 
						|
    newStyleName = (
 | 
						|
        nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME]
 | 
						|
    )
 | 
						|
 | 
						|
    nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}"
 | 
						|
    nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord(
 | 
						|
        varfont, newFamilyName, newStyleName, platform
 | 
						|
    )
 | 
						|
 | 
						|
    uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform)
 | 
						|
    if uniqueID:
 | 
						|
        nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID
 | 
						|
 | 
						|
    for nameID, string in nameIDs.items():
 | 
						|
        assert string, nameID
 | 
						|
        nametable.setName(string, nameID, *platform)
 | 
						|
 | 
						|
    if "fvar" not in varfont:
 | 
						|
        nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX)
 | 
						|
 | 
						|
 | 
						|
def _updatePSNameRecord(varfont, familyName, styleName, platform):
 | 
						|
    # Implementation based on Adobe Technical Note #5902 :
 | 
						|
    # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf
 | 
						|
    nametable = varfont["name"]
 | 
						|
 | 
						|
    family_prefix = nametable.getName(
 | 
						|
        NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform
 | 
						|
    )
 | 
						|
    if family_prefix:
 | 
						|
        family_prefix = family_prefix.toUnicode()
 | 
						|
    else:
 | 
						|
        family_prefix = familyName
 | 
						|
 | 
						|
    psName = f"{family_prefix}-{styleName}"
 | 
						|
    # Remove any characters other than uppercase Latin letters, lowercase
 | 
						|
    # Latin letters, digits and hyphens.
 | 
						|
    psName = re.sub(r"[^A-Za-z0-9-]", r"", psName)
 | 
						|
 | 
						|
    if len(psName) > 127:
 | 
						|
        # Abbreviating the stylename so it fits within 127 characters whilst
 | 
						|
        # conforming to every vendor's specification is too complex. Instead
 | 
						|
        # we simply truncate the psname and add the required "..."
 | 
						|
        return f"{psName[:124]}..."
 | 
						|
    return psName
 | 
						|
 | 
						|
 | 
						|
def _updateUniqueIdNameRecord(varfont, nameIDs, platform):
 | 
						|
    nametable = varfont["name"]
 | 
						|
    currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform)
 | 
						|
    if not currentRecord:
 | 
						|
        return None
 | 
						|
 | 
						|
    # Check if full name and postscript name are a substring of currentRecord
 | 
						|
    for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME):
 | 
						|
        nameRecord = nametable.getName(nameID, *platform)
 | 
						|
        if not nameRecord:
 | 
						|
            continue
 | 
						|
        if nameRecord.toUnicode() in currentRecord.toUnicode():
 | 
						|
            return currentRecord.toUnicode().replace(
 | 
						|
                nameRecord.toUnicode(), nameIDs[nameRecord.nameID]
 | 
						|
            )
 | 
						|
 | 
						|
    # Create a new string since we couldn't find any substrings.
 | 
						|
    fontVersion = _fontVersion(varfont, platform)
 | 
						|
    achVendID = varfont["OS/2"].achVendID
 | 
						|
    # Remove non-ASCII characers and trailing spaces
 | 
						|
    vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip()
 | 
						|
    psName = nameIDs[NameID.POSTSCRIPT_NAME]
 | 
						|
    return f"{fontVersion};{vendor};{psName}"
 | 
						|
 | 
						|
 | 
						|
def _fontVersion(font, platform=(3, 1, 0x409)):
 | 
						|
    nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform)
 | 
						|
    if nameRecord is None:
 | 
						|
        return f'{font["head"].fontRevision:.3f}'
 | 
						|
    # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101"
 | 
						|
    # Also works fine with inputs "Version 1.101" or "1.101" etc
 | 
						|
    versionNumber = nameRecord.toUnicode().split(";")[0]
 | 
						|
    return versionNumber.lstrip("Version ").strip()
 |