xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/instancer/names.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""Helpers for instantiating name table records."""
2
3from contextlib import contextmanager
4from copy import deepcopy
5from enum import IntEnum
6import re
7
8
9class NameID(IntEnum):
10    FAMILY_NAME = 1
11    SUBFAMILY_NAME = 2
12    UNIQUE_FONT_IDENTIFIER = 3
13    FULL_FONT_NAME = 4
14    VERSION_STRING = 5
15    POSTSCRIPT_NAME = 6
16    TYPOGRAPHIC_FAMILY_NAME = 16
17    TYPOGRAPHIC_SUBFAMILY_NAME = 17
18    VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25
19
20
21ELIDABLE_AXIS_VALUE_NAME = 2
22
23
24def getVariationNameIDs(varfont):
25    used = []
26    if "fvar" in varfont:
27        fvar = varfont["fvar"]
28        for axis in fvar.axes:
29            used.append(axis.axisNameID)
30        for instance in fvar.instances:
31            used.append(instance.subfamilyNameID)
32            if instance.postscriptNameID != 0xFFFF:
33                used.append(instance.postscriptNameID)
34    if "STAT" in varfont:
35        stat = varfont["STAT"].table
36        for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else ():
37            used.append(axis.AxisNameID)
38        for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else ():
39            used.append(value.ValueNameID)
40        elidedFallbackNameID = getattr(stat, "ElidedFallbackNameID", None)
41        if elidedFallbackNameID is not None:
42            used.append(elidedFallbackNameID)
43    # nameIDs <= 255 are reserved by OT spec so we don't touch them
44    return {nameID for nameID in used if nameID > 255}
45
46
47@contextmanager
48def pruningUnusedNames(varfont):
49    from . import log
50
51    origNameIDs = getVariationNameIDs(varfont)
52
53    yield
54
55    log.info("Pruning name table")
56    exclude = origNameIDs - getVariationNameIDs(varfont)
57    varfont["name"].names[:] = [
58        record for record in varfont["name"].names if record.nameID not in exclude
59    ]
60    if "ltag" in varfont:
61        # Drop the whole 'ltag' table if all the language-dependent Unicode name
62        # records that reference it have been dropped.
63        # TODO: Only prune unused ltag tags, renumerating langIDs accordingly.
64        # Note ltag can also be used by feat or morx tables, so check those too.
65        if not any(
66            record
67            for record in varfont["name"].names
68            if record.platformID == 0 and record.langID != 0xFFFF
69        ):
70            del varfont["ltag"]
71
72
73def updateNameTable(varfont, axisLimits):
74    """Update instatiated variable font's name table using STAT AxisValues.
75
76    Raises ValueError if the STAT table is missing or an Axis Value table is
77    missing for requested axis locations.
78
79    First, collect all STAT AxisValues that match the new default axis locations
80    (excluding "elided" ones); concatenate the strings in design axis order,
81    while giving priority to "synthetic" values (Format 4), to form the
82    typographic subfamily name associated with the new default instance.
83    Finally, update all related records in the name table, making sure that
84    legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic,
85    Bold, Bold Italic) naming model.
86
87    Example: Updating a partial variable font:
88    | >>> ttFont = TTFont("OpenSans[wdth,wght].ttf")
89    | >>> updateNameTable(ttFont, {"wght": (400, 900), "wdth": 75})
90
91    The name table records will be updated in the following manner:
92    NameID 1 familyName: "Open Sans" --> "Open Sans Condensed"
93    NameID 2 subFamilyName: "Regular" --> "Regular"
94    NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \
95        "3.000;GOOG;OpenSans-Condensed"
96    NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed"
97    NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed"
98    NameID 16 Typographic Family name: None --> "Open Sans"
99    NameID 17 Typographic Subfamily name: None --> "Condensed"
100
101    References:
102    https://docs.microsoft.com/en-us/typography/opentype/spec/stat
103    https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids
104    """
105    from . import AxisLimits, axisValuesFromAxisLimits
106
107    if "STAT" not in varfont:
108        raise ValueError("Cannot update name table since there is no STAT table.")
109    stat = varfont["STAT"].table
110    if not stat.AxisValueArray:
111        raise ValueError("Cannot update name table since there are no STAT Axis Values")
112    fvar = varfont["fvar"]
113
114    # The updated name table will reflect the new 'zero origin' of the font.
115    # If we're instantiating a partial font, we will populate the unpinned
116    # axes with their default axis values from fvar.
117    axisLimits = AxisLimits(axisLimits).limitAxesAndPopulateDefaults(varfont)
118    partialDefaults = axisLimits.defaultLocation()
119    fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes}
120    defaultAxisCoords = AxisLimits({**fvarDefaults, **partialDefaults})
121    assert all(v.minimum == v.maximum for v in defaultAxisCoords.values())
122
123    axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords)
124    checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords.pinnedLocation())
125
126    # ignore "elidable" axis values, should be omitted in application font menus.
127    axisValueTables = [
128        v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME
129    ]
130    axisValueTables = _sortAxisValues(axisValueTables)
131    _updateNameRecords(varfont, axisValueTables)
132
133
134def checkAxisValuesExist(stat, axisValues, axisCoords):
135    seen = set()
136    designAxes = stat.DesignAxisRecord.Axis
137    hasValues = set()
138    for value in stat.AxisValueArray.AxisValue:
139        if value.Format in (1, 2, 3):
140            hasValues.add(designAxes[value.AxisIndex].AxisTag)
141        elif value.Format == 4:
142            for rec in value.AxisValueRecord:
143                hasValues.add(designAxes[rec.AxisIndex].AxisTag)
144
145    for axisValueTable in axisValues:
146        axisValueFormat = axisValueTable.Format
147        if axisValueTable.Format in (1, 2, 3):
148            axisTag = designAxes[axisValueTable.AxisIndex].AxisTag
149            if axisValueFormat == 2:
150                axisValue = axisValueTable.NominalValue
151            else:
152                axisValue = axisValueTable.Value
153            if axisTag in axisCoords and axisValue == axisCoords[axisTag]:
154                seen.add(axisTag)
155        elif axisValueTable.Format == 4:
156            for rec in axisValueTable.AxisValueRecord:
157                axisTag = designAxes[rec.AxisIndex].AxisTag
158                if axisTag in axisCoords and rec.Value == axisCoords[axisTag]:
159                    seen.add(axisTag)
160
161    missingAxes = (set(axisCoords) - seen) & hasValues
162    if missingAxes:
163        missing = ", ".join(f"'{i}': {axisCoords[i]}" for i in missingAxes)
164        raise ValueError(f"Cannot find Axis Values {{{missing}}}")
165
166
167def _sortAxisValues(axisValues):
168    # Sort by axis index, remove duplicates and ensure that format 4 AxisValues
169    # are dominant.
170    # The MS Spec states: "if a format 1, format 2 or format 3 table has a
171    # (nominal) value used in a format 4 table that also has values for
172    # other axes, the format 4 table, being the more specific match, is used",
173    # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4
174    results = []
175    seenAxes = set()
176    # Sort format 4 axes so the tables with the most AxisValueRecords are first
177    format4 = sorted(
178        [v for v in axisValues if v.Format == 4],
179        key=lambda v: len(v.AxisValueRecord),
180        reverse=True,
181    )
182
183    for val in format4:
184        axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord)
185        minIndex = min(axisIndexes)
186        if not seenAxes & axisIndexes:
187            seenAxes |= axisIndexes
188            results.append((minIndex, val))
189
190    for val in axisValues:
191        if val in format4:
192            continue
193        axisIndex = val.AxisIndex
194        if axisIndex not in seenAxes:
195            seenAxes.add(axisIndex)
196            results.append((axisIndex, val))
197
198    return [axisValue for _, axisValue in sorted(results)]
199
200
201def _updateNameRecords(varfont, axisValues):
202    # Update nametable based on the axisValues using the R/I/B/BI model.
203    nametable = varfont["name"]
204    stat = varfont["STAT"].table
205
206    axisValueNameIDs = [a.ValueNameID for a in axisValues]
207    ribbiNameIDs = [n for n in axisValueNameIDs if _isRibbi(nametable, n)]
208    nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs]
209    elidedNameID = stat.ElidedFallbackNameID
210    elidedNameIsRibbi = _isRibbi(nametable, elidedNameID)
211
212    getName = nametable.getName
213    platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names)
214    for platform in platforms:
215        if not all(getName(i, *platform) for i in (1, 2, elidedNameID)):
216            # Since no family name and subfamily name records were found,
217            # we cannot update this set of name Records.
218            continue
219
220        subFamilyName = " ".join(
221            getName(n, *platform).toUnicode() for n in ribbiNameIDs
222        )
223        if nonRibbiNameIDs:
224            typoSubFamilyName = " ".join(
225                getName(n, *platform).toUnicode() for n in axisValueNameIDs
226            )
227        else:
228            typoSubFamilyName = None
229
230        # If neither subFamilyName and typographic SubFamilyName exist,
231        # we will use the STAT's elidedFallbackName
232        if not typoSubFamilyName and not subFamilyName:
233            if elidedNameIsRibbi:
234                subFamilyName = getName(elidedNameID, *platform).toUnicode()
235            else:
236                typoSubFamilyName = getName(elidedNameID, *platform).toUnicode()
237
238        familyNameSuffix = " ".join(
239            getName(n, *platform).toUnicode() for n in nonRibbiNameIDs
240        )
241
242        _updateNameTableStyleRecords(
243            varfont,
244            familyNameSuffix,
245            subFamilyName,
246            typoSubFamilyName,
247            *platform,
248        )
249
250
251def _isRibbi(nametable, nameID):
252    englishRecord = nametable.getName(nameID, 3, 1, 0x409)
253    return (
254        True
255        if englishRecord is not None
256        and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic")
257        else False
258    )
259
260
261def _updateNameTableStyleRecords(
262    varfont,
263    familyNameSuffix,
264    subFamilyName,
265    typoSubFamilyName,
266    platformID=3,
267    platEncID=1,
268    langID=0x409,
269):
270    # TODO (Marc F) It may be nice to make this part a standalone
271    # font renamer in the future.
272    nametable = varfont["name"]
273    platform = (platformID, platEncID, langID)
274
275    currentFamilyName = nametable.getName(
276        NameID.TYPOGRAPHIC_FAMILY_NAME, *platform
277    ) or nametable.getName(NameID.FAMILY_NAME, *platform)
278
279    currentStyleName = nametable.getName(
280        NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform
281    ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform)
282
283    if not all([currentFamilyName, currentStyleName]):
284        raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}")
285
286    currentFamilyName = currentFamilyName.toUnicode()
287    currentStyleName = currentStyleName.toUnicode()
288
289    nameIDs = {
290        NameID.FAMILY_NAME: currentFamilyName,
291        NameID.SUBFAMILY_NAME: subFamilyName or "Regular",
292    }
293    if typoSubFamilyName:
294        nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip()
295        nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName
296        nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName
297    else:
298        # Remove previous Typographic Family and SubFamily names since they're
299        # no longer required
300        for nameID in (
301            NameID.TYPOGRAPHIC_FAMILY_NAME,
302            NameID.TYPOGRAPHIC_SUBFAMILY_NAME,
303        ):
304            nametable.removeNames(nameID=nameID)
305
306    newFamilyName = (
307        nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME]
308    )
309    newStyleName = (
310        nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME]
311    )
312
313    nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}"
314    nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord(
315        varfont, newFamilyName, newStyleName, platform
316    )
317
318    uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform)
319    if uniqueID:
320        nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID
321
322    for nameID, string in nameIDs.items():
323        assert string, nameID
324        nametable.setName(string, nameID, *platform)
325
326    if "fvar" not in varfont:
327        nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX)
328
329
330def _updatePSNameRecord(varfont, familyName, styleName, platform):
331    # Implementation based on Adobe Technical Note #5902 :
332    # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf
333    nametable = varfont["name"]
334
335    family_prefix = nametable.getName(
336        NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform
337    )
338    if family_prefix:
339        family_prefix = family_prefix.toUnicode()
340    else:
341        family_prefix = familyName
342
343    psName = f"{family_prefix}-{styleName}"
344    # Remove any characters other than uppercase Latin letters, lowercase
345    # Latin letters, digits and hyphens.
346    psName = re.sub(r"[^A-Za-z0-9-]", r"", psName)
347
348    if len(psName) > 127:
349        # Abbreviating the stylename so it fits within 127 characters whilst
350        # conforming to every vendor's specification is too complex. Instead
351        # we simply truncate the psname and add the required "..."
352        return f"{psName[:124]}..."
353    return psName
354
355
356def _updateUniqueIdNameRecord(varfont, nameIDs, platform):
357    nametable = varfont["name"]
358    currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform)
359    if not currentRecord:
360        return None
361
362    # Check if full name and postscript name are a substring of currentRecord
363    for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME):
364        nameRecord = nametable.getName(nameID, *platform)
365        if not nameRecord:
366            continue
367        if nameRecord.toUnicode() in currentRecord.toUnicode():
368            return currentRecord.toUnicode().replace(
369                nameRecord.toUnicode(), nameIDs[nameRecord.nameID]
370            )
371
372    # Create a new string since we couldn't find any substrings.
373    fontVersion = _fontVersion(varfont, platform)
374    achVendID = varfont["OS/2"].achVendID
375    # Remove non-ASCII characers and trailing spaces
376    vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip()
377    psName = nameIDs[NameID.POSTSCRIPT_NAME]
378    return f"{fontVersion};{vendor};{psName}"
379
380
381def _fontVersion(font, platform=(3, 1, 0x409)):
382    nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform)
383    if nameRecord is None:
384        return f'{font["head"].fontRevision:.3f}'
385    # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101"
386    # Also works fine with inputs "Version 1.101" or "1.101" etc
387    versionNumber = nameRecord.toUnicode().split(";")[0]
388    return versionNumber.lstrip("Version ").strip()
389