12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338 |
- """
- designSpaceDocument
- - Read and write designspace files
- """
- 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
- __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[SimpleLocationDict]):
- 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") -> SimpleLocationDict:
- """Get the complete design location of this source, from its
- :attr:`designLocation` and the document's axis defaults.
- .. versionadded:: 5.0
- """
- result: SimpleLocationDict = {}
- 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,
- description=None,
- groupDescription=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
- """
- self.description = description
- """string. A description of the mapping.
- varLib.
- .. versionadded:: 5.2
- """
- self.groupDescription = groupDescription
- """string. A description of the group of mappings.
- varLib.
- .. versionadded:: 5.2
- """
- 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 = None
- lastGroup = object()
- for mappingObject in self.documentObject.axisMappings:
- if getattr(mappingObject, "groupDescription", None) != lastGroup:
- if mappingsElement is not None:
- self.root.findall(".axes")[0].append(mappingsElement)
- lastGroup = getattr(mappingObject, "groupDescription", None)
- mappingsElement = ET.Element("mappings")
- if lastGroup is not None:
- mappingsElement.attrib["description"] = lastGroup
- self._addAxisMapping(mappingsElement, mappingObject)
- if mappingsElement is not None:
- self.root.findall(".axes")[0].append(mappingsElement)
- 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 is not None 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")
- if getattr(mappingObject, "description", None) is not None:
- mappingElement.attrib["description"] = mappingObject.description
- 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
- self.documentObject.axisMappings = []
- for mappingsElement in self.root.findall(".axes/mappings"):
- groupDescription = mappingsElement.attrib.get("description")
- for mappingElement in mappingsElement.findall("mapping"):
- description = mappingElement.attrib.get("description")
- 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,
- description=description,
- groupDescription=groupDescription,
- )
- 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
- def main(args=None):
- """Roundtrip .designspace file through the DesignSpaceDocument class"""
- if args is None:
- import sys
- args = sys.argv[1:]
- from argparse import ArgumentParser
- parser = ArgumentParser(prog="designspaceLib", description=main.__doc__)
- parser.add_argument("input")
- parser.add_argument("output")
- options = parser.parse_args(args)
- ds = DesignSpaceDocument.fromfile(options.input)
- ds.write(options.output)
|