1# -*- coding: utf-8 -*-
2# -----------------------------------------------------------------------------
3# Name:         style.py
4# Purpose:      Music21 classes for non-analytic display properties
5#
6# Authors:      Michael Scott Cuthbert
7#
8# Copyright:    Copyright © 2016 Michael Scott Cuthbert and the music21
9#               Project
10# License:      BSD, see license.txt
11# -----------------------------------------------------------------------------
12'''
13The style module represents information about the style of a Note, Accidental,
14etc. such that precise positioning information, layout, size, etc. can be specified.
15'''
16from typing import Optional, Union
17import unittest
18
19from music21 import common
20from music21 import exceptions21
21from music21.prebase import ProtoM21Object
22
23
24class TextFormatException(exceptions21.Music21Exception):
25    pass
26
27
28class Style(ProtoM21Object):
29    '''
30    A style object is a lightweight object that
31    keeps track of information about the look of an object.
32
33    >>> st = style.Style()
34    >>> st.units
35    'tenths'
36    >>> st.absoluteX is None
37    True
38
39    >>> st.absoluteX = 20.4
40    >>> st.absoluteX
41    20.4
42
43    '''
44    _DOC_ATTR = {
45        'hideObjectOnPrint': '''if set to `True` will not print upon output
46            (only used in MusicXML output at this point and
47            Lilypond for notes, chords, and rests).''',
48    }
49
50    def __init__(self):
51        self.size = None
52
53        self.relativeX: Optional[Union[float, int]] = None
54        self.relativeY: Optional[Union[float, int]] = None
55        self.absoluteX: Optional[Union[float, int]] = None
56
57        # managed by property below.
58        self._absoluteY: Optional[Union[float, int]] = None
59
60        self._enclosure: Optional[str] = None
61
62        # how should this symbol be represented in the font?
63        # SMuFL characters are allowed.
64        self.fontRepresentation = None
65
66        self.color: Optional[str] = None
67
68        self.units: str = 'tenths'
69        self.hideObjectOnPrint: bool = False
70
71    def _getEnclosure(self):
72        return self._enclosure
73
74    def _setEnclosure(self, value):
75        if value is None:
76            self._enclosure = value
77        elif value == 'none':
78            self._enclosure = None
79        elif value.lower() in ('rectangle', 'square', 'oval', 'circle',
80                               'bracket', 'triangle', 'diamond',
81                               'pentagon', 'hexagon', 'heptagon', 'octagon',
82                               'nonagon', 'decagon'):
83            self._enclosure = value.lower()
84        else:
85            raise TextFormatException(f'Not a supported enclosure: {value}')
86
87    enclosure = property(_getEnclosure,
88                         _setEnclosure,
89                         doc='''
90        Get or set the enclosure.  Valid names are
91        rectangle, square, oval, circle, bracket, triangle, diamond,
92        pentagon, hexagon, heptagon, octagon,
93        nonagon, decagon or None.
94
95
96        >>> tst = style.TextStyle()
97        >>> tst.enclosure = None
98        >>> tst.enclosure = 'rectangle'
99        >>> tst.enclosure
100        'rectangle'
101
102        ''')
103
104    def _getAbsoluteY(self):
105        return self._absoluteY
106
107    def _setAbsoluteY(self, value):
108        if value is None:
109            self._absoluteY = None
110        elif value == 'above':
111            self._absoluteY = 10
112        elif value == 'below':
113            self._absoluteY = -70
114        else:
115            try:
116                self._absoluteY = common.numToIntOrFloat(value)
117            except ValueError as ve:
118                raise TextFormatException(
119                    f'Not a supported absoluteY position: {value!r}'
120                ) from ve
121
122    absoluteY = property(_getAbsoluteY,
123                         _setAbsoluteY,
124                         doc='''
125        Get or set the vertical position, where 0
126        is the top line of the staff and units
127        are in 10ths of a staff space.
128
129        Other legal positions are 'above' and 'below' which
130        are synonyms for 10 and -70 respectively (for 5-line
131        staves; other staves are not yet implemented)
132
133        >>> te = style.Style()
134        >>> te.absoluteY = 10
135        >>> te.absoluteY
136        10
137
138        >>> te.absoluteY = 'below'
139        >>> te.absoluteY
140        -70
141        ''')
142
143
144class NoteStyle(Style):
145    '''
146    A Style object that also includes stem and accidental style information.
147
148    Beam style is stored on the Beams object, as is lyric style
149    '''
150
151    def __init__(self):
152        super().__init__()
153        self.stemStyle = None
154        self.accidentalStyle = None
155        self.noteSize = None  # can be 'cue' etc.
156
157
158class TextStyle(Style):
159    '''
160    A Style object that also includes text formatting.
161
162    >>> ts = style.TextStyle()
163    >>> ts.classes
164    ('TextStyle', 'Style', 'ProtoM21Object', 'object')
165    '''
166
167    def __init__(self):
168        super().__init__()
169        self._fontFamily = None
170        self._fontSize = None
171        self._fontStyle = None
172        self._fontWeight = None
173        self._letterSpacing = None
174
175        self.lineHeight = None
176        self.textDirection = None
177        self.textRotation = None
178        self.language = None
179        # this might be a complex device -- underline, overline, line-through etc.
180        self.textDecoration = None
181
182        self._justify = None
183        self._alignHorizontal = None
184        self._alignVertical = None
185
186    def _getAlignVertical(self):
187        return self._alignVertical
188
189    def _setAlignVertical(self, value):
190        if value in (None, 'top', 'middle', 'bottom', 'baseline'):
191            self._alignVertical = value
192        else:
193            raise TextFormatException(f'invalid vertical align: {value}')
194
195    alignVertical = property(_getAlignVertical,
196                             _setAlignVertical,
197                             doc='''
198        Get or set the vertical align. Valid values are top, middle, bottom, baseline
199        or None
200
201        >>> te = style.TextStyle()
202        >>> te.alignVertical = 'top'
203        >>> te.alignVertical
204        'top'
205        ''')
206
207    def _getAlignHorizontal(self):
208        return self._alignHorizontal
209
210    def _setAlignHorizontal(self, value):
211        if value in (None, 'left', 'right', 'center'):
212            self._alignHorizontal = value
213        else:
214            raise TextFormatException(f'invalid horizontal align: {value}')
215
216    alignHorizontal = property(_getAlignHorizontal,
217                               _setAlignHorizontal,
218                               doc='''
219        Get or set the horizontal alignment.  Valid values are left, right, center,
220        or None
221
222
223        >>> te = style.TextStyle()
224        >>> te.alignHorizontal = 'right'
225        >>> te.alignHorizontal
226        'right'
227        ''')
228
229    def _getJustify(self):
230        return self._justify
231
232    def _setJustify(self, value):
233        if value is None:
234            self._justify = None
235        else:
236            if value.lower() not in ('left', 'center', 'right', 'full'):
237                raise TextFormatException(f'Not a supported justification: {value}')
238            self._justify = value.lower()
239
240    justify = property(_getJustify,
241                       _setJustify,
242                       doc='''
243        Get or set the justification.  Valid values are left,
244        center, right, full (not supported by MusicXML), and None
245
246        >>> tst = style.TextStyle()
247        >>> tst.justify = 'center'
248        >>> tst.justify
249        'center'
250        ''')
251
252    def _getStyle(self):
253        return self._fontStyle
254
255    def _setStyle(self, value):
256        if value is None:
257            self._fontStyle = None
258        else:
259            if value.lower() not in ('italic', 'normal', 'bold', 'bolditalic'):
260                raise TextFormatException(f'Not a supported fontStyle: {value}')
261            self._fontStyle = value.lower()
262
263    fontStyle = property(_getStyle,
264                         _setStyle,
265                         doc='''
266        Get or set the style, as normal, italic, bold, and bolditalic.
267
268        >>> tst = style.TextStyle()
269        >>> tst.fontStyle = 'bold'
270        >>> tst.fontStyle
271        'bold'
272        ''')
273
274    def _getWeight(self):
275        return self._fontWeight
276
277    def _setWeight(self, value):
278        if value is None:
279            self._fontWeight = None
280        else:
281            if value.lower() not in ('normal', 'bold'):
282                raise TextFormatException(f'Not a supported fontWeight: {value}')
283            self._fontWeight = value.lower()
284
285    fontWeight = property(_getWeight,
286                          _setWeight,
287                          doc='''
288        Get or set the weight, as normal, or bold.
289
290        >>> tst = style.TextStyle()
291        >>> tst.fontWeight = 'bold'
292        >>> tst.fontWeight
293        'bold'
294        ''')
295
296    def _getSize(self):
297        return self._fontSize
298
299    def _setSize(self, value):
300        if value is not None:
301            try:
302                value = common.numToIntOrFloat(value)
303            except ValueError:
304                pass  # MusicXML font sizes can be CSS strings.
305                # raise TextFormatException('Not a supported size: %s' % value)
306        self._fontSize = value
307
308    fontSize = property(_getSize,
309                        _setSize,
310                        doc='''
311        Get or set the size.  Best, an int or float, but also a css font size
312
313        >>> tst = style.TextStyle()
314        >>> tst.fontSize = 20
315        >>> tst.fontSize
316        20
317        ''')
318
319    def _getLetterSpacing(self):
320        return self._letterSpacing
321
322    def _setLetterSpacing(self, value):
323        if value != 'normal' and value is not None:
324            # convert to number
325            try:
326                value = float(value)
327            except ValueError as ve:
328                raise TextFormatException(
329                    f'Not a supported letterSpacing: {value!r}'
330                ) from ve
331
332        self._letterSpacing = value
333
334    letterSpacing = property(_getLetterSpacing,
335                             _setLetterSpacing,
336                             doc='''
337         Get or set the letter spacing.
338
339        >>> tst = style.TextStyle()
340        >>> tst.letterSpacing = 20
341        >>> tst.letterSpacing
342        20.0
343        >>> tst.letterSpacing = 'normal'
344        ''')
345
346    @property
347    def fontFamily(self):
348        '''
349        Returns a list of font family names associated with
350        the style, or sets the font family name list.
351
352        If a single string is passed then it is converted to
353        a list.
354
355        >>> ts = style.TextStyle()
356        >>> ff = ts.fontFamily
357        >>> ff
358        []
359        >>> ff.append('Times')
360        >>> ts.fontFamily
361        ['Times']
362        >>> ts.fontFamily.append('Garamond')
363        >>> ts.fontFamily
364        ['Times', 'Garamond']
365        >>> ts.fontFamily = 'Helvetica, sans-serif'
366        >>> ts.fontFamily
367        ['Helvetica', 'sans-serif']
368        '''
369        if self._fontFamily is None:
370            self._fontFamily = []
371        return self._fontFamily
372
373    @fontFamily.setter
374    def fontFamily(self, newFamily):
375        if common.isIterable(newFamily):
376            self._fontFamily = newFamily
377        else:
378            self._fontFamily = [f.strip() for f in newFamily.split(',')]
379
380
381class TextStylePlacement(TextStyle):
382    '''
383    TextStyle plus a placement attribute
384    '''
385
386    def __init__(self):
387        super().__init__()
388        self.placement = None
389
390
391class BezierStyle(Style):
392    '''
393    From the MusicXML Definition.
394    '''
395
396    def __init__(self):
397        super().__init__()
398
399        self.bezierOffset = None
400        self.bezierOffset2 = None
401
402        self.bezierX = None
403        self.bezierY = None
404        self.bezierX2 = None
405        self.bezierY2 = None
406
407
408class LineStyle(Style):
409    '''
410    from the MusicXML Definition
411
412    Defines lineShape ('straight', 'curved' or None)
413    lineType ('solid', 'dashed', 'dotted', 'wavy' or None)
414    dashLength (in tenths)
415    spaceLength (in tenths)
416    '''
417
418    def __init__(self):
419        super().__init__()
420
421        self.lineShape = None
422        self.lineType = None
423        self.dashLength = None
424        self.spaceLength = None
425
426
427class StreamStyle(Style):
428    '''
429    Includes several elements in the MusicXML <appearance> tag in <defaults>
430    along with <music-font> and <word-font>
431    '''
432
433    def __init__(self):
434        super().__init__()
435        self.lineWidths = []  # two-tuples of type, width measured in tenths
436        self.noteSizes = []  # two-tuples of type and percentages of the normal size
437        self.distances = []  # two-tuples of beam or hyphen and tenths
438        self.otherAppearances = []  # two-tuples of type and tenths
439        self.musicFont = None  # None or a TextStyle object
440        self.wordFont = None  # None or a TextStyle object
441        self.lyricFonts = []  # a list of TextStyle objects
442        self.lyricLanguages = []  # a list of strings
443
444        self.printPartName = True
445        self.printPartAbbreviation = True
446
447        # can be None -- meaning no comment,
448        # 'none', 'measure', or 'system'...
449        self.measureNumbering = None
450        self.measureNumberStyle = None
451
452
453class BeamStyle(Style):
454    '''
455    Style for beams
456    '''
457
458    def __init__(self):
459        super().__init__()
460        self.fan = None
461
462
463class StyleMixin(common.SlottedObjectMixin):
464    '''
465    Mixin for any class that wants to support style and editorial, since several
466    non-music21 objects, such as Lyrics and Accidentals will support Style.
467
468    Not used by Music21Objects because of the added trouble in copying etc. so
469    there is code duplication with base.Music21Object
470    '''
471    _styleClass = Style
472
473    __slots__ = ('_style', '_editorial')
474
475    def __init__(self):
476        #  no need to call super().__init__() on SlottedObjectMixin
477        self._style = None
478        self._editorial = None
479
480    @property
481    def hasStyleInformation(self):
482        '''
483        Returns True if there is a :class:`~music21.style.Style` object
484        already associated with this object, False otherwise.
485
486        Calling .style on an object will always create a new
487        Style object, so even though a new Style object isn't too expensive
488        to create, this property helps to prevent creating new Styles more than
489        necessary.
490
491        >>> lObj = note.Lyric('hello')
492        >>> lObj.hasStyleInformation
493        False
494        >>> lObj.style
495        <music21.style.TextStylePlacement object at 0x10b0a2080>
496        >>> lObj.hasStyleInformation
497        True
498        '''
499        try:
500            self._style
501        except AttributeError:
502            pass
503
504        return not (self._style is None)
505
506    @property
507    def style(self):
508        '''
509        Returns (or Creates and then Returns) the Style object
510        associated with this object, or sets a new
511        style object.  Different classes might use
512        different Style objects because they might have different
513        style needs (such as text formatting or bezier positioning)
514
515        Eventually will also query the groups to see if they have
516        any styles associated with them.
517
518        >>> acc = pitch.Accidental()
519        >>> st = acc.style
520        >>> st
521        <music21.style.TextStyle object at 0x10ba96208>
522        >>> st.absoluteX = 20.0
523        >>> st.absoluteX
524        20.0
525        >>> acc.style = style.TextStyle()
526        >>> acc.style.absoluteX is None
527        True
528        '''
529        if self._style is None:
530            styleClass = self._styleClass
531            self._style = styleClass()
532        return self._style
533
534    @style.setter
535    def style(self, newStyle):
536        self._style = newStyle
537
538    @property
539    def hasEditorialInformation(self):
540        '''
541        Returns True if there is a :class:`~music21.editorial.Editorial` object
542        already associated with this object, False otherwise.
543
544        Calling .style on an object will always create a new
545        Style object, so even though a new Style object isn't too expensive
546        to create, this property helps to prevent creating new Styles more than
547        necessary.
548
549        >>> acc = pitch.Accidental('#')
550        >>> acc.hasEditorialInformation
551        False
552        >>> acc.editorial
553        <music21.editorial.Editorial {}>
554        >>> acc.hasEditorialInformation
555        True
556        '''
557        return not (self._editorial is None)
558
559    @property
560    def editorial(self):
561        '''
562        a :class:`~music21.editorial.Editorial` object that stores editorial information
563        (comments, footnotes, harmonic information, ficta).
564
565        Created automatically as needed:
566
567        >>> acc = pitch.Accidental()
568        >>> acc.editorial
569        <music21.editorial.Editorial {}>
570        >>> acc.editorial.ficta = pitch.Accidental('sharp')
571        >>> acc.editorial.ficta
572        <music21.pitch.Accidental sharp>
573        >>> acc.editorial
574        <music21.editorial.Editorial {'ficta': <music21.pitch.Accidental sharp>}>
575        '''
576        from music21 import editorial
577        if self._editorial is None:
578            self._editorial = editorial.Editorial()
579        return self._editorial
580
581    @editorial.setter
582    def editorial(self, ed):
583        self._editorial = ed
584
585
586class Test(unittest.TestCase):
587    pass
588
589
590if __name__ == '__main__':
591    import music21
592    music21.mainTest(Test)  # , runTest='')
593
594