1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283 |
- from __future__ import annotations
- import collections
- import copy
- import itertools
- import math
- import os
- import posixpath
- from io import BytesIO, StringIO
- from textwrap import indent
- from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union, cast
- from fontTools.misc import etree as ET
- from fontTools.misc import plistlib
- from fontTools.misc.loggingTools import LogMixin
- from fontTools.misc.textTools import tobytes, tostr
- """
- designSpaceDocument
- - read and write designspace files
- """
- __all__ = [
- "AxisDescriptor",
- "AxisLabelDescriptor",
- "AxisMappingDescriptor",
- "BaseDocReader",
- "BaseDocWriter",
- "DesignSpaceDocument",
- "DesignSpaceDocumentError",
- "DiscreteAxisDescriptor",
- "InstanceDescriptor",
- "LocationLabelDescriptor",
- "RangeAxisSubsetDescriptor",
- "RuleDescriptor",
- "SourceDescriptor",
- "ValueAxisSubsetDescriptor",
- "VariableFontDescriptor",
- ]
- # ElementTree allows to find namespace-prefixed elements, but not attributes
- # so we have to do it ourselves for 'xml:lang'
- XML_NS = "{http://www.w3.org/XML/1998/namespace}"
- XML_LANG = XML_NS + "lang"
- def posix(path):
- """Normalize paths using forward slash to work also on Windows."""
- new_path = posixpath.join(*path.split(os.path.sep))
- if path.startswith("/"):
- # The above transformation loses absolute paths
- new_path = "/" + new_path
- elif path.startswith(r"\\"):
- # The above transformation loses leading slashes of UNC path mounts
- new_path = "//" + new_path
- return new_path
- def posixpath_property(private_name):
- """Generate a propery that holds a path always using forward slashes."""
- def getter(self):
- # Normal getter
- return getattr(self, private_name)
- def setter(self, value):
- # The setter rewrites paths using forward slashes
- if value is not None:
- value = posix(value)
- setattr(self, private_name, value)
- return property(getter, setter)
- class DesignSpaceDocumentError(Exception):
- def __init__(self, msg, obj=None):
- self.msg = msg
- self.obj = obj
- def __str__(self):
- return str(self.msg) + (": %r" % self.obj if self.obj is not None else "")
- class AsDictMixin(object):
- def asdict(self):
- d = {}
- for attr, value in self.__dict__.items():
- if attr.startswith("_"):
- continue
- if hasattr(value, "asdict"):
- value = value.asdict()
- elif isinstance(value, list):
- value = [v.asdict() if hasattr(v, "asdict") else v for v in value]
- d[attr] = value
- return d
- class SimpleDescriptor(AsDictMixin):
- """Containers for a bunch of attributes"""
- # XXX this is ugly. The 'print' is inappropriate here, and instead of
- # assert, it should simply return True/False
- def compare(self, other):
- # test if this object contains the same data as the other
- for attr in self._attrs:
- try:
- assert getattr(self, attr) == getattr(other, attr)
- except AssertionError:
- print(
- "failed attribute",
- attr,
- getattr(self, attr),
- "!=",
- getattr(other, attr),
- )
- def __repr__(self):
- attrs = [f"{a}={repr(getattr(self, a))}," for a in self._attrs]
- attrs = indent("\n".join(attrs), " ")
- return f"{self.__class__.__name__}(\n{attrs}\n)"
- class SourceDescriptor(SimpleDescriptor):
- """Simple container for data related to the source
- .. code:: python
- doc = DesignSpaceDocument()
- s1 = SourceDescriptor()
- s1.path = masterPath1
- s1.name = "master.ufo1"
- s1.font = defcon.Font("master.ufo1")
- s1.location = dict(weight=0)
- s1.familyName = "MasterFamilyName"
- s1.styleName = "MasterStyleNameOne"
- s1.localisedFamilyName = dict(fr="Caractère")
- s1.mutedGlyphNames.append("A")
- s1.mutedGlyphNames.append("Z")
- doc.addSource(s1)
- """
- flavor = "source"
- _attrs = [
- "filename",
- "path",
- "name",
- "layerName",
- "location",
- "copyLib",
- "copyGroups",
- "copyFeatures",
- "muteKerning",
- "muteInfo",
- "mutedGlyphNames",
- "familyName",
- "styleName",
- "localisedFamilyName",
- ]
- filename = posixpath_property("_filename")
- path = posixpath_property("_path")
- def __init__(
- self,
- *,
- filename=None,
- path=None,
- font=None,
- name=None,
- location=None,
- designLocation=None,
- layerName=None,
- familyName=None,
- styleName=None,
- localisedFamilyName=None,
- copyLib=False,
- copyInfo=False,
- copyGroups=False,
- copyFeatures=False,
- muteKerning=False,
- muteInfo=False,
- mutedGlyphNames=None,
- ):
- self.filename = filename
- """string. A relative path to the source file, **as it is in the document**.
- MutatorMath + VarLib.
- """
- self.path = path
- """The absolute path, calculated from filename."""
- self.font = font
- """Any Python object. Optional. Points to a representation of this
- source font that is loaded in memory, as a Python object (e.g. a
- ``defcon.Font`` or a ``fontTools.ttFont.TTFont``).
- The default document reader will not fill-in this attribute, and the
- default writer will not use this attribute. It is up to the user of
- ``designspaceLib`` to either load the resource identified by
- ``filename`` and store it in this field, or write the contents of
- this field to the disk and make ```filename`` point to that.
- """
- self.name = name
- """string. Optional. Unique identifier name for this source.
- MutatorMath + varLib.
- """
- self.designLocation = (
- designLocation if designLocation is not None else location or {}
- )
- """dict. Axis values for this source, in design space coordinates.
- MutatorMath + varLib.
- This may be only part of the full design location.
- See :meth:`getFullDesignLocation()`
- .. versionadded:: 5.0
- """
- self.layerName = layerName
- """string. The name of the layer in the source to look for
- outline data. Default ``None`` which means ``foreground``.
- """
- self.familyName = familyName
- """string. Family name of this source. Though this data
- can be extracted from the font, it can be efficient to have it right
- here.
- varLib.
- """
- self.styleName = styleName
- """string. Style name of this source. Though this data
- can be extracted from the font, it can be efficient to have it right
- here.
- varLib.
- """
- self.localisedFamilyName = localisedFamilyName or {}
- """dict. A dictionary of localised family name strings, keyed by
- language code.
- If present, will be used to build localized names for all instances.
- .. versionadded:: 5.0
- """
- self.copyLib = copyLib
- """bool. Indicates if the contents of the font.lib need to
- be copied to the instances.
- MutatorMath.
- .. deprecated:: 5.0
- """
- self.copyInfo = copyInfo
- """bool. Indicates if the non-interpolating font.info needs
- to be copied to the instances.
- MutatorMath.
- .. deprecated:: 5.0
- """
- self.copyGroups = copyGroups
- """bool. Indicates if the groups need to be copied to the
- instances.
- MutatorMath.
- .. deprecated:: 5.0
- """
- self.copyFeatures = copyFeatures
- """bool. Indicates if the feature text needs to be
- copied to the instances.
- MutatorMath.
- .. deprecated:: 5.0
- """
- self.muteKerning = muteKerning
- """bool. Indicates if the kerning data from this source
- needs to be muted (i.e. not be part of the calculations).
- MutatorMath only.
- """
- self.muteInfo = muteInfo
- """bool. Indicated if the interpolating font.info data for
- this source needs to be muted.
- MutatorMath only.
- """
- self.mutedGlyphNames = mutedGlyphNames or []
- """list. Glyphnames that need to be muted in the
- instances.
- MutatorMath only.
- """
- @property
- def location(self):
- """dict. Axis values for this source, in design space coordinates.
- MutatorMath + varLib.
- .. deprecated:: 5.0
- Use the more explicit alias for this property :attr:`designLocation`.
- """
- return self.designLocation
- @location.setter
- def location(self, location: Optional[AnisotropicLocationDict]):
- self.designLocation = location or {}
- def setFamilyName(self, familyName, languageCode="en"):
- """Setter for :attr:`localisedFamilyName`
- .. versionadded:: 5.0
- """
- self.localisedFamilyName[languageCode] = tostr(familyName)
- def getFamilyName(self, languageCode="en"):
- """Getter for :attr:`localisedFamilyName`
- .. versionadded:: 5.0
- """
- return self.localisedFamilyName.get(languageCode)
- def getFullDesignLocation(
- self, doc: "DesignSpaceDocument"
- ) -> AnisotropicLocationDict:
- """Get the complete design location of this source, from its
- :attr:`designLocation` and the document's axis defaults.
- .. versionadded:: 5.0
- """
- result: AnisotropicLocationDict = {}
- for axis in doc.axes:
- if axis.name in self.designLocation:
- result[axis.name] = self.designLocation[axis.name]
- else:
- result[axis.name] = axis.map_forward(axis.default)
- return result
- class RuleDescriptor(SimpleDescriptor):
- """Represents the rule descriptor element: a set of glyph substitutions to
- trigger conditionally in some parts of the designspace.
- .. code:: python
- r1 = RuleDescriptor()
- r1.name = "unique.rule.name"
- r1.conditionSets.append([dict(name="weight", minimum=-10, maximum=10), dict(...)])
- r1.conditionSets.append([dict(...), dict(...)])
- r1.subs.append(("a", "a.alt"))
- .. code:: xml
- <!-- optional: list of substitution rules -->
- <rules>
- <rule name="vertical.bars">
- <conditionset>
- <condition minimum="250.000000" maximum="750.000000" name="weight"/>
- <condition minimum="100" name="width"/>
- <condition minimum="10" maximum="40" name="optical"/>
- </conditionset>
- <sub name="cent" with="cent.alt"/>
- <sub name="dollar" with="dollar.alt"/>
- </rule>
- </rules>
- """
- _attrs = ["name", "conditionSets", "subs"] # what do we need here
- def __init__(self, *, name=None, conditionSets=None, subs=None):
- self.name = name
- """string. Unique name for this rule. Can be used to reference this rule data."""
- # list of lists of dict(name='aaaa', minimum=0, maximum=1000)
- self.conditionSets = conditionSets or []
- """a list of conditionsets.
- - Each conditionset is a list of conditions.
- - Each condition is a dict with ``name``, ``minimum`` and ``maximum`` keys.
- """
- # list of substitutions stored as tuples of glyphnames ("a", "a.alt")
- self.subs = subs or []
- """list of substitutions.
- - Each substitution is stored as tuples of glyphnames, e.g. ("a", "a.alt").
- - Note: By default, rules are applied first, before other text
- shaping/OpenType layout, as they are part of the
- `Required Variation Alternates OpenType feature <https://docs.microsoft.com/en-us/typography/opentype/spec/features_pt#-tag-rvrn>`_.
- See ref:`rules-element` § Attributes.
- """
- def evaluateRule(rule, location):
- """Return True if any of the rule's conditionsets matches the given location."""
- return any(evaluateConditions(c, location) for c in rule.conditionSets)
- def evaluateConditions(conditions, location):
- """Return True if all the conditions matches the given location.
- - If a condition has no minimum, check for < maximum.
- - If a condition has no maximum, check for > minimum.
- """
- for cd in conditions:
- value = location[cd["name"]]
- if cd.get("minimum") is None:
- if value > cd["maximum"]:
- return False
- elif cd.get("maximum") is None:
- if cd["minimum"] > value:
- return False
- elif not cd["minimum"] <= value <= cd["maximum"]:
- return False
- return True
- def processRules(rules, location, glyphNames):
- """Apply these rules at this location to these glyphnames.
- Return a new list of glyphNames with substitutions applied.
- - rule order matters
- """
- newNames = []
- for rule in rules:
- if evaluateRule(rule, location):
- for name in glyphNames:
- swap = False
- for a, b in rule.subs:
- if name == a:
- swap = True
- break
- if swap:
- newNames.append(b)
- else:
- newNames.append(name)
- glyphNames = newNames
- newNames = []
- return glyphNames
- AnisotropicLocationDict = Dict[str, Union[float, Tuple[float, float]]]
- SimpleLocationDict = Dict[str, float]
- class AxisMappingDescriptor(SimpleDescriptor):
- """Represents the axis mapping element: mapping an input location
- to an output location in the designspace.
- .. code:: python
- m1 = AxisMappingDescriptor()
- m1.inputLocation = {"weight": 900, "width": 150}
- m1.outputLocation = {"weight": 870}
- .. code:: xml
- <mappings>
- <mapping>
- <input>
- <dimension name="weight" xvalue="900"/>
- <dimension name="width" xvalue="150"/>
- </input>
- <output>
- <dimension name="weight" xvalue="870"/>
- </output>
- </mapping>
- </mappings>
- """
- _attrs = ["inputLocation", "outputLocation"]
- def __init__(self, *, inputLocation=None, outputLocation=None):
- self.inputLocation: SimpleLocationDict = inputLocation or {}
- """dict. Axis values for the input of the mapping, in design space coordinates.
- varLib.
- .. versionadded:: 5.1
- """
- self.outputLocation: SimpleLocationDict = outputLocation or {}
- """dict. Axis values for the output of the mapping, in design space coordinates.
- varLib.
- .. versionadded:: 5.1
- """
- class InstanceDescriptor(SimpleDescriptor):
- """Simple container for data related to the instance
- .. code:: python
- i2 = InstanceDescriptor()
- i2.path = instancePath2
- i2.familyName = "InstanceFamilyName"
- i2.styleName = "InstanceStyleName"
- i2.name = "instance.ufo2"
- # anisotropic location
- i2.designLocation = dict(weight=500, width=(400,300))
- i2.postScriptFontName = "InstancePostscriptName"
- i2.styleMapFamilyName = "InstanceStyleMapFamilyName"
- i2.styleMapStyleName = "InstanceStyleMapStyleName"
- i2.lib['com.coolDesignspaceApp.specimenText'] = 'Hamburgerwhatever'
- doc.addInstance(i2)
- """
- flavor = "instance"
- _defaultLanguageCode = "en"
- _attrs = [
- "filename",
- "path",
- "name",
- "locationLabel",
- "designLocation",
- "userLocation",
- "familyName",
- "styleName",
- "postScriptFontName",
- "styleMapFamilyName",
- "styleMapStyleName",
- "localisedFamilyName",
- "localisedStyleName",
- "localisedStyleMapFamilyName",
- "localisedStyleMapStyleName",
- "glyphs",
- "kerning",
- "info",
- "lib",
- ]
- filename = posixpath_property("_filename")
- path = posixpath_property("_path")
- def __init__(
- self,
- *,
- filename=None,
- path=None,
- font=None,
- name=None,
- location=None,
- locationLabel=None,
- designLocation=None,
- userLocation=None,
- familyName=None,
- styleName=None,
- postScriptFontName=None,
- styleMapFamilyName=None,
- styleMapStyleName=None,
- localisedFamilyName=None,
- localisedStyleName=None,
- localisedStyleMapFamilyName=None,
- localisedStyleMapStyleName=None,
- glyphs=None,
- kerning=True,
- info=True,
- lib=None,
- ):
- self.filename = filename
- """string. Relative path to the instance file, **as it is
- in the document**. The file may or may not exist.
- MutatorMath + VarLib.
- """
- self.path = path
- """string. Absolute path to the instance file, calculated from
- the document path and the string in the filename attr. The file may
- or may not exist.
- MutatorMath.
- """
- self.font = font
- """Same as :attr:`SourceDescriptor.font`
- .. seealso:: :attr:`SourceDescriptor.font`
- """
- self.name = name
- """string. Unique identifier name of the instance, used to
- identify it if it needs to be referenced from elsewhere in the
- document.
- """
- self.locationLabel = locationLabel
- """Name of a :class:`LocationLabelDescriptor`. If
- provided, the instance should have the same location as the
- LocationLabel.
- .. seealso::
- :meth:`getFullDesignLocation`
- :meth:`getFullUserLocation`
- .. versionadded:: 5.0
- """
- self.designLocation: AnisotropicLocationDict = (
- designLocation if designLocation is not None else (location or {})
- )
- """dict. Axis values for this instance, in design space coordinates.
- MutatorMath + varLib.
- .. seealso:: This may be only part of the full location. See:
- :meth:`getFullDesignLocation`
- :meth:`getFullUserLocation`
- .. versionadded:: 5.0
- """
- self.userLocation: SimpleLocationDict = userLocation or {}
- """dict. Axis values for this instance, in user space coordinates.
- MutatorMath + varLib.
- .. seealso:: This may be only part of the full location. See:
- :meth:`getFullDesignLocation`
- :meth:`getFullUserLocation`
- .. versionadded:: 5.0
- """
- self.familyName = familyName
- """string. Family name of this instance.
- MutatorMath + varLib.
- """
- self.styleName = styleName
- """string. Style name of this instance.
- MutatorMath + varLib.
- """
- self.postScriptFontName = postScriptFontName
- """string. Postscript fontname for this instance.
- MutatorMath + varLib.
- """
- self.styleMapFamilyName = styleMapFamilyName
- """string. StyleMap familyname for this instance.
- MutatorMath + varLib.
- """
- self.styleMapStyleName = styleMapStyleName
- """string. StyleMap stylename for this instance.
- MutatorMath + varLib.
- """
- self.localisedFamilyName = localisedFamilyName or {}
- """dict. A dictionary of localised family name
- strings, keyed by language code.
- """
- self.localisedStyleName = localisedStyleName or {}
- """dict. A dictionary of localised stylename
- strings, keyed by language code.
- """
- self.localisedStyleMapFamilyName = localisedStyleMapFamilyName or {}
- """A dictionary of localised style map
- familyname strings, keyed by language code.
- """
- self.localisedStyleMapStyleName = localisedStyleMapStyleName or {}
- """A dictionary of localised style map
- stylename strings, keyed by language code.
- """
- self.glyphs = glyphs or {}
- """dict for special master definitions for glyphs. If glyphs
- need special masters (to record the results of executed rules for
- example).
- MutatorMath.
- .. deprecated:: 5.0
- Use rules or sparse sources instead.
- """
- self.kerning = kerning
- """ bool. Indicates if this instance needs its kerning
- calculated.
- MutatorMath.
- .. deprecated:: 5.0
- """
- self.info = info
- """bool. Indicated if this instance needs the interpolating
- font.info calculated.
- .. deprecated:: 5.0
- """
- self.lib = lib or {}
- """Custom data associated with this instance."""
- @property
- def location(self):
- """dict. Axis values for this instance.
- MutatorMath + varLib.
- .. deprecated:: 5.0
- Use the more explicit alias for this property :attr:`designLocation`.
- """
- return self.designLocation
- @location.setter
- def location(self, location: Optional[AnisotropicLocationDict]):
- self.designLocation = location or {}
- def setStyleName(self, styleName, languageCode="en"):
- """These methods give easier access to the localised names."""
- self.localisedStyleName[languageCode] = tostr(styleName)
- def getStyleName(self, languageCode="en"):
- return self.localisedStyleName.get(languageCode)
- def setFamilyName(self, familyName, languageCode="en"):
- self.localisedFamilyName[languageCode] = tostr(familyName)
- def getFamilyName(self, languageCode="en"):
- return self.localisedFamilyName.get(languageCode)
- def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"):
- self.localisedStyleMapStyleName[languageCode] = tostr(styleMapStyleName)
- def getStyleMapStyleName(self, languageCode="en"):
- return self.localisedStyleMapStyleName.get(languageCode)
- def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"):
- self.localisedStyleMapFamilyName[languageCode] = tostr(styleMapFamilyName)
- def getStyleMapFamilyName(self, languageCode="en"):
- return self.localisedStyleMapFamilyName.get(languageCode)
- def clearLocation(self, axisName: Optional[str] = None):
- """Clear all location-related fields. Ensures that
- :attr:``designLocation`` and :attr:``userLocation`` are dictionaries
- (possibly empty if clearing everything).
- In order to update the location of this instance wholesale, a user
- should first clear all the fields, then change the field(s) for which
- they have data.
- .. code:: python
- instance.clearLocation()
- instance.designLocation = {'Weight': (34, 36.5), 'Width': 100}
- instance.userLocation = {'Opsz': 16}
- In order to update a single axis location, the user should only clear
- that axis, then edit the values:
- .. code:: python
- instance.clearLocation('Weight')
- instance.designLocation['Weight'] = (34, 36.5)
- Args:
- axisName: if provided, only clear the location for that axis.
- .. versionadded:: 5.0
- """
- self.locationLabel = None
- if axisName is None:
- self.designLocation = {}
- self.userLocation = {}
- else:
- if self.designLocation is None:
- self.designLocation = {}
- if axisName in self.designLocation:
- del self.designLocation[axisName]
- if self.userLocation is None:
- self.userLocation = {}
- if axisName in self.userLocation:
- del self.userLocation[axisName]
- def getLocationLabelDescriptor(
- self, doc: "DesignSpaceDocument"
- ) -> Optional[LocationLabelDescriptor]:
- """Get the :class:`LocationLabelDescriptor` instance that matches
- this instances's :attr:`locationLabel`.
- Raises if the named label can't be found.
- .. versionadded:: 5.0
- """
- if self.locationLabel is None:
- return None
- label = doc.getLocationLabel(self.locationLabel)
- if label is None:
- raise DesignSpaceDocumentError(
- "InstanceDescriptor.getLocationLabelDescriptor(): "
- f"unknown location label `{self.locationLabel}` in instance `{self.name}`."
- )
- return label
- def getFullDesignLocation(
- self, doc: "DesignSpaceDocument"
- ) -> AnisotropicLocationDict:
- """Get the complete design location of this instance, by combining data
- from the various location fields, default axis values and mappings, and
- top-level location labels.
- The source of truth for this instance's location is determined for each
- axis independently by taking the first not-None field in this list:
- - ``locationLabel``: the location along this axis is the same as the
- matching STAT format 4 label. No anisotropy.
- - ``designLocation[axisName]``: the explicit design location along this
- axis, possibly anisotropic.
- - ``userLocation[axisName]``: the explicit user location along this
- axis. No anisotropy.
- - ``axis.default``: default axis value. No anisotropy.
- .. versionadded:: 5.0
- """
- label = self.getLocationLabelDescriptor(doc)
- if label is not None:
- return doc.map_forward(label.userLocation) # type: ignore
- result: AnisotropicLocationDict = {}
- for axis in doc.axes:
- if axis.name in self.designLocation:
- result[axis.name] = self.designLocation[axis.name]
- elif axis.name in self.userLocation:
- result[axis.name] = axis.map_forward(self.userLocation[axis.name])
- else:
- result[axis.name] = axis.map_forward(axis.default)
- return result
- def getFullUserLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict:
- """Get the complete user location for this instance.
- .. seealso:: :meth:`getFullDesignLocation`
- .. versionadded:: 5.0
- """
- return doc.map_backward(self.getFullDesignLocation(doc))
- def tagForAxisName(name):
- # try to find or make a tag name for this axis name
- names = {
- "weight": ("wght", dict(en="Weight")),
- "width": ("wdth", dict(en="Width")),
- "optical": ("opsz", dict(en="Optical Size")),
- "slant": ("slnt", dict(en="Slant")),
- "italic": ("ital", dict(en="Italic")),
- }
- if name.lower() in names:
- return names[name.lower()]
- if len(name) < 4:
- tag = name + "*" * (4 - len(name))
- else:
- tag = name[:4]
- return tag, dict(en=name)
- class AbstractAxisDescriptor(SimpleDescriptor):
- flavor = "axis"
- def __init__(
- self,
- *,
- tag=None,
- name=None,
- labelNames=None,
- hidden=False,
- map=None,
- axisOrdering=None,
- axisLabels=None,
- ):
- # opentype tag for this axis
- self.tag = tag
- """string. Four letter tag for this axis. Some might be
- registered at the `OpenType
- specification <https://www.microsoft.com/typography/otspec/fvar.htm#VAT>`__.
- Privately-defined axis tags must begin with an uppercase letter and
- use only uppercase letters or digits.
- """
- # name of the axis used in locations
- self.name = name
- """string. Name of the axis as it is used in the location dicts.
- MutatorMath + varLib.
- """
- # names for UI purposes, if this is not a standard axis,
- self.labelNames = labelNames or {}
- """dict. When defining a non-registered axis, it will be
- necessary to define user-facing readable names for the axis. Keyed by
- xml:lang code. Values are required to be ``unicode`` strings, even if
- they only contain ASCII characters.
- """
- self.hidden = hidden
- """bool. Whether this axis should be hidden in user interfaces.
- """
- self.map = map or []
- """list of input / output values that can describe a warp of user space
- to design space coordinates. If no map values are present, it is assumed
- user space is the same as design space, as in [(minimum, minimum),
- (maximum, maximum)].
- varLib.
- """
- self.axisOrdering = axisOrdering
- """STAT table field ``axisOrdering``.
- See: `OTSpec STAT Axis Record <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-records>`_
- .. versionadded:: 5.0
- """
- self.axisLabels: List[AxisLabelDescriptor] = axisLabels or []
- """STAT table entries for Axis Value Tables format 1, 2, 3.
- See: `OTSpec STAT Axis Value Tables <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-tables>`_
- .. versionadded:: 5.0
- """
- class AxisDescriptor(AbstractAxisDescriptor):
- """Simple container for the axis data.
- Add more localisations?
- .. code:: python
- a1 = AxisDescriptor()
- a1.minimum = 1
- a1.maximum = 1000
- a1.default = 400
- a1.name = "weight"
- a1.tag = "wght"
- a1.labelNames['fa-IR'] = "قطر"
- a1.labelNames['en'] = "Wéíght"
- a1.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)]
- a1.axisOrdering = 1
- a1.axisLabels = [
- AxisLabelDescriptor(name="Regular", userValue=400, elidable=True)
- ]
- doc.addAxis(a1)
- """
- _attrs = [
- "tag",
- "name",
- "maximum",
- "minimum",
- "default",
- "map",
- "axisOrdering",
- "axisLabels",
- ]
- def __init__(
- self,
- *,
- tag=None,
- name=None,
- labelNames=None,
- minimum=None,
- default=None,
- maximum=None,
- hidden=False,
- map=None,
- axisOrdering=None,
- axisLabels=None,
- ):
- super().__init__(
- tag=tag,
- name=name,
- labelNames=labelNames,
- hidden=hidden,
- map=map,
- axisOrdering=axisOrdering,
- axisLabels=axisLabels,
- )
- self.minimum = minimum
- """number. The minimum value for this axis in user space.
- MutatorMath + varLib.
- """
- self.maximum = maximum
- """number. The maximum value for this axis in user space.
- MutatorMath + varLib.
- """
- self.default = default
- """number. The default value for this axis, i.e. when a new location is
- created, this is the value this axis will get in user space.
- MutatorMath + varLib.
- """
- def serialize(self):
- # output to a dict, used in testing
- return dict(
- tag=self.tag,
- name=self.name,
- labelNames=self.labelNames,
- maximum=self.maximum,
- minimum=self.minimum,
- default=self.default,
- hidden=self.hidden,
- map=self.map,
- axisOrdering=self.axisOrdering,
- axisLabels=self.axisLabels,
- )
- def map_forward(self, v):
- """Maps value from axis mapping's input (user) to output (design)."""
- from fontTools.varLib.models import piecewiseLinearMap
- if not self.map:
- return v
- return piecewiseLinearMap(v, {k: v for k, v in self.map})
- def map_backward(self, v):
- """Maps value from axis mapping's output (design) to input (user)."""
- from fontTools.varLib.models import piecewiseLinearMap
- if isinstance(v, tuple):
- v = v[0]
- if not self.map:
- return v
- return piecewiseLinearMap(v, {v: k for k, v in self.map})
- class DiscreteAxisDescriptor(AbstractAxisDescriptor):
- """Container for discrete axis data.
- Use this for axes that do not interpolate. The main difference from a
- continuous axis is that a continuous axis has a ``minimum`` and ``maximum``,
- while a discrete axis has a list of ``values``.
- Example: an Italic axis with 2 stops, Roman and Italic, that are not
- compatible. The axis still allows to bind together the full font family,
- which is useful for the STAT table, however it can't become a variation
- axis in a VF.
- .. code:: python
- a2 = DiscreteAxisDescriptor()
- a2.values = [0, 1]
- a2.default = 0
- a2.name = "Italic"
- a2.tag = "ITAL"
- a2.labelNames['fr'] = "Italique"
- a2.map = [(0, 0), (1, -11)]
- a2.axisOrdering = 2
- a2.axisLabels = [
- AxisLabelDescriptor(name="Roman", userValue=0, elidable=True)
- ]
- doc.addAxis(a2)
- .. versionadded:: 5.0
- """
- flavor = "axis"
- _attrs = ("tag", "name", "values", "default", "map", "axisOrdering", "axisLabels")
- def __init__(
- self,
- *,
- tag=None,
- name=None,
- labelNames=None,
- values=None,
- default=None,
- hidden=False,
- map=None,
- axisOrdering=None,
- axisLabels=None,
- ):
- super().__init__(
- tag=tag,
- name=name,
- labelNames=labelNames,
- hidden=hidden,
- map=map,
- axisOrdering=axisOrdering,
- axisLabels=axisLabels,
- )
- self.default: float = default
- """The default value for this axis, i.e. when a new location is
- created, this is the value this axis will get in user space.
- However, this default value is less important than in continuous axes:
- - it doesn't define the "neutral" version of outlines from which
- deltas would apply, as this axis does not interpolate.
- - it doesn't provide the reference glyph set for the designspace, as
- fonts at each value can have different glyph sets.
- """
- self.values: List[float] = values or []
- """List of possible values for this axis. Contrary to continuous axes,
- only the values in this list can be taken by the axis, nothing in-between.
- """
- def map_forward(self, value):
- """Maps value from axis mapping's input to output.
- Returns value unchanged if no mapping entry is found.
- Note: for discrete axes, each value must have its mapping entry, if
- you intend that value to be mapped.
- """
- return next((v for k, v in self.map if k == value), value)
- def map_backward(self, value):
- """Maps value from axis mapping's output to input.
- Returns value unchanged if no mapping entry is found.
- Note: for discrete axes, each value must have its mapping entry, if
- you intend that value to be mapped.
- """
- if isinstance(value, tuple):
- value = value[0]
- return next((k for k, v in self.map if v == value), value)
- class AxisLabelDescriptor(SimpleDescriptor):
- """Container for axis label data.
- Analogue of OpenType's STAT data for a single axis (formats 1, 2 and 3).
- All values are user values.
- See: `OTSpec STAT Axis value table, format 1, 2, 3 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-1>`_
- The STAT format of the Axis value depends on which field are filled-in,
- see :meth:`getFormat`
- .. versionadded:: 5.0
- """
- flavor = "label"
- _attrs = (
- "userMinimum",
- "userValue",
- "userMaximum",
- "name",
- "elidable",
- "olderSibling",
- "linkedUserValue",
- "labelNames",
- )
- def __init__(
- self,
- *,
- name,
- userValue,
- userMinimum=None,
- userMaximum=None,
- elidable=False,
- olderSibling=False,
- linkedUserValue=None,
- labelNames=None,
- ):
- self.userMinimum: Optional[float] = userMinimum
- """STAT field ``rangeMinValue`` (format 2)."""
- self.userValue: float = userValue
- """STAT field ``value`` (format 1, 3) or ``nominalValue`` (format 2)."""
- self.userMaximum: Optional[float] = userMaximum
- """STAT field ``rangeMaxValue`` (format 2)."""
- self.name: str = name
- """Label for this axis location, STAT field ``valueNameID``."""
- self.elidable: bool = elidable
- """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``.
- See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
- """
- self.olderSibling: bool = olderSibling
- """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``.
- See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
- """
- self.linkedUserValue: Optional[float] = linkedUserValue
- """STAT field ``linkedValue`` (format 3)."""
- self.labelNames: MutableMapping[str, str] = labelNames or {}
- """User-facing translations of this location's label. Keyed by
- ``xml:lang`` code.
- """
- def getFormat(self) -> int:
- """Determine which format of STAT Axis value to use to encode this label.
- =========== ========= =========== =========== ===============
- STAT Format userValue userMinimum userMaximum linkedUserValue
- =========== ========= =========== =========== ===============
- 1 ✅ ❌ ❌ ❌
- 2 ✅ ✅ ✅ ❌
- 3 ✅ ❌ ❌ ✅
- =========== ========= =========== =========== ===============
- """
- if self.linkedUserValue is not None:
- return 3
- if self.userMinimum is not None or self.userMaximum is not None:
- return 2
- return 1
- @property
- def defaultName(self) -> str:
- """Return the English name from :attr:`labelNames` or the :attr:`name`."""
- return self.labelNames.get("en") or self.name
- class LocationLabelDescriptor(SimpleDescriptor):
- """Container for location label data.
- Analogue of OpenType's STAT data for a free-floating location (format 4).
- All values are user values.
- See: `OTSpec STAT Axis value table, format 4 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4>`_
- .. versionadded:: 5.0
- """
- flavor = "label"
- _attrs = ("name", "elidable", "olderSibling", "userLocation", "labelNames")
- def __init__(
- self,
- *,
- name,
- userLocation,
- elidable=False,
- olderSibling=False,
- labelNames=None,
- ):
- self.name: str = name
- """Label for this named location, STAT field ``valueNameID``."""
- self.userLocation: SimpleLocationDict = userLocation or {}
- """Location in user coordinates along each axis.
- If an axis is not mentioned, it is assumed to be at its default location.
- .. seealso:: This may be only part of the full location. See:
- :meth:`getFullUserLocation`
- """
- self.elidable: bool = elidable
- """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``.
- See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
- """
- self.olderSibling: bool = olderSibling
- """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``.
- See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
- """
- self.labelNames: Dict[str, str] = labelNames or {}
- """User-facing translations of this location's label. Keyed by
- xml:lang code.
- """
- @property
- def defaultName(self) -> str:
- """Return the English name from :attr:`labelNames` or the :attr:`name`."""
- return self.labelNames.get("en") or self.name
- def getFullUserLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict:
- """Get the complete user location of this label, by combining data
- from the explicit user location and default axis values.
- .. versionadded:: 5.0
- """
- return {
- axis.name: self.userLocation.get(axis.name, axis.default)
- for axis in doc.axes
- }
- class VariableFontDescriptor(SimpleDescriptor):
- """Container for variable fonts, sub-spaces of the Designspace.
- Use-cases:
- - From a single DesignSpace with discrete axes, define 1 variable font
- per value on the discrete axes. Before version 5, you would have needed
- 1 DesignSpace per such variable font, and a lot of data duplication.
- - From a big variable font with many axes, define subsets of that variable
- font that only include some axes and freeze other axes at a given location.
- .. versionadded:: 5.0
- """
- flavor = "variable-font"
- _attrs = ("filename", "axisSubsets", "lib")
- filename = posixpath_property("_filename")
- def __init__(self, *, name, filename=None, axisSubsets=None, lib=None):
- self.name: str = name
- """string, required. Name of this variable to identify it during the
- build process and from other parts of the document, and also as a
- filename in case the filename property is empty.
- VarLib.
- """
- self.filename: str = filename
- """string, optional. Relative path to the variable font file, **as it is
- in the document**. The file may or may not exist.
- If not specified, the :attr:`name` will be used as a basename for the file.
- """
- self.axisSubsets: List[
- Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]
- ] = (axisSubsets or [])
- """Axis subsets to include in this variable font.
- If an axis is not mentioned, assume that we only want the default
- location of that axis (same as a :class:`ValueAxisSubsetDescriptor`).
- """
- self.lib: MutableMapping[str, Any] = lib or {}
- """Custom data associated with this variable font."""
- class RangeAxisSubsetDescriptor(SimpleDescriptor):
- """Subset of a continuous axis to include in a variable font.
- .. versionadded:: 5.0
- """
- flavor = "axis-subset"
- _attrs = ("name", "userMinimum", "userDefault", "userMaximum")
- def __init__(
- self, *, name, userMinimum=-math.inf, userDefault=None, userMaximum=math.inf
- ):
- self.name: str = name
- """Name of the :class:`AxisDescriptor` to subset."""
- self.userMinimum: float = userMinimum
- """New minimum value of the axis in the target variable font.
- If not specified, assume the same minimum value as the full axis.
- (default = ``-math.inf``)
- """
- self.userDefault: Optional[float] = userDefault
- """New default value of the axis in the target variable font.
- If not specified, assume the same default value as the full axis.
- (default = ``None``)
- """
- self.userMaximum: float = userMaximum
- """New maximum value of the axis in the target variable font.
- If not specified, assume the same maximum value as the full axis.
- (default = ``math.inf``)
- """
- class ValueAxisSubsetDescriptor(SimpleDescriptor):
- """Single value of a discrete or continuous axis to use in a variable font.
- .. versionadded:: 5.0
- """
- flavor = "axis-subset"
- _attrs = ("name", "userValue")
- def __init__(self, *, name, userValue):
- self.name: str = name
- """Name of the :class:`AxisDescriptor` or :class:`DiscreteAxisDescriptor`
- to "snapshot" or "freeze".
- """
- self.userValue: float = userValue
- """Value in user coordinates at which to freeze the given axis."""
- class BaseDocWriter(object):
- _whiteSpace = " "
- axisDescriptorClass = AxisDescriptor
- discreteAxisDescriptorClass = DiscreteAxisDescriptor
- axisLabelDescriptorClass = AxisLabelDescriptor
- axisMappingDescriptorClass = AxisMappingDescriptor
- locationLabelDescriptorClass = LocationLabelDescriptor
- ruleDescriptorClass = RuleDescriptor
- sourceDescriptorClass = SourceDescriptor
- variableFontDescriptorClass = VariableFontDescriptor
- valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor
- rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor
- instanceDescriptorClass = InstanceDescriptor
- @classmethod
- def getAxisDecriptor(cls):
- return cls.axisDescriptorClass()
- @classmethod
- def getAxisMappingDescriptor(cls):
- return cls.axisMappingDescriptorClass()
- @classmethod
- def getSourceDescriptor(cls):
- return cls.sourceDescriptorClass()
- @classmethod
- def getInstanceDescriptor(cls):
- return cls.instanceDescriptorClass()
- @classmethod
- def getRuleDescriptor(cls):
- return cls.ruleDescriptorClass()
- def __init__(self, documentPath, documentObject: DesignSpaceDocument):
- self.path = documentPath
- self.documentObject = documentObject
- self.effectiveFormatTuple = self._getEffectiveFormatTuple()
- self.root = ET.Element("designspace")
- def write(self, pretty=True, encoding="UTF-8", xml_declaration=True):
- self.root.attrib["format"] = ".".join(str(i) for i in self.effectiveFormatTuple)
- if (
- self.documentObject.axes
- or self.documentObject.axisMappings
- or self.documentObject.elidedFallbackName is not None
- ):
- axesElement = ET.Element("axes")
- if self.documentObject.elidedFallbackName is not None:
- axesElement.attrib[
- "elidedfallbackname"
- ] = self.documentObject.elidedFallbackName
- self.root.append(axesElement)
- for axisObject in self.documentObject.axes:
- self._addAxis(axisObject)
- if self.documentObject.axisMappings:
- mappingsElement = ET.Element("mappings")
- self.root.findall(".axes")[0].append(mappingsElement)
- for mappingObject in self.documentObject.axisMappings:
- self._addAxisMapping(mappingsElement, mappingObject)
- if self.documentObject.locationLabels:
- labelsElement = ET.Element("labels")
- for labelObject in self.documentObject.locationLabels:
- self._addLocationLabel(labelsElement, labelObject)
- self.root.append(labelsElement)
- if self.documentObject.rules:
- if getattr(self.documentObject, "rulesProcessingLast", False):
- attributes = {"processing": "last"}
- else:
- attributes = {}
- self.root.append(ET.Element("rules", attributes))
- for ruleObject in self.documentObject.rules:
- self._addRule(ruleObject)
- if self.documentObject.sources:
- self.root.append(ET.Element("sources"))
- for sourceObject in self.documentObject.sources:
- self._addSource(sourceObject)
- if self.documentObject.variableFonts:
- variableFontsElement = ET.Element("variable-fonts")
- for variableFont in self.documentObject.variableFonts:
- self._addVariableFont(variableFontsElement, variableFont)
- self.root.append(variableFontsElement)
- if self.documentObject.instances:
- self.root.append(ET.Element("instances"))
- for instanceObject in self.documentObject.instances:
- self._addInstance(instanceObject)
- if self.documentObject.lib:
- self._addLib(self.root, self.documentObject.lib, 2)
- tree = ET.ElementTree(self.root)
- tree.write(
- self.path,
- encoding=encoding,
- method="xml",
- xml_declaration=xml_declaration,
- pretty_print=pretty,
- )
- def _getEffectiveFormatTuple(self):
- """Try to use the version specified in the document, or a sufficiently
- recent version to be able to encode what the document contains.
- """
- minVersion = self.documentObject.formatTuple
- if (
- any(
- hasattr(axis, "values")
- or axis.axisOrdering is not None
- or axis.axisLabels
- for axis in self.documentObject.axes
- )
- or self.documentObject.locationLabels
- or any(source.localisedFamilyName for source in self.documentObject.sources)
- or self.documentObject.variableFonts
- or any(
- instance.locationLabel or instance.userLocation
- for instance in self.documentObject.instances
- )
- ):
- if minVersion < (5, 0):
- minVersion = (5, 0)
- if self.documentObject.axisMappings:
- if minVersion < (5, 1):
- minVersion = (5, 1)
- return minVersion
- def _makeLocationElement(self, locationObject, name=None):
- """Convert Location dict to a locationElement."""
- locElement = ET.Element("location")
- if name is not None:
- locElement.attrib["name"] = name
- validatedLocation = self.documentObject.newDefaultLocation()
- for axisName, axisValue in locationObject.items():
- if axisName in validatedLocation:
- # only accept values we know
- validatedLocation[axisName] = axisValue
- for dimensionName, dimensionValue in validatedLocation.items():
- dimElement = ET.Element("dimension")
- dimElement.attrib["name"] = dimensionName
- if type(dimensionValue) == tuple:
- dimElement.attrib["xvalue"] = self.intOrFloat(dimensionValue[0])
- dimElement.attrib["yvalue"] = self.intOrFloat(dimensionValue[1])
- else:
- dimElement.attrib["xvalue"] = self.intOrFloat(dimensionValue)
- locElement.append(dimElement)
- return locElement, validatedLocation
- def intOrFloat(self, num):
- if int(num) == num:
- return "%d" % num
- return ("%f" % num).rstrip("0").rstrip(".")
- def _addRule(self, ruleObject):
- # if none of the conditions have minimum or maximum values, do not add the rule.
- ruleElement = ET.Element("rule")
- if ruleObject.name is not None:
- ruleElement.attrib["name"] = ruleObject.name
- for conditions in ruleObject.conditionSets:
- conditionsetElement = ET.Element("conditionset")
- for cond in conditions:
- if cond.get("minimum") is None and cond.get("maximum") is None:
- # neither is defined, don't add this condition
- continue
- conditionElement = ET.Element("condition")
- conditionElement.attrib["name"] = cond.get("name")
- if cond.get("minimum") is not None:
- conditionElement.attrib["minimum"] = self.intOrFloat(
- cond.get("minimum")
- )
- if cond.get("maximum") is not None:
- conditionElement.attrib["maximum"] = self.intOrFloat(
- cond.get("maximum")
- )
- conditionsetElement.append(conditionElement)
- if len(conditionsetElement):
- ruleElement.append(conditionsetElement)
- for sub in ruleObject.subs:
- subElement = ET.Element("sub")
- subElement.attrib["name"] = sub[0]
- subElement.attrib["with"] = sub[1]
- ruleElement.append(subElement)
- if len(ruleElement):
- self.root.findall(".rules")[0].append(ruleElement)
- def _addAxis(self, axisObject):
- axisElement = ET.Element("axis")
- axisElement.attrib["tag"] = axisObject.tag
- axisElement.attrib["name"] = axisObject.name
- self._addLabelNames(axisElement, axisObject.labelNames)
- if axisObject.map:
- for inputValue, outputValue in axisObject.map:
- mapElement = ET.Element("map")
- mapElement.attrib["input"] = self.intOrFloat(inputValue)
- mapElement.attrib["output"] = self.intOrFloat(outputValue)
- axisElement.append(mapElement)
- if axisObject.axisOrdering or axisObject.axisLabels:
- labelsElement = ET.Element("labels")
- if axisObject.axisOrdering is not None:
- labelsElement.attrib["ordering"] = str(axisObject.axisOrdering)
- for label in axisObject.axisLabels:
- self._addAxisLabel(labelsElement, label)
- axisElement.append(labelsElement)
- if hasattr(axisObject, "minimum"):
- axisElement.attrib["minimum"] = self.intOrFloat(axisObject.minimum)
- axisElement.attrib["maximum"] = self.intOrFloat(axisObject.maximum)
- elif hasattr(axisObject, "values"):
- axisElement.attrib["values"] = " ".join(
- self.intOrFloat(v) for v in axisObject.values
- )
- axisElement.attrib["default"] = self.intOrFloat(axisObject.default)
- if axisObject.hidden:
- axisElement.attrib["hidden"] = "1"
- self.root.findall(".axes")[0].append(axisElement)
- def _addAxisMapping(self, mappingsElement, mappingObject):
- mappingElement = ET.Element("mapping")
- for what in ("inputLocation", "outputLocation"):
- whatObject = getattr(mappingObject, what, None)
- if whatObject is None:
- continue
- whatElement = ET.Element(what[:-8])
- mappingElement.append(whatElement)
- for name, value in whatObject.items():
- dimensionElement = ET.Element("dimension")
- dimensionElement.attrib["name"] = name
- dimensionElement.attrib["xvalue"] = self.intOrFloat(value)
- whatElement.append(dimensionElement)
- mappingsElement.append(mappingElement)
- def _addAxisLabel(
- self, axisElement: ET.Element, label: AxisLabelDescriptor
- ) -> None:
- labelElement = ET.Element("label")
- labelElement.attrib["uservalue"] = self.intOrFloat(label.userValue)
- if label.userMinimum is not None:
- labelElement.attrib["userminimum"] = self.intOrFloat(label.userMinimum)
- if label.userMaximum is not None:
- labelElement.attrib["usermaximum"] = self.intOrFloat(label.userMaximum)
- labelElement.attrib["name"] = label.name
- if label.elidable:
- labelElement.attrib["elidable"] = "true"
- if label.olderSibling:
- labelElement.attrib["oldersibling"] = "true"
- if label.linkedUserValue is not None:
- labelElement.attrib["linkeduservalue"] = self.intOrFloat(
- label.linkedUserValue
- )
- self._addLabelNames(labelElement, label.labelNames)
- axisElement.append(labelElement)
- def _addLabelNames(self, parentElement, labelNames):
- for languageCode, labelName in sorted(labelNames.items()):
- languageElement = ET.Element("labelname")
- languageElement.attrib[XML_LANG] = languageCode
- languageElement.text = labelName
- parentElement.append(languageElement)
- def _addLocationLabel(
- self, parentElement: ET.Element, label: LocationLabelDescriptor
- ) -> None:
- labelElement = ET.Element("label")
- labelElement.attrib["name"] = label.name
- if label.elidable:
- labelElement.attrib["elidable"] = "true"
- if label.olderSibling:
- labelElement.attrib["oldersibling"] = "true"
- self._addLabelNames(labelElement, label.labelNames)
- self._addLocationElement(labelElement, userLocation=label.userLocation)
- parentElement.append(labelElement)
- def _addLocationElement(
- self,
- parentElement,
- *,
- designLocation: AnisotropicLocationDict = None,
- userLocation: SimpleLocationDict = None,
- ):
- locElement = ET.Element("location")
- for axis in self.documentObject.axes:
- if designLocation is not None and axis.name in designLocation:
- dimElement = ET.Element("dimension")
- dimElement.attrib["name"] = axis.name
- value = designLocation[axis.name]
- if isinstance(value, tuple):
- dimElement.attrib["xvalue"] = self.intOrFloat(value[0])
- dimElement.attrib["yvalue"] = self.intOrFloat(value[1])
- else:
- dimElement.attrib["xvalue"] = self.intOrFloat(value)
- locElement.append(dimElement)
- elif userLocation is not None and axis.name in userLocation:
- dimElement = ET.Element("dimension")
- dimElement.attrib["name"] = axis.name
- value = userLocation[axis.name]
- dimElement.attrib["uservalue"] = self.intOrFloat(value)
- locElement.append(dimElement)
- if len(locElement) > 0:
- parentElement.append(locElement)
- def _addInstance(self, instanceObject):
- instanceElement = ET.Element("instance")
- if instanceObject.name is not None:
- instanceElement.attrib["name"] = instanceObject.name
- if instanceObject.locationLabel is not None:
- instanceElement.attrib["location"] = instanceObject.locationLabel
- if instanceObject.familyName is not None:
- instanceElement.attrib["familyname"] = instanceObject.familyName
- if instanceObject.styleName is not None:
- instanceElement.attrib["stylename"] = instanceObject.styleName
- # add localisations
- if instanceObject.localisedStyleName:
- languageCodes = list(instanceObject.localisedStyleName.keys())
- languageCodes.sort()
- for code in languageCodes:
- if code == "en":
- continue # already stored in the element attribute
- localisedStyleNameElement = ET.Element("stylename")
- localisedStyleNameElement.attrib[XML_LANG] = code
- localisedStyleNameElement.text = instanceObject.getStyleName(code)
- instanceElement.append(localisedStyleNameElement)
- if instanceObject.localisedFamilyName:
- languageCodes = list(instanceObject.localisedFamilyName.keys())
- languageCodes.sort()
- for code in languageCodes:
- if code == "en":
- continue # already stored in the element attribute
- localisedFamilyNameElement = ET.Element("familyname")
- localisedFamilyNameElement.attrib[XML_LANG] = code
- localisedFamilyNameElement.text = instanceObject.getFamilyName(code)
- instanceElement.append(localisedFamilyNameElement)
- if instanceObject.localisedStyleMapStyleName:
- languageCodes = list(instanceObject.localisedStyleMapStyleName.keys())
- languageCodes.sort()
- for code in languageCodes:
- if code == "en":
- continue
- localisedStyleMapStyleNameElement = ET.Element("stylemapstylename")
- localisedStyleMapStyleNameElement.attrib[XML_LANG] = code
- localisedStyleMapStyleNameElement.text = (
- instanceObject.getStyleMapStyleName(code)
- )
- instanceElement.append(localisedStyleMapStyleNameElement)
- if instanceObject.localisedStyleMapFamilyName:
- languageCodes = list(instanceObject.localisedStyleMapFamilyName.keys())
- languageCodes.sort()
- for code in languageCodes:
- if code == "en":
- continue
- localisedStyleMapFamilyNameElement = ET.Element("stylemapfamilyname")
- localisedStyleMapFamilyNameElement.attrib[XML_LANG] = code
- localisedStyleMapFamilyNameElement.text = (
- instanceObject.getStyleMapFamilyName(code)
- )
- instanceElement.append(localisedStyleMapFamilyNameElement)
- if self.effectiveFormatTuple >= (5, 0):
- if instanceObject.locationLabel is None:
- self._addLocationElement(
- instanceElement,
- designLocation=instanceObject.designLocation,
- userLocation=instanceObject.userLocation,
- )
- else:
- # Pre-version 5.0 code was validating and filling in the location
- # dict while writing it out, as preserved below.
- if instanceObject.location is not None:
- locationElement, instanceObject.location = self._makeLocationElement(
- instanceObject.location
- )
- instanceElement.append(locationElement)
- if instanceObject.filename is not None:
- instanceElement.attrib["filename"] = instanceObject.filename
- if instanceObject.postScriptFontName is not None:
- instanceElement.attrib[
- "postscriptfontname"
- ] = instanceObject.postScriptFontName
- if instanceObject.styleMapFamilyName is not None:
- instanceElement.attrib[
- "stylemapfamilyname"
- ] = instanceObject.styleMapFamilyName
- if instanceObject.styleMapStyleName is not None:
- instanceElement.attrib[
- "stylemapstylename"
- ] = instanceObject.styleMapStyleName
- if self.effectiveFormatTuple < (5, 0):
- # Deprecated members as of version 5.0
- if instanceObject.glyphs:
- if instanceElement.findall(".glyphs") == []:
- glyphsElement = ET.Element("glyphs")
- instanceElement.append(glyphsElement)
- glyphsElement = instanceElement.findall(".glyphs")[0]
- for glyphName, data in sorted(instanceObject.glyphs.items()):
- glyphElement = self._writeGlyphElement(
- instanceElement, instanceObject, glyphName, data
- )
- glyphsElement.append(glyphElement)
- if instanceObject.kerning:
- kerningElement = ET.Element("kerning")
- instanceElement.append(kerningElement)
- if instanceObject.info:
- infoElement = ET.Element("info")
- instanceElement.append(infoElement)
- self._addLib(instanceElement, instanceObject.lib, 4)
- self.root.findall(".instances")[0].append(instanceElement)
- def _addSource(self, sourceObject):
- sourceElement = ET.Element("source")
- if sourceObject.filename is not None:
- sourceElement.attrib["filename"] = sourceObject.filename
- if sourceObject.name is not None:
- if sourceObject.name.find("temp_master") != 0:
- # do not save temporary source names
- sourceElement.attrib["name"] = sourceObject.name
- if sourceObject.familyName is not None:
- sourceElement.attrib["familyname"] = sourceObject.familyName
- if sourceObject.styleName is not None:
- sourceElement.attrib["stylename"] = sourceObject.styleName
- if sourceObject.layerName is not None:
- sourceElement.attrib["layer"] = sourceObject.layerName
- if sourceObject.localisedFamilyName:
- languageCodes = list(sourceObject.localisedFamilyName.keys())
- languageCodes.sort()
- for code in languageCodes:
- if code == "en":
- continue # already stored in the element attribute
- localisedFamilyNameElement = ET.Element("familyname")
- localisedFamilyNameElement.attrib[XML_LANG] = code
- localisedFamilyNameElement.text = sourceObject.getFamilyName(code)
- sourceElement.append(localisedFamilyNameElement)
- if sourceObject.copyLib:
- libElement = ET.Element("lib")
- libElement.attrib["copy"] = "1"
- sourceElement.append(libElement)
- if sourceObject.copyGroups:
- groupsElement = ET.Element("groups")
- groupsElement.attrib["copy"] = "1"
- sourceElement.append(groupsElement)
- if sourceObject.copyFeatures:
- featuresElement = ET.Element("features")
- featuresElement.attrib["copy"] = "1"
- sourceElement.append(featuresElement)
- if sourceObject.copyInfo or sourceObject.muteInfo:
- infoElement = ET.Element("info")
- if sourceObject.copyInfo:
- infoElement.attrib["copy"] = "1"
- if sourceObject.muteInfo:
- infoElement.attrib["mute"] = "1"
- sourceElement.append(infoElement)
- if sourceObject.muteKerning:
- kerningElement = ET.Element("kerning")
- kerningElement.attrib["mute"] = "1"
- sourceElement.append(kerningElement)
- if sourceObject.mutedGlyphNames:
- for name in sourceObject.mutedGlyphNames:
- glyphElement = ET.Element("glyph")
- glyphElement.attrib["name"] = name
- glyphElement.attrib["mute"] = "1"
- sourceElement.append(glyphElement)
- if self.effectiveFormatTuple >= (5, 0):
- self._addLocationElement(
- sourceElement, designLocation=sourceObject.location
- )
- else:
- # Pre-version 5.0 code was validating and filling in the location
- # dict while writing it out, as preserved below.
- locationElement, sourceObject.location = self._makeLocationElement(
- sourceObject.location
- )
- sourceElement.append(locationElement)
- self.root.findall(".sources")[0].append(sourceElement)
- def _addVariableFont(
- self, parentElement: ET.Element, vf: VariableFontDescriptor
- ) -> None:
- vfElement = ET.Element("variable-font")
- vfElement.attrib["name"] = vf.name
- if vf.filename is not None:
- vfElement.attrib["filename"] = vf.filename
- if vf.axisSubsets:
- subsetsElement = ET.Element("axis-subsets")
- for subset in vf.axisSubsets:
- subsetElement = ET.Element("axis-subset")
- subsetElement.attrib["name"] = subset.name
- # Mypy doesn't support narrowing union types via hasattr()
- # https://mypy.readthedocs.io/en/stable/type_narrowing.html
- # TODO(Python 3.10): use TypeGuard
- if hasattr(subset, "userMinimum"):
- subset = cast(RangeAxisSubsetDescriptor, subset)
- if subset.userMinimum != -math.inf:
- subsetElement.attrib["userminimum"] = self.intOrFloat(
- subset.userMinimum
- )
- if subset.userMaximum != math.inf:
- subsetElement.attrib["usermaximum"] = self.intOrFloat(
- subset.userMaximum
- )
- if subset.userDefault is not None:
- subsetElement.attrib["userdefault"] = self.intOrFloat(
- subset.userDefault
- )
- elif hasattr(subset, "userValue"):
- subset = cast(ValueAxisSubsetDescriptor, subset)
- subsetElement.attrib["uservalue"] = self.intOrFloat(
- subset.userValue
- )
- subsetsElement.append(subsetElement)
- vfElement.append(subsetsElement)
- self._addLib(vfElement, vf.lib, 4)
- parentElement.append(vfElement)
- def _addLib(self, parentElement: ET.Element, data: Any, indent_level: int) -> None:
- if not data:
- return
- libElement = ET.Element("lib")
- libElement.append(plistlib.totree(data, indent_level=indent_level))
- parentElement.append(libElement)
- def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data):
- glyphElement = ET.Element("glyph")
- if data.get("mute"):
- glyphElement.attrib["mute"] = "1"
- if data.get("unicodes") is not None:
- glyphElement.attrib["unicode"] = " ".join(
- [hex(u) for u in data.get("unicodes")]
- )
- if data.get("instanceLocation") is not None:
- locationElement, data["instanceLocation"] = self._makeLocationElement(
- data.get("instanceLocation")
- )
- glyphElement.append(locationElement)
- if glyphName is not None:
- glyphElement.attrib["name"] = glyphName
- if data.get("note") is not None:
- noteElement = ET.Element("note")
- noteElement.text = data.get("note")
- glyphElement.append(noteElement)
- if data.get("masters") is not None:
- mastersElement = ET.Element("masters")
- for m in data.get("masters"):
- masterElement = ET.Element("master")
- if m.get("glyphName") is not None:
- masterElement.attrib["glyphname"] = m.get("glyphName")
- if m.get("font") is not None:
- masterElement.attrib["source"] = m.get("font")
- if m.get("location") is not None:
- locationElement, m["location"] = self._makeLocationElement(
- m.get("location")
- )
- masterElement.append(locationElement)
- mastersElement.append(masterElement)
- glyphElement.append(mastersElement)
- return glyphElement
- class BaseDocReader(LogMixin):
- axisDescriptorClass = AxisDescriptor
- discreteAxisDescriptorClass = DiscreteAxisDescriptor
- axisLabelDescriptorClass = AxisLabelDescriptor
- axisMappingDescriptorClass = AxisMappingDescriptor
- locationLabelDescriptorClass = LocationLabelDescriptor
- ruleDescriptorClass = RuleDescriptor
- sourceDescriptorClass = SourceDescriptor
- variableFontsDescriptorClass = VariableFontDescriptor
- valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor
- rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor
- instanceDescriptorClass = InstanceDescriptor
- def __init__(self, documentPath, documentObject):
- self.path = documentPath
- self.documentObject = documentObject
- tree = ET.parse(self.path)
- self.root = tree.getroot()
- self.documentObject.formatVersion = self.root.attrib.get("format", "3.0")
- self._axes = []
- self.rules = []
- self.sources = []
- self.instances = []
- self.axisDefaults = {}
- self._strictAxisNames = True
- @classmethod
- def fromstring(cls, string, documentObject):
- f = BytesIO(tobytes(string, encoding="utf-8"))
- self = cls(f, documentObject)
- self.path = None
- return self
- def read(self):
- self.readAxes()
- self.readLabels()
- self.readRules()
- self.readVariableFonts()
- self.readSources()
- self.readInstances()
- self.readLib()
- def readRules(self):
- # we also need to read any conditions that are outside of a condition set.
- rules = []
- rulesElement = self.root.find(".rules")
- if rulesElement is not None:
- processingValue = rulesElement.attrib.get("processing", "first")
- if processingValue not in {"first", "last"}:
- raise DesignSpaceDocumentError(
- "<rules> processing attribute value is not valid: %r, "
- "expected 'first' or 'last'" % processingValue
- )
- self.documentObject.rulesProcessingLast = processingValue == "last"
- for ruleElement in self.root.findall(".rules/rule"):
- ruleObject = self.ruleDescriptorClass()
- ruleName = ruleObject.name = ruleElement.attrib.get("name")
- # read any stray conditions outside a condition set
- externalConditions = self._readConditionElements(
- ruleElement,
- ruleName,
- )
- if externalConditions:
- ruleObject.conditionSets.append(externalConditions)
- self.log.info(
- "Found stray rule conditions outside a conditionset. "
- "Wrapped them in a new conditionset."
- )
- # read the conditionsets
- for conditionSetElement in ruleElement.findall(".conditionset"):
- conditionSet = self._readConditionElements(
- conditionSetElement,
- ruleName,
- )
- if conditionSet is not None:
- ruleObject.conditionSets.append(conditionSet)
- for subElement in ruleElement.findall(".sub"):
- a = subElement.attrib["name"]
- b = subElement.attrib["with"]
- ruleObject.subs.append((a, b))
- rules.append(ruleObject)
- self.documentObject.rules = rules
- def _readConditionElements(self, parentElement, ruleName=None):
- cds = []
- for conditionElement in parentElement.findall(".condition"):
- cd = {}
- cdMin = conditionElement.attrib.get("minimum")
- if cdMin is not None:
- cd["minimum"] = float(cdMin)
- else:
- # will allow these to be None, assume axis.minimum
- cd["minimum"] = None
- cdMax = conditionElement.attrib.get("maximum")
- if cdMax is not None:
- cd["maximum"] = float(cdMax)
- else:
- # will allow these to be None, assume axis.maximum
- cd["maximum"] = None
- cd["name"] = conditionElement.attrib.get("name")
- # # test for things
- if cd.get("minimum") is None and cd.get("maximum") is None:
- raise DesignSpaceDocumentError(
- "condition missing required minimum or maximum in rule"
- + (" '%s'" % ruleName if ruleName is not None else "")
- )
- cds.append(cd)
- return cds
- def readAxes(self):
- # read the axes elements, including the warp map.
- axesElement = self.root.find(".axes")
- if axesElement is not None and "elidedfallbackname" in axesElement.attrib:
- self.documentObject.elidedFallbackName = axesElement.attrib[
- "elidedfallbackname"
- ]
- axisElements = self.root.findall(".axes/axis")
- if not axisElements:
- return
- for axisElement in axisElements:
- if (
- self.documentObject.formatTuple >= (5, 0)
- and "values" in axisElement.attrib
- ):
- axisObject = self.discreteAxisDescriptorClass()
- axisObject.values = [
- float(s) for s in axisElement.attrib["values"].split(" ")
- ]
- else:
- axisObject = self.axisDescriptorClass()
- axisObject.minimum = float(axisElement.attrib.get("minimum"))
- axisObject.maximum = float(axisElement.attrib.get("maximum"))
- axisObject.default = float(axisElement.attrib.get("default"))
- axisObject.name = axisElement.attrib.get("name")
- if axisElement.attrib.get("hidden", False):
- axisObject.hidden = True
- axisObject.tag = axisElement.attrib.get("tag")
- for mapElement in axisElement.findall("map"):
- a = float(mapElement.attrib["input"])
- b = float(mapElement.attrib["output"])
- axisObject.map.append((a, b))
- for labelNameElement in axisElement.findall("labelname"):
- # Note: elementtree reads the "xml:lang" attribute name as
- # '{http://www.w3.org/XML/1998/namespace}lang'
- for key, lang in labelNameElement.items():
- if key == XML_LANG:
- axisObject.labelNames[lang] = tostr(labelNameElement.text)
- labelElement = axisElement.find(".labels")
- if labelElement is not None:
- if "ordering" in labelElement.attrib:
- axisObject.axisOrdering = int(labelElement.attrib["ordering"])
- for label in labelElement.findall(".label"):
- axisObject.axisLabels.append(self.readAxisLabel(label))
- self.documentObject.axes.append(axisObject)
- self.axisDefaults[axisObject.name] = axisObject.default
- mappingsElement = self.root.find(".axes/mappings")
- self.documentObject.axisMappings = []
- if mappingsElement is not None:
- for mappingElement in mappingsElement.findall("mapping"):
- inputElement = mappingElement.find("input")
- outputElement = mappingElement.find("output")
- inputLoc = {}
- outputLoc = {}
- for dimElement in inputElement.findall(".dimension"):
- name = dimElement.attrib["name"]
- value = float(dimElement.attrib["xvalue"])
- inputLoc[name] = value
- for dimElement in outputElement.findall(".dimension"):
- name = dimElement.attrib["name"]
- value = float(dimElement.attrib["xvalue"])
- outputLoc[name] = value
- axisMappingObject = self.axisMappingDescriptorClass(
- inputLocation=inputLoc, outputLocation=outputLoc
- )
- self.documentObject.axisMappings.append(axisMappingObject)
- def readAxisLabel(self, element: ET.Element):
- xml_attrs = {
- "userminimum",
- "uservalue",
- "usermaximum",
- "name",
- "elidable",
- "oldersibling",
- "linkeduservalue",
- }
- unknown_attrs = set(element.attrib) - xml_attrs
- if unknown_attrs:
- raise DesignSpaceDocumentError(
- f"label element contains unknown attributes: {', '.join(unknown_attrs)}"
- )
- name = element.get("name")
- if name is None:
- raise DesignSpaceDocumentError("label element must have a name attribute.")
- valueStr = element.get("uservalue")
- if valueStr is None:
- raise DesignSpaceDocumentError(
- "label element must have a uservalue attribute."
- )
- value = float(valueStr)
- minimumStr = element.get("userminimum")
- minimum = float(minimumStr) if minimumStr is not None else None
- maximumStr = element.get("usermaximum")
- maximum = float(maximumStr) if maximumStr is not None else None
- linkedValueStr = element.get("linkeduservalue")
- linkedValue = float(linkedValueStr) if linkedValueStr is not None else None
- elidable = True if element.get("elidable") == "true" else False
- olderSibling = True if element.get("oldersibling") == "true" else False
- labelNames = {
- lang: label_name.text or ""
- for label_name in element.findall("labelname")
- for attr, lang in label_name.items()
- if attr == XML_LANG
- # Note: elementtree reads the "xml:lang" attribute name as
- # '{http://www.w3.org/XML/1998/namespace}lang'
- }
- return self.axisLabelDescriptorClass(
- name=name,
- userValue=value,
- userMinimum=minimum,
- userMaximum=maximum,
- elidable=elidable,
- olderSibling=olderSibling,
- linkedUserValue=linkedValue,
- labelNames=labelNames,
- )
- def readLabels(self):
- if self.documentObject.formatTuple < (5, 0):
- return
- xml_attrs = {"name", "elidable", "oldersibling"}
- for labelElement in self.root.findall(".labels/label"):
- unknown_attrs = set(labelElement.attrib) - xml_attrs
- if unknown_attrs:
- raise DesignSpaceDocumentError(
- f"Label element contains unknown attributes: {', '.join(unknown_attrs)}"
- )
- name = labelElement.get("name")
- if name is None:
- raise DesignSpaceDocumentError(
- "label element must have a name attribute."
- )
- designLocation, userLocation = self.locationFromElement(labelElement)
- if designLocation:
- raise DesignSpaceDocumentError(
- f'<label> element "{name}" must only have user locations (using uservalue="").'
- )
- elidable = True if labelElement.get("elidable") == "true" else False
- olderSibling = True if labelElement.get("oldersibling") == "true" else False
- labelNames = {
- lang: label_name.text or ""
- for label_name in labelElement.findall("labelname")
- for attr, lang in label_name.items()
- if attr == XML_LANG
- # Note: elementtree reads the "xml:lang" attribute name as
- # '{http://www.w3.org/XML/1998/namespace}lang'
- }
- locationLabel = self.locationLabelDescriptorClass(
- name=name,
- userLocation=userLocation,
- elidable=elidable,
- olderSibling=olderSibling,
- labelNames=labelNames,
- )
- self.documentObject.locationLabels.append(locationLabel)
- def readVariableFonts(self):
- if self.documentObject.formatTuple < (5, 0):
- return
- xml_attrs = {"name", "filename"}
- for variableFontElement in self.root.findall(".variable-fonts/variable-font"):
- unknown_attrs = set(variableFontElement.attrib) - xml_attrs
- if unknown_attrs:
- raise DesignSpaceDocumentError(
- f"variable-font element contains unknown attributes: {', '.join(unknown_attrs)}"
- )
- name = variableFontElement.get("name")
- if name is None:
- raise DesignSpaceDocumentError(
- "variable-font element must have a name attribute."
- )
- filename = variableFontElement.get("filename")
- axisSubsetsElement = variableFontElement.find(".axis-subsets")
- if axisSubsetsElement is None:
- raise DesignSpaceDocumentError(
- "variable-font element must contain an axis-subsets element."
- )
- axisSubsets = []
- for axisSubset in axisSubsetsElement.iterfind(".axis-subset"):
- axisSubsets.append(self.readAxisSubset(axisSubset))
- lib = None
- libElement = variableFontElement.find(".lib")
- if libElement is not None:
- lib = plistlib.fromtree(libElement[0])
- variableFont = self.variableFontsDescriptorClass(
- name=name,
- filename=filename,
- axisSubsets=axisSubsets,
- lib=lib,
- )
- self.documentObject.variableFonts.append(variableFont)
- def readAxisSubset(self, element: ET.Element):
- if "uservalue" in element.attrib:
- xml_attrs = {"name", "uservalue"}
- unknown_attrs = set(element.attrib) - xml_attrs
- if unknown_attrs:
- raise DesignSpaceDocumentError(
- f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}"
- )
- name = element.get("name")
- if name is None:
- raise DesignSpaceDocumentError(
- "axis-subset element must have a name attribute."
- )
- userValueStr = element.get("uservalue")
- if userValueStr is None:
- raise DesignSpaceDocumentError(
- "The axis-subset element for a discrete subset must have a uservalue attribute."
- )
- userValue = float(userValueStr)
- return self.valueAxisSubsetDescriptorClass(name=name, userValue=userValue)
- else:
- xml_attrs = {"name", "userminimum", "userdefault", "usermaximum"}
- unknown_attrs = set(element.attrib) - xml_attrs
- if unknown_attrs:
- raise DesignSpaceDocumentError(
- f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}"
- )
- name = element.get("name")
- if name is None:
- raise DesignSpaceDocumentError(
- "axis-subset element must have a name attribute."
- )
- userMinimum = element.get("userminimum")
- userDefault = element.get("userdefault")
- userMaximum = element.get("usermaximum")
- if (
- userMinimum is not None
- and userDefault is not None
- and userMaximum is not None
- ):
- return self.rangeAxisSubsetDescriptorClass(
- name=name,
- userMinimum=float(userMinimum),
- userDefault=float(userDefault),
- userMaximum=float(userMaximum),
- )
- if all(v is None for v in (userMinimum, userDefault, userMaximum)):
- return self.rangeAxisSubsetDescriptorClass(name=name)
- raise DesignSpaceDocumentError(
- "axis-subset element must have min/max/default values or none at all."
- )
- def readSources(self):
- for sourceCount, sourceElement in enumerate(
- self.root.findall(".sources/source")
- ):
- filename = sourceElement.attrib.get("filename")
- if filename is not None and self.path is not None:
- sourcePath = os.path.abspath(
- os.path.join(os.path.dirname(self.path), filename)
- )
- else:
- sourcePath = None
- sourceName = sourceElement.attrib.get("name")
- if sourceName is None:
- # add a temporary source name
- sourceName = "temp_master.%d" % (sourceCount)
- sourceObject = self.sourceDescriptorClass()
- sourceObject.path = sourcePath # absolute path to the ufo source
- sourceObject.filename = filename # path as it is stored in the document
- sourceObject.name = sourceName
- familyName = sourceElement.attrib.get("familyname")
- if familyName is not None:
- sourceObject.familyName = familyName
- styleName = sourceElement.attrib.get("stylename")
- if styleName is not None:
- sourceObject.styleName = styleName
- for familyNameElement in sourceElement.findall("familyname"):
- for key, lang in familyNameElement.items():
- if key == XML_LANG:
- familyName = familyNameElement.text
- sourceObject.setFamilyName(familyName, lang)
- designLocation, userLocation = self.locationFromElement(sourceElement)
- if userLocation:
- raise DesignSpaceDocumentError(
- f'<source> element "{sourceName}" must only have design locations (using xvalue="").'
- )
- sourceObject.location = designLocation
- layerName = sourceElement.attrib.get("layer")
- if layerName is not None:
- sourceObject.layerName = layerName
- for libElement in sourceElement.findall(".lib"):
- if libElement.attrib.get("copy") == "1":
- sourceObject.copyLib = True
- for groupsElement in sourceElement.findall(".groups"):
- if groupsElement.attrib.get("copy") == "1":
- sourceObject.copyGroups = True
- for infoElement in sourceElement.findall(".info"):
- if infoElement.attrib.get("copy") == "1":
- sourceObject.copyInfo = True
- if infoElement.attrib.get("mute") == "1":
- sourceObject.muteInfo = True
- for featuresElement in sourceElement.findall(".features"):
- if featuresElement.attrib.get("copy") == "1":
- sourceObject.copyFeatures = True
- for glyphElement in sourceElement.findall(".glyph"):
- glyphName = glyphElement.attrib.get("name")
- if glyphName is None:
- continue
- if glyphElement.attrib.get("mute") == "1":
- sourceObject.mutedGlyphNames.append(glyphName)
- for kerningElement in sourceElement.findall(".kerning"):
- if kerningElement.attrib.get("mute") == "1":
- sourceObject.muteKerning = True
- self.documentObject.sources.append(sourceObject)
- def locationFromElement(self, element):
- """Read a nested ``<location>`` element inside the given ``element``.
- .. versionchanged:: 5.0
- Return a tuple of (designLocation, userLocation)
- """
- elementLocation = (None, None)
- for locationElement in element.findall(".location"):
- elementLocation = self.readLocationElement(locationElement)
- break
- return elementLocation
- def readLocationElement(self, locationElement):
- """Read a ``<location>`` element.
- .. versionchanged:: 5.0
- Return a tuple of (designLocation, userLocation)
- """
- if self._strictAxisNames and not self.documentObject.axes:
- raise DesignSpaceDocumentError("No axes defined")
- userLoc = {}
- designLoc = {}
- for dimensionElement in locationElement.findall(".dimension"):
- dimName = dimensionElement.attrib.get("name")
- if self._strictAxisNames and dimName not in self.axisDefaults:
- # In case the document contains no axis definitions,
- self.log.warning('Location with undefined axis: "%s".', dimName)
- continue
- userValue = xValue = yValue = None
- try:
- userValue = dimensionElement.attrib.get("uservalue")
- if userValue is not None:
- userValue = float(userValue)
- except ValueError:
- self.log.warning(
- "ValueError in readLocation userValue %3.3f", userValue
- )
- try:
- xValue = dimensionElement.attrib.get("xvalue")
- if xValue is not None:
- xValue = float(xValue)
- except ValueError:
- self.log.warning("ValueError in readLocation xValue %3.3f", xValue)
- try:
- yValue = dimensionElement.attrib.get("yvalue")
- if yValue is not None:
- yValue = float(yValue)
- except ValueError:
- self.log.warning("ValueError in readLocation yValue %3.3f", yValue)
- if userValue is None == xValue is None:
- raise DesignSpaceDocumentError(
- f'Exactly one of uservalue="" or xvalue="" must be provided for location dimension "{dimName}"'
- )
- if yValue is not None:
- if xValue is None:
- raise DesignSpaceDocumentError(
- f'Missing xvalue="" for the location dimension "{dimName}"" with yvalue="{yValue}"'
- )
- designLoc[dimName] = (xValue, yValue)
- elif xValue is not None:
- designLoc[dimName] = xValue
- else:
- userLoc[dimName] = userValue
- return designLoc, userLoc
- def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True):
- instanceElements = self.root.findall(".instances/instance")
- for instanceElement in instanceElements:
- self._readSingleInstanceElement(
- instanceElement,
- makeGlyphs=makeGlyphs,
- makeKerning=makeKerning,
- makeInfo=makeInfo,
- )
- def _readSingleInstanceElement(
- self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True
- ):
- filename = instanceElement.attrib.get("filename")
- if filename is not None and self.documentObject.path is not None:
- instancePath = os.path.join(
- os.path.dirname(self.documentObject.path), filename
- )
- else:
- instancePath = None
- instanceObject = self.instanceDescriptorClass()
- instanceObject.path = instancePath # absolute path to the instance
- instanceObject.filename = filename # path as it is stored in the document
- name = instanceElement.attrib.get("name")
- if name is not None:
- instanceObject.name = name
- familyname = instanceElement.attrib.get("familyname")
- if familyname is not None:
- instanceObject.familyName = familyname
- stylename = instanceElement.attrib.get("stylename")
- if stylename is not None:
- instanceObject.styleName = stylename
- postScriptFontName = instanceElement.attrib.get("postscriptfontname")
- if postScriptFontName is not None:
- instanceObject.postScriptFontName = postScriptFontName
- styleMapFamilyName = instanceElement.attrib.get("stylemapfamilyname")
- if styleMapFamilyName is not None:
- instanceObject.styleMapFamilyName = styleMapFamilyName
- styleMapStyleName = instanceElement.attrib.get("stylemapstylename")
- if styleMapStyleName is not None:
- instanceObject.styleMapStyleName = styleMapStyleName
- # read localised names
- for styleNameElement in instanceElement.findall("stylename"):
- for key, lang in styleNameElement.items():
- if key == XML_LANG:
- styleName = styleNameElement.text
- instanceObject.setStyleName(styleName, lang)
- for familyNameElement in instanceElement.findall("familyname"):
- for key, lang in familyNameElement.items():
- if key == XML_LANG:
- familyName = familyNameElement.text
- instanceObject.setFamilyName(familyName, lang)
- for styleMapStyleNameElement in instanceElement.findall("stylemapstylename"):
- for key, lang in styleMapStyleNameElement.items():
- if key == XML_LANG:
- styleMapStyleName = styleMapStyleNameElement.text
- instanceObject.setStyleMapStyleName(styleMapStyleName, lang)
- for styleMapFamilyNameElement in instanceElement.findall("stylemapfamilyname"):
- for key, lang in styleMapFamilyNameElement.items():
- if key == XML_LANG:
- styleMapFamilyName = styleMapFamilyNameElement.text
- instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang)
- designLocation, userLocation = self.locationFromElement(instanceElement)
- locationLabel = instanceElement.attrib.get("location")
- if (designLocation or userLocation) and locationLabel is not None:
- raise DesignSpaceDocumentError(
- 'instance element must have at most one of the location="..." attribute or the nested location element'
- )
- instanceObject.locationLabel = locationLabel
- instanceObject.userLocation = userLocation or {}
- instanceObject.designLocation = designLocation or {}
- for glyphElement in instanceElement.findall(".glyphs/glyph"):
- self.readGlyphElement(glyphElement, instanceObject)
- for infoElement in instanceElement.findall("info"):
- self.readInfoElement(infoElement, instanceObject)
- for libElement in instanceElement.findall("lib"):
- self.readLibElement(libElement, instanceObject)
- self.documentObject.instances.append(instanceObject)
- def readLibElement(self, libElement, instanceObject):
- """Read the lib element for the given instance."""
- instanceObject.lib = plistlib.fromtree(libElement[0])
- def readInfoElement(self, infoElement, instanceObject):
- """Read the info element."""
- instanceObject.info = True
- def readGlyphElement(self, glyphElement, instanceObject):
- """
- Read the glyph element, which could look like either one of these:
- .. code-block:: xml
- <glyph name="b" unicode="0x62"/>
- <glyph name="b"/>
- <glyph name="b">
- <master location="location-token-bbb" source="master-token-aaa2"/>
- <master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/>
- <note>
- This is an instance from an anisotropic interpolation.
- </note>
- </glyph>
- """
- glyphData = {}
- glyphName = glyphElement.attrib.get("name")
- if glyphName is None:
- raise DesignSpaceDocumentError("Glyph object without name attribute")
- mute = glyphElement.attrib.get("mute")
- if mute == "1":
- glyphData["mute"] = True
- # unicode
- unicodes = glyphElement.attrib.get("unicode")
- if unicodes is not None:
- try:
- unicodes = [int(u, 16) for u in unicodes.split(" ")]
- glyphData["unicodes"] = unicodes
- except ValueError:
- raise DesignSpaceDocumentError(
- "unicode values %s are not integers" % unicodes
- )
- for noteElement in glyphElement.findall(".note"):
- glyphData["note"] = noteElement.text
- break
- designLocation, userLocation = self.locationFromElement(glyphElement)
- if userLocation:
- raise DesignSpaceDocumentError(
- f'<glyph> element "{glyphName}" must only have design locations (using xvalue="").'
- )
- if designLocation is not None:
- glyphData["instanceLocation"] = designLocation
- glyphSources = None
- for masterElement in glyphElement.findall(".masters/master"):
- fontSourceName = masterElement.attrib.get("source")
- designLocation, userLocation = self.locationFromElement(masterElement)
- if userLocation:
- raise DesignSpaceDocumentError(
- f'<master> element "{fontSourceName}" must only have design locations (using xvalue="").'
- )
- masterGlyphName = masterElement.attrib.get("glyphname")
- if masterGlyphName is None:
- # if we don't read a glyphname, use the one we have
- masterGlyphName = glyphName
- d = dict(
- font=fontSourceName, location=designLocation, glyphName=masterGlyphName
- )
- if glyphSources is None:
- glyphSources = []
- glyphSources.append(d)
- if glyphSources is not None:
- glyphData["masters"] = glyphSources
- instanceObject.glyphs[glyphName] = glyphData
- def readLib(self):
- """Read the lib element for the whole document."""
- for libElement in self.root.findall(".lib"):
- self.documentObject.lib = plistlib.fromtree(libElement[0])
- class DesignSpaceDocument(LogMixin, AsDictMixin):
- """The DesignSpaceDocument object can read and write ``.designspace`` data.
- It imports the axes, sources, variable fonts and instances to very basic
- **descriptor** objects that store the data in attributes. Data is added to
- the document by creating such descriptor objects, filling them with data
- and then adding them to the document. This makes it easy to integrate this
- object in different contexts.
- The **DesignSpaceDocument** object can be subclassed to work with
- different objects, as long as they have the same attributes. Reader and
- Writer objects can be subclassed as well.
- **Note:** Python attribute names are usually camelCased, the
- corresponding `XML <document-xml-structure>`_ attributes are usually
- all lowercase.
- .. code:: python
- from fontTools.designspaceLib import DesignSpaceDocument
- doc = DesignSpaceDocument.fromfile("some/path/to/my.designspace")
- doc.formatVersion
- doc.elidedFallbackName
- doc.axes
- doc.axisMappings
- doc.locationLabels
- doc.rules
- doc.rulesProcessingLast
- doc.sources
- doc.variableFonts
- doc.instances
- doc.lib
- """
- def __init__(self, readerClass=None, writerClass=None):
- self.path = None
- """String, optional. When the document is read from the disk, this is
- the full path that was given to :meth:`read` or :meth:`fromfile`.
- """
- self.filename = None
- """String, optional. When the document is read from the disk, this is
- its original file name, i.e. the last part of its path.
- When the document is produced by a Python script and still only exists
- in memory, the producing script can write here an indication of a
- possible "good" filename, in case one wants to save the file somewhere.
- """
- self.formatVersion: Optional[str] = None
- """Format version for this document, as a string. E.g. "4.0" """
- self.elidedFallbackName: Optional[str] = None
- """STAT Style Attributes Header field ``elidedFallbackNameID``.
- See: `OTSpec STAT Style Attributes Header <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#style-attributes-header>`_
- .. versionadded:: 5.0
- """
- self.axes: List[Union[AxisDescriptor, DiscreteAxisDescriptor]] = []
- """List of this document's axes."""
- self.axisMappings: List[AxisMappingDescriptor] = []
- """List of this document's axis mappings."""
- self.locationLabels: List[LocationLabelDescriptor] = []
- """List of this document's STAT format 4 labels.
- .. versionadded:: 5.0"""
- self.rules: List[RuleDescriptor] = []
- """List of this document's rules."""
- self.rulesProcessingLast: bool = False
- """This flag indicates whether the substitution rules should be applied
- before or after other glyph substitution features.
- - False: before
- - True: after.
- Default is False. For new projects, you probably want True. See
- the following issues for more information:
- `fontTools#1371 <https://github.com/fonttools/fonttools/issues/1371#issuecomment-590214572>`__
- `fontTools#2050 <https://github.com/fonttools/fonttools/issues/2050#issuecomment-678691020>`__
- If you want to use a different feature altogether, e.g. ``calt``,
- use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``
- .. code:: xml
- <lib>
- <dict>
- <key>com.github.fonttools.varLib.featureVarsFeatureTag</key>
- <string>calt</string>
- </dict>
- </lib>
- """
- self.sources: List[SourceDescriptor] = []
- """List of this document's sources."""
- self.variableFonts: List[VariableFontDescriptor] = []
- """List of this document's variable fonts.
- .. versionadded:: 5.0"""
- self.instances: List[InstanceDescriptor] = []
- """List of this document's instances."""
- self.lib: Dict = {}
- """User defined, custom data associated with the whole document.
- Use reverse-DNS notation to identify your own data.
- Respect the data stored by others.
- """
- self.default: Optional[str] = None
- """Name of the default master.
- This attribute is updated by the :meth:`findDefault`
- """
- if readerClass is not None:
- self.readerClass = readerClass
- else:
- self.readerClass = BaseDocReader
- if writerClass is not None:
- self.writerClass = writerClass
- else:
- self.writerClass = BaseDocWriter
- @classmethod
- def fromfile(cls, path, readerClass=None, writerClass=None):
- """Read a designspace file from ``path`` and return a new instance of
- :class:.
- """
- self = cls(readerClass=readerClass, writerClass=writerClass)
- self.read(path)
- return self
- @classmethod
- def fromstring(cls, string, readerClass=None, writerClass=None):
- self = cls(readerClass=readerClass, writerClass=writerClass)
- reader = self.readerClass.fromstring(string, self)
- reader.read()
- if self.sources:
- self.findDefault()
- return self
- def tostring(self, encoding=None):
- """Returns the designspace as a string. Default encoding ``utf-8``."""
- if encoding is str or (encoding is not None and encoding.lower() == "unicode"):
- f = StringIO()
- xml_declaration = False
- elif encoding is None or encoding == "utf-8":
- f = BytesIO()
- encoding = "UTF-8"
- xml_declaration = True
- else:
- raise ValueError("unsupported encoding: '%s'" % encoding)
- writer = self.writerClass(f, self)
- writer.write(encoding=encoding, xml_declaration=xml_declaration)
- return f.getvalue()
- def read(self, path):
- """Read a designspace file from ``path`` and populates the fields of
- ``self`` with the data.
- """
- if hasattr(path, "__fspath__"): # support os.PathLike objects
- path = path.__fspath__()
- self.path = path
- self.filename = os.path.basename(path)
- reader = self.readerClass(path, self)
- reader.read()
- if self.sources:
- self.findDefault()
- def write(self, path):
- """Write this designspace to ``path``."""
- if hasattr(path, "__fspath__"): # support os.PathLike objects
- path = path.__fspath__()
- self.path = path
- self.filename = os.path.basename(path)
- self.updatePaths()
- writer = self.writerClass(path, self)
- writer.write()
- def _posixRelativePath(self, otherPath):
- relative = os.path.relpath(otherPath, os.path.dirname(self.path))
- return posix(relative)
- def updatePaths(self):
- """
- Right before we save we need to identify and respond to the following situations:
- In each descriptor, we have to do the right thing for the filename attribute.
- ::
- case 1.
- descriptor.filename == None
- descriptor.path == None
- -- action:
- write as is, descriptors will not have a filename attr.
- useless, but no reason to interfere.
- case 2.
- descriptor.filename == "../something"
- descriptor.path == None
- -- action:
- write as is. The filename attr should not be touched.
- case 3.
- descriptor.filename == None
- descriptor.path == "~/absolute/path/there"
- -- action:
- calculate the relative path for filename.
- We're not overwriting some other value for filename, it should be fine
- case 4.
- descriptor.filename == '../somewhere'
- descriptor.path == "~/absolute/path/there"
- -- action:
- there is a conflict between the given filename, and the path.
- So we know where the file is relative to the document.
- Can't guess why they're different, we just choose for path to be correct and update filename.
- """
- assert self.path is not None
- for descriptor in self.sources + self.instances:
- if descriptor.path is not None:
- # case 3 and 4: filename gets updated and relativized
- descriptor.filename = self._posixRelativePath(descriptor.path)
- def addSource(self, sourceDescriptor: SourceDescriptor):
- """Add the given ``sourceDescriptor`` to ``doc.sources``."""
- self.sources.append(sourceDescriptor)
- def addSourceDescriptor(self, **kwargs):
- """Instantiate a new :class:`SourceDescriptor` using the given
- ``kwargs`` and add it to ``doc.sources``.
- """
- source = self.writerClass.sourceDescriptorClass(**kwargs)
- self.addSource(source)
- return source
- def addInstance(self, instanceDescriptor: InstanceDescriptor):
- """Add the given ``instanceDescriptor`` to :attr:`instances`."""
- self.instances.append(instanceDescriptor)
- def addInstanceDescriptor(self, **kwargs):
- """Instantiate a new :class:`InstanceDescriptor` using the given
- ``kwargs`` and add it to :attr:`instances`.
- """
- instance = self.writerClass.instanceDescriptorClass(**kwargs)
- self.addInstance(instance)
- return instance
- def addAxis(self, axisDescriptor: Union[AxisDescriptor, DiscreteAxisDescriptor]):
- """Add the given ``axisDescriptor`` to :attr:`axes`."""
- self.axes.append(axisDescriptor)
- def addAxisDescriptor(self, **kwargs):
- """Instantiate a new :class:`AxisDescriptor` using the given
- ``kwargs`` and add it to :attr:`axes`.
- The axis will be and instance of :class:`DiscreteAxisDescriptor` if
- the ``kwargs`` provide a ``value``, or a :class:`AxisDescriptor` otherwise.
- """
- if "values" in kwargs:
- axis = self.writerClass.discreteAxisDescriptorClass(**kwargs)
- else:
- axis = self.writerClass.axisDescriptorClass(**kwargs)
- self.addAxis(axis)
- return axis
- def addAxisMapping(self, axisMappingDescriptor: AxisMappingDescriptor):
- """Add the given ``axisMappingDescriptor`` to :attr:`axisMappings`."""
- self.axisMappings.append(axisMappingDescriptor)
- def addAxisMappingDescriptor(self, **kwargs):
- """Instantiate a new :class:`AxisMappingDescriptor` using the given
- ``kwargs`` and add it to :attr:`rules`.
- """
- axisMapping = self.writerClass.axisMappingDescriptorClass(**kwargs)
- self.addAxisMapping(axisMapping)
- return axisMapping
- def addRule(self, ruleDescriptor: RuleDescriptor):
- """Add the given ``ruleDescriptor`` to :attr:`rules`."""
- self.rules.append(ruleDescriptor)
- def addRuleDescriptor(self, **kwargs):
- """Instantiate a new :class:`RuleDescriptor` using the given
- ``kwargs`` and add it to :attr:`rules`.
- """
- rule = self.writerClass.ruleDescriptorClass(**kwargs)
- self.addRule(rule)
- return rule
- def addVariableFont(self, variableFontDescriptor: VariableFontDescriptor):
- """Add the given ``variableFontDescriptor`` to :attr:`variableFonts`.
- .. versionadded:: 5.0
- """
- self.variableFonts.append(variableFontDescriptor)
- def addVariableFontDescriptor(self, **kwargs):
- """Instantiate a new :class:`VariableFontDescriptor` using the given
- ``kwargs`` and add it to :attr:`variableFonts`.
- .. versionadded:: 5.0
- """
- variableFont = self.writerClass.variableFontDescriptorClass(**kwargs)
- self.addVariableFont(variableFont)
- return variableFont
- def addLocationLabel(self, locationLabelDescriptor: LocationLabelDescriptor):
- """Add the given ``locationLabelDescriptor`` to :attr:`locationLabels`.
- .. versionadded:: 5.0
- """
- self.locationLabels.append(locationLabelDescriptor)
- def addLocationLabelDescriptor(self, **kwargs):
- """Instantiate a new :class:`LocationLabelDescriptor` using the given
- ``kwargs`` and add it to :attr:`locationLabels`.
- .. versionadded:: 5.0
- """
- locationLabel = self.writerClass.locationLabelDescriptorClass(**kwargs)
- self.addLocationLabel(locationLabel)
- return locationLabel
- def newDefaultLocation(self):
- """Return a dict with the default location in design space coordinates."""
- # Without OrderedDict, output XML would be non-deterministic.
- # https://github.com/LettError/designSpaceDocument/issues/10
- loc = collections.OrderedDict()
- for axisDescriptor in self.axes:
- loc[axisDescriptor.name] = axisDescriptor.map_forward(
- axisDescriptor.default
- )
- return loc
- def labelForUserLocation(
- self, userLocation: SimpleLocationDict
- ) -> Optional[LocationLabelDescriptor]:
- """Return the :class:`LocationLabel` that matches the given
- ``userLocation``, or ``None`` if no such label exists.
- .. versionadded:: 5.0
- """
- return next(
- (
- label
- for label in self.locationLabels
- if label.userLocation == userLocation
- ),
- None,
- )
- def updateFilenameFromPath(self, masters=True, instances=True, force=False):
- """Set a descriptor filename attr from the path and this document path.
- If the filename attribute is not None: skip it.
- """
- if masters:
- for descriptor in self.sources:
- if descriptor.filename is not None and not force:
- continue
- if self.path is not None:
- descriptor.filename = self._posixRelativePath(descriptor.path)
- if instances:
- for descriptor in self.instances:
- if descriptor.filename is not None and not force:
- continue
- if self.path is not None:
- descriptor.filename = self._posixRelativePath(descriptor.path)
- def newAxisDescriptor(self):
- """Ask the writer class to make us a new axisDescriptor."""
- return self.writerClass.getAxisDecriptor()
- def newSourceDescriptor(self):
- """Ask the writer class to make us a new sourceDescriptor."""
- return self.writerClass.getSourceDescriptor()
- def newInstanceDescriptor(self):
- """Ask the writer class to make us a new instanceDescriptor."""
- return self.writerClass.getInstanceDescriptor()
- def getAxisOrder(self):
- """Return a list of axis names, in the same order as defined in the document."""
- names = []
- for axisDescriptor in self.axes:
- names.append(axisDescriptor.name)
- return names
- def getAxis(self, name: str) -> AxisDescriptor | DiscreteAxisDescriptor | None:
- """Return the axis with the given ``name``, or ``None`` if no such axis exists."""
- return next((axis for axis in self.axes if axis.name == name), None)
- def getAxisByTag(self, tag: str) -> AxisDescriptor | DiscreteAxisDescriptor | None:
- """Return the axis with the given ``tag``, or ``None`` if no such axis exists."""
- return next((axis for axis in self.axes if axis.tag == tag), None)
- def getLocationLabel(self, name: str) -> Optional[LocationLabelDescriptor]:
- """Return the top-level location label with the given ``name``, or
- ``None`` if no such label exists.
- .. versionadded:: 5.0
- """
- for label in self.locationLabels:
- if label.name == name:
- return label
- return None
- def map_forward(self, userLocation: SimpleLocationDict) -> SimpleLocationDict:
- """Map a user location to a design location.
- Assume that missing coordinates are at the default location for that axis.
- Note: the output won't be anisotropic, only the xvalue is set.
- .. versionadded:: 5.0
- """
- return {
- axis.name: axis.map_forward(userLocation.get(axis.name, axis.default))
- for axis in self.axes
- }
- def map_backward(
- self, designLocation: AnisotropicLocationDict
- ) -> SimpleLocationDict:
- """Map a design location to a user location.
- Assume that missing coordinates are at the default location for that axis.
- When the input has anisotropic locations, only the xvalue is used.
- .. versionadded:: 5.0
- """
- return {
- axis.name: (
- axis.map_backward(designLocation[axis.name])
- if axis.name in designLocation
- else axis.default
- )
- for axis in self.axes
- }
- def findDefault(self):
- """Set and return SourceDescriptor at the default location or None.
- The default location is the set of all `default` values in user space
- of all axes.
- This function updates the document's :attr:`default` value.
- .. versionchanged:: 5.0
- Allow the default source to not specify some of the axis values, and
- they are assumed to be the default.
- See :meth:`SourceDescriptor.getFullDesignLocation()`
- """
- self.default = None
- # Convert the default location from user space to design space before comparing
- # it against the SourceDescriptor locations (always in design space).
- defaultDesignLocation = self.newDefaultLocation()
- for sourceDescriptor in self.sources:
- if sourceDescriptor.getFullDesignLocation(self) == defaultDesignLocation:
- self.default = sourceDescriptor
- return sourceDescriptor
- return None
- def normalizeLocation(self, location):
- """Return a dict with normalized axis values."""
- from fontTools.varLib.models import normalizeValue
- new = {}
- for axis in self.axes:
- if axis.name not in location:
- # skipping this dimension it seems
- continue
- value = location[axis.name]
- # 'anisotropic' location, take first coord only
- if isinstance(value, tuple):
- value = value[0]
- triple = [
- axis.map_forward(v) for v in (axis.minimum, axis.default, axis.maximum)
- ]
- new[axis.name] = normalizeValue(value, triple)
- return new
- def normalize(self):
- """
- Normalise the geometry of this designspace:
- - scale all the locations of all masters and instances to the -1 - 0 - 1 value.
- - we need the axis data to do the scaling, so we do those last.
- """
- # masters
- for item in self.sources:
- item.location = self.normalizeLocation(item.location)
- # instances
- for item in self.instances:
- # glyph masters for this instance
- for _, glyphData in item.glyphs.items():
- glyphData["instanceLocation"] = self.normalizeLocation(
- glyphData["instanceLocation"]
- )
- for glyphMaster in glyphData["masters"]:
- glyphMaster["location"] = self.normalizeLocation(
- glyphMaster["location"]
- )
- item.location = self.normalizeLocation(item.location)
- # the axes
- for axis in self.axes:
- # scale the map first
- newMap = []
- for inputValue, outputValue in axis.map:
- newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(
- axis.name
- )
- newMap.append((inputValue, newOutputValue))
- if newMap:
- axis.map = newMap
- # finally the axis values
- minimum = self.normalizeLocation({axis.name: axis.minimum}).get(axis.name)
- maximum = self.normalizeLocation({axis.name: axis.maximum}).get(axis.name)
- default = self.normalizeLocation({axis.name: axis.default}).get(axis.name)
- # and set them in the axis.minimum
- axis.minimum = minimum
- axis.maximum = maximum
- axis.default = default
- # now the rules
- for rule in self.rules:
- newConditionSets = []
- for conditions in rule.conditionSets:
- newConditions = []
- for cond in conditions:
- if cond.get("minimum") is not None:
- minimum = self.normalizeLocation(
- {cond["name"]: cond["minimum"]}
- ).get(cond["name"])
- else:
- minimum = None
- if cond.get("maximum") is not None:
- maximum = self.normalizeLocation(
- {cond["name"]: cond["maximum"]}
- ).get(cond["name"])
- else:
- maximum = None
- newConditions.append(
- dict(name=cond["name"], minimum=minimum, maximum=maximum)
- )
- newConditionSets.append(newConditions)
- rule.conditionSets = newConditionSets
- def loadSourceFonts(self, opener, **kwargs):
- """Ensure SourceDescriptor.font attributes are loaded, and return list of fonts.
- Takes a callable which initializes a new font object (e.g. TTFont, or
- defcon.Font, etc.) from the SourceDescriptor.path, and sets the
- SourceDescriptor.font attribute.
- If the font attribute is already not None, it is not loaded again.
- Fonts with the same path are only loaded once and shared among SourceDescriptors.
- For example, to load UFO sources using defcon:
- designspace = DesignSpaceDocument.fromfile("path/to/my.designspace")
- designspace.loadSourceFonts(defcon.Font)
- Or to load masters as FontTools binary fonts, including extra options:
- designspace.loadSourceFonts(ttLib.TTFont, recalcBBoxes=False)
- Args:
- opener (Callable): takes one required positional argument, the source.path,
- and an optional list of keyword arguments, and returns a new font object
- loaded from the path.
- **kwargs: extra options passed on to the opener function.
- Returns:
- List of font objects in the order they appear in the sources list.
- """
- # we load fonts with the same source.path only once
- loaded = {}
- fonts = []
- for source in self.sources:
- if source.font is not None: # font already loaded
- fonts.append(source.font)
- continue
- if source.path in loaded:
- source.font = loaded[source.path]
- else:
- if source.path is None:
- raise DesignSpaceDocumentError(
- "Designspace source '%s' has no 'path' attribute"
- % (source.name or "<Unknown>")
- )
- source.font = opener(source.path, **kwargs)
- loaded[source.path] = source.font
- fonts.append(source.font)
- return fonts
- @property
- def formatTuple(self):
- """Return the formatVersion as a tuple of (major, minor).
- .. versionadded:: 5.0
- """
- if self.formatVersion is None:
- return (5, 0)
- numbers = (int(i) for i in self.formatVersion.split("."))
- major = next(numbers)
- minor = next(numbers, 0)
- return (major, minor)
- def getVariableFonts(self) -> List[VariableFontDescriptor]:
- """Return all variable fonts defined in this document, or implicit
- variable fonts that can be built from the document's continuous axes.
- In the case of Designspace documents before version 5, the whole
- document was implicitly describing a variable font that covers the
- whole space.
- In version 5 and above documents, there can be as many variable fonts
- as there are locations on discrete axes.
- .. seealso:: :func:`splitInterpolable`
- .. versionadded:: 5.0
- """
- if self.variableFonts:
- return self.variableFonts
- variableFonts = []
- discreteAxes = []
- rangeAxisSubsets: List[
- Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]
- ] = []
- for axis in self.axes:
- if hasattr(axis, "values"):
- # Mypy doesn't support narrowing union types via hasattr()
- # TODO(Python 3.10): use TypeGuard
- # https://mypy.readthedocs.io/en/stable/type_narrowing.html
- axis = cast(DiscreteAxisDescriptor, axis)
- discreteAxes.append(axis) # type: ignore
- else:
- rangeAxisSubsets.append(RangeAxisSubsetDescriptor(name=axis.name))
- valueCombinations = itertools.product(*[axis.values for axis in discreteAxes])
- for values in valueCombinations:
- basename = None
- if self.filename is not None:
- basename = os.path.splitext(self.filename)[0] + "-VF"
- if self.path is not None:
- basename = os.path.splitext(os.path.basename(self.path))[0] + "-VF"
- if basename is None:
- basename = "VF"
- axisNames = "".join(
- [f"-{axis.tag}{value}" for axis, value in zip(discreteAxes, values)]
- )
- variableFonts.append(
- VariableFontDescriptor(
- name=f"{basename}{axisNames}",
- axisSubsets=rangeAxisSubsets
- + [
- ValueAxisSubsetDescriptor(name=axis.name, userValue=value)
- for axis, value in zip(discreteAxes, values)
- ],
- )
- )
- return variableFonts
- def deepcopyExceptFonts(self):
- """Allow deep-copying a DesignSpace document without deep-copying
- attached UFO fonts or TTFont objects. The :attr:`font` attribute
- is shared by reference between the original and the copy.
- .. versionadded:: 5.0
- """
- fonts = [source.font for source in self.sources]
- try:
- for source in self.sources:
- source.font = None
- res = copy.deepcopy(self)
- for source, font in zip(res.sources, fonts):
- source.font = font
- return res
- finally:
- for source, font in zip(self.sources, fonts):
- source.font = font
|