├── flux.png
├── flux.icns
├── .gitignore
├── Rajdhani-Regular.otf
├── .editorconfig
├── gon.json
├── requirements.txt
├── entitlements.plist
├── Flux
├── dividerroutine.py
├── UI
│ ├── qfontfeatures.py
│ ├── qhbshapetrace.py
│ ├── GlyphActions.py
│ ├── qattachmenteditor.py
│ ├── qbufferrenderer.py
│ ├── qglyphname.py
│ ├── classlist.py
│ ├── qshapingdebugger.py
│ ├── glyphpredicateeditor.py
│ ├── featurelist.py
│ ├── lookuplist.py
│ └── qruleeditor.py
├── Plugins
│ ├── IMatra.py
│ ├── __init__.py
│ ├── MedialRa.py
│ ├── NameBasedFeature.py
│ ├── RegexSubstitution.py
│ └── Arabic.py
├── variations.py
├── ThirdParty
│ ├── HTMLDelegate.py
│ ├── QFlowLayout.py
│ ├── QGridLayout.py
│ ├── QFlowGridLayout.py
│ └── qtoaster.py
├── computedroutine.py
├── constants.py
├── project.py
└── editor.py
├── README.md
├── flux.py
├── setup.py
├── attic
├── qvharfbuzz.py
├── ttfontinfo.py
└── vharfbuzz.py
├── tests
└── test_model.py
└── .github
└── workflows
└── buildapp.yaml
/flux.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simoncozens/flux/HEAD/flux.png
--------------------------------------------------------------------------------
/flux.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simoncozens/flux/HEAD/flux.icns
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.fluxml
2 | *.p12
3 | *.glyphs
4 | *.ufo
5 | build/
6 | dist/
7 | *.fea
8 |
--------------------------------------------------------------------------------
/Rajdhani-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simoncozens/flux/HEAD/Rajdhani-Regular.otf
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 4
6 | insert_final_newline = true
7 | trim_trailing_whitespace = true
8 | end_of_line = lf
9 | charset = utf-8
10 |
11 | [*.py]
12 | max_line_length = 119
13 |
14 |
--------------------------------------------------------------------------------
/gon.json:
--------------------------------------------------------------------------------
1 | {
2 | "notarize": [{
3 | "path": "dist/Flux.zip",
4 | "bundle_id": "uk.co.corvelsoftware.flux",
5 | "staple": false
6 | }],
7 |
8 | "apple_id": {
9 | "username": "simon@simon-cozens.org"
10 | }
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | PyQt5==5.15.0
2 | lxml
3 | darkdetect
4 | git+git://github.com/simoncozens/glyphsLib@glyphs3-try3#egg=glyphsLib
5 | git+git://github.com/simoncozens/fontfeatures.git#egg=fontfeatures
6 | git+git://github.com/simoncozens/babelfont.git#egg=babelfont
7 | qcrash
8 | Pillow
9 |
--------------------------------------------------------------------------------
/entitlements.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 | com.apple.security.cs.allow-unsigned-executable-memory
12 |
13 |
14 |
--------------------------------------------------------------------------------
/Flux/dividerroutine.py:
--------------------------------------------------------------------------------
1 | from fontFeatures import Routine
2 | from lxml import etree
3 |
4 |
5 | class DividerRoutine(Routine):
6 | def __init__(self, **kwargs):
7 | self.comment = None
8 | if "comment" in kwargs:
9 | self.comment = kwargs["comment"]
10 | del kwargs["comment"]
11 | super().__init__(**kwargs)
12 |
13 | @property
14 | def rules(self):
15 | return []
16 |
17 | @rules.setter
18 | def rules(self, value):
19 | pass
20 |
21 | def toXML(self):
22 | root = etree.Element("routine")
23 | root.attrib["divider"] = "true"
24 | if self.comment:
25 | root.attrib["comment"] = self.comment
26 | return root
27 |
28 | @classmethod
29 | def fromXML(klass, el):
30 | return klass(comment=el.get("comment"))
31 |
--------------------------------------------------------------------------------
/Flux/UI/qfontfeatures.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import Qt, QDataStream, QMimeData, QVariant
2 | from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QWidget, \
3 | QSplitter, QVBoxLayout, QAbstractItemView, QMenu
4 | from .classlist import GlyphClassList
5 | from .lookuplist import LookupList
6 | from .featurelist import FeatureList
7 |
8 |
9 | class QFontFeaturesPanel(QSplitter):
10 | def __init__(self, project, editor):
11 | self.project = project
12 | self.editor = editor
13 | super(QFontFeaturesPanel, self).__init__()
14 | self.setOrientation(Qt.Vertical)
15 | self.addWidget(GlyphClassList(self.project))
16 | self.lookuplist = LookupList(self.project, self)
17 | self.addWidget(self.lookuplist)
18 | self.featurelist = FeatureList(self.project, self)
19 | self.addWidget(self.featurelist)
20 |
21 | def update(self):
22 | for i in range(0,self.count()):
23 | self.widget(i).update()
24 | super().update()
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Flux: Font Layout UX
2 |
3 | Flux *will be* a font layout editor. Currently this is in
4 | a rapid prototyping ("spike") phase; there are many moving
5 | parts and lots will change.
6 |
7 | Flux relies heavily on my fontFeatures module for
8 | representation of layout rules. Consequently, that module
9 | is *also* rapidly changing to respond to the needs of this
10 | project. All of the edges are bleeding.
11 |
12 | Currently Flux only reads fonts in `.glyphs` format.
13 |
14 | If you want to play:
15 |
16 | ```
17 | pip3 install -r requirements.txt
18 | python3 flux.py
19 | ```
20 |
21 | ## Building an app on OS X
22 |
23 | * Ensure that fontFeatures is installed unpacked (i.e. not as an egg)
24 | * Ensure that lxml.etree is built from source and installed
25 | * Clone `py2app` and hack it as per https://github.com/ronaldoussoren/py2app/issues/271#issuecomment-609078700
26 | * python3 setup.py py2app
27 | * rm -rf dist/flux.app/Contents/Resources/lib/python3.8/PyQt5/Qt/lib/Qt{WebEngine,Designer,Quick}*
28 |
--------------------------------------------------------------------------------
/flux.py:
--------------------------------------------------------------------------------
1 | import sys, os
2 |
3 | if "RESOURCEPATH" in os.environ:
4 | sys.path = [os.path.join(os.environ['RESOURCEPATH'], 'lib', 'python3.8', 'lib-dynload')] + sys.path
5 |
6 | import Flux.ucd
7 |
8 | from Flux.project import FluxProject
9 | from Flux.editor import FluxEditor
10 | from PyQt5.QtWidgets import QApplication
11 |
12 | import qcrash.api as qcrash
13 |
14 | app = QApplication(sys.argv)
15 | app.setApplicationName("Flux")
16 | app.setOrganizationDomain("corvelsoftware.co.uk")
17 | # app.setOrganizationName("Corvel Software")
18 |
19 | email = qcrash.backends.EmailBackend('simon@simon-cozens.org', 'flux')
20 | github = qcrash.backends.GithubBackend('simoncozens', 'flux')
21 | qcrash.install_backend(github)
22 | qcrash.install_backend(email)
23 | qcrash.install_except_hook()
24 |
25 | proj = None
26 | if len(sys.argv) > 1:
27 | if sys.argv[1].endswith(".fluxml"):
28 | proj = FluxProject(sys.argv[1])
29 | else:
30 | proj = FluxProject.new(sys.argv[1])
31 | f = FluxEditor(proj)
32 | f.show()
33 |
34 | sys.exit(app.exec_())
35 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | """
2 | This is a setup.py script generated by py2applet
3 |
4 | Usage:
5 | python setup.py py2app
6 | """
7 |
8 | from setuptools import setup
9 | import Flux.Plugins
10 | import os, pkgutil
11 |
12 | APP = ['flux.py']
13 | DATA_FILES = []
14 | OPTIONS = {
15 | 'iconfile': 'flux.icns',
16 | 'packages': ['flux','ometa', 'terml', 'fontFeatures', 'babelfont', 'qcrash', 'PIL'],
17 | 'excludes': ['PyQt5.QtDesigner', 'PyQt5.QtNetwork', 'PyQt5.QtOpenGL', 'PyQt5.QtScript', 'PyQt5.QtSql', 'PyQt5.QtTest', 'PyQt5.QtWebKit', 'PyQt5.QtXml', 'PyQt5.phonon', 'PyQt5.QtWebEngine'],
18 | 'plist': {
19 | 'CFBundleIdentifier': 'uk.co.corvelsoftware.Flux',
20 | 'UTExportedTypeDeclarations': [{
21 | 'UTTypeIdentifier': 'uk.co.corvelsoftware.Flux',
22 | 'UTTypeTagSpecification': {
23 | 'public.filename-extension': [ 'fluxml' ],
24 | },
25 | 'UTTypeDescription': 'Flux Project File',
26 | 'UTTypeConformsTo': [ 'public.xml' ]
27 | }]
28 | }
29 | }
30 |
31 | setup(
32 | app=APP,
33 | name="Flux",
34 | data_files=DATA_FILES,
35 | options={'py2app': OPTIONS},
36 | setup_requires=['py2app'],
37 | )
38 |
--------------------------------------------------------------------------------
/Flux/Plugins/IMatra.py:
--------------------------------------------------------------------------------
1 | from fontFeatures.feeLib.IMatra import IMatra as IMatra_FF
2 | from PyQt5.QtWidgets import QLabel, QDialog, QLineEdit, QGroupBox, QCompleter, QFormLayout, QComboBox
3 | from Flux.Plugins import FluxPlugin
4 | from Flux.UI.qglyphname import QGlyphName
5 |
6 |
7 | plugin_name = "IMatra Substitution"
8 |
9 | class Dialog(FluxPlugin):
10 | def createForm(self):
11 | form = QGroupBox("IMatra parameters")
12 | layout = QFormLayout()
13 |
14 | self.consonant_edit = QGlyphName(self.project, multiple=True, allow_classes=True)
15 | self.base_matra = QGlyphName(self.project)
16 | self.variants_edit = QGlyphName(self.project, multiple=True, allow_classes=True)
17 |
18 | layout.addRow(QLabel("Consonants"), self.consonant_edit)
19 | layout.addRow(QLabel("Base I-matra glyph"), self.base_matra)
20 | layout.addRow(QLabel("I-matra variants"), self.variants_edit)
21 | form.setLayout(layout)
22 | return form
23 |
24 | def accept(self):
25 | consonants = self.glyphSelector(self.consonant_edit.text())
26 | base_matra = self.glyphSelector(self.base_matra.text())
27 | variants = self.glyphSelector(self.variants_edit.text())
28 | # Ideally we would serialize this routine as an automatic
29 | # one, but for now let's reify it and return.
30 | routines = IMatra_FF.action(self.feeparser, consonants, base_matra, variants)
31 | self.project.fontfeatures.routines.extend(routines)
32 | routines[0].name = "IMatra"
33 | return super().accept()
34 |
--------------------------------------------------------------------------------
/Flux/Plugins/__init__.py:
--------------------------------------------------------------------------------
1 | from fontFeatures.feeLib import FeeParser
2 | from PyQt5.QtWidgets import QLabel, QDialog, QCompleter, QDialogButtonBox, QVBoxLayout
3 | from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, QStringListModel, QSettings
4 | import sys
5 |
6 | class FluxPlugin(QDialog):
7 | def __init__(self, project):
8 | super(QDialog, self).__init__()
9 | self.project = project
10 | self.feeparser = FeeParser(project.font)
11 | self.feeparser.fontfeatures = self.project.fontfeatures
12 | self.setWindowTitle(sys.modules[self.__module__].plugin_name)
13 | QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel
14 | self.buttonBox = QDialogButtonBox(QBtn)
15 | self.buttonBox.accepted.connect(self.accept)
16 | self.buttonBox.rejected.connect(self.reject)
17 | self.form = self.createForm()
18 | self.layout = QVBoxLayout()
19 | self.layout.addWidget(self.form)
20 | self.layout.addWidget(self.buttonBox)
21 | self.setLayout(self.layout)
22 | self.settings = QSettings()
23 | geometry = self.settings.value('plugin%sgeometry' % self.__class__.__name__, '')
24 | if geometry:
25 | self.restoreGeometry(geometry)
26 |
27 | def glyphSelector(self, text):
28 | return self.feeparser.parser(text).glyphselector()
29 |
30 | def accept(self):
31 | geometry = self.saveGeometry()
32 | self.settings.setValue('plugin%sgeometry' % self.__class__.__name__, geometry)
33 | print("Saved geometry")
34 | return super().accept()
35 |
--------------------------------------------------------------------------------
/Flux/Plugins/MedialRa.py:
--------------------------------------------------------------------------------
1 | from fontFeatures.feeLib.MedialRa import MedialRa as MedialRa_FF
2 | from PyQt5.QtWidgets import QLabel, QDialog, QLineEdit, QGroupBox, QCompleter, QFormLayout, QComboBox
3 | from Flux.Plugins import FluxPlugin
4 | from Flux.UI.qglyphname import QGlyphName
5 |
6 |
7 | plugin_name = "MedialRa Substitution"
8 |
9 | class Dialog(FluxPlugin):
10 | def createForm(self):
11 | form = QGroupBox("MedialRa parameters")
12 | layout = QFormLayout()
13 |
14 | self.consonant_edit = QGlyphName(self.project, multiple=True, allow_classes=True)
15 | self.base_matra = QGlyphName(self.project)
16 | self.variants_edit = QGlyphName(self.project, multiple=True, allow_classes=True)
17 |
18 | layout.addRow(QLabel("Consonants"), self.consonant_edit)
19 | layout.addRow(QLabel("Base Medial Ra glyph"), self.base_matra)
20 | layout.addRow(QLabel("Medial Ra variants"), self.variants_edit)
21 | form.setLayout(layout)
22 | return form
23 |
24 | def accept(self):
25 | consonants = self.glyphSelector(self.consonant_edit.text())
26 | base_matra = self.glyphSelector(self.base_matra.text())
27 | variants = self.glyphSelector(self.variants_edit.text())
28 | # Ideally we would serialize this routine as an automatic
29 | # one, but for now let's reify it and return.
30 | routines = MedialRa_FF.action(self.feeparser, consonants, base_matra, variants)
31 | self.project.fontfeatures.routines.extend(routines)
32 | routines[0].name = "MedialRa"
33 | return super().accept()
34 |
--------------------------------------------------------------------------------
/attic/qvharfbuzz.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import QPoint, QMargins
2 | from PyQt5.QtGui import QGlyphRun, QPainter, QRawFont
3 | from PyQt5.QtWidgets import QWidget
4 |
5 |
6 | class QVHarfbuzzWidget(QWidget):
7 | def __init__(self, vharfbuzz, size, buf):
8 | self.vharfbuzz = vharfbuzz
9 | self.size = size
10 | self.buf = buf
11 | self.setup_font()
12 | self.margins = QMargins(25, 25, 25, 25)
13 | super(QVHarfbuzzWidget, self).__init__()
14 |
15 | def set_buf(self, buf):
16 | self.buf = buf
17 | self.update()
18 |
19 | def setup_font(self):
20 | rf = QRawFont()
21 | rf.loadFromData(self.vharfbuzz.fontdata, self.size, 0)
22 | self.rf = rf
23 |
24 | def scale_point(self, x, y):
25 | return QPoint(
26 | x / self.vharfbuzz.upem * self.size, y / self.vharfbuzz.upem * self.size
27 | )
28 |
29 | def paintEvent(self, e):
30 | if not self.buf:
31 | return
32 | qp = QPainter()
33 | qp.begin(self)
34 | g = QGlyphRun()
35 | g.setRawFont(self.rf)
36 | g.setGlyphIndexes([x.codepoint for x in self.buf.glyph_infos])
37 | pos = (0, 0)
38 | poses = []
39 | for _p in self.buf.glyph_positions:
40 | p = _p.position
41 | # Y coordinates go down, not up.
42 | poses.append(self.scale_point(pos[0] + p[0], pos[1] - p[1]))
43 | pos = (pos[0] + p[2], pos[1] + p[3])
44 |
45 | g.setPositions(poses)
46 | qp.drawGlyphRun(e.rect().marginsRemoved(self.margins).bottomLeft(), g)
47 | qp.end()
48 |
--------------------------------------------------------------------------------
/tests/test_model.py:
--------------------------------------------------------------------------------
1 | from Flux.UI.featurelist import FeatureListModel
2 | from Flux.UI.lookuplist import LookupListModel
3 | from Flux.project import FluxProject
4 | from fontFeatures import FontFeatures, Routine, Substitution
5 |
6 | pytest_plugins = ("pytest-qt",)
7 |
8 | def test_featurelist(qtmodeltester):
9 | proj = FluxProject()
10 | proj.fontfeatures = FontFeatures()
11 | r1 = Routine(name="routine1")
12 | r2 = Routine(name="routine2")
13 | proj.fontfeatures.features["test"] = [r1, r2]
14 |
15 | proj = FluxProject("Hind.fluxml")
16 |
17 | model = FeatureListModel(proj)
18 |
19 | root = model.index(-1,-1)
20 | assert(model.describeIndex(root) == "root of tree")
21 | feature1 = model.index(0,0)
22 | assert(model.describeIndex(feature1) == "feature at row 0")
23 | child1 = model.index(0,0,feature1)
24 | assert(child1.parent() == feature1)
25 | assert(model.index(0,0,feature1) == model.index(0,0,feature1))
26 | # import code; code.interact(local=locals())
27 | qtmodeltester.check(model, force_py=True)
28 | qtmodeltester.check(model)
29 |
30 | def test_lookuplist(qtmodeltester):
31 | proj = FluxProject("Hind.fluxml")
32 | # proj = FluxProject()
33 | # proj.fontfeatures = FontFeatures()
34 | # r1 = Routine(name="routine1")
35 | # r1.addRule(Substitution([["a"]], [["b"]]))
36 | # r1.addRule(Substitution([["c"]], [["d"]]))
37 | # r2 = Routine(name="routine2")
38 | # r2.addRule(Substitution([["e"]], [["f"]]))
39 | # proj.fontfeatures.routines = [r1, r2]
40 |
41 | model = LookupListModel(proj)
42 | qtmodeltester.check(model, force_py=True)
43 | qtmodeltester.check(model)
44 |
--------------------------------------------------------------------------------
/Flux/variations.py:
--------------------------------------------------------------------------------
1 | from fontFeatures.shaperLib.Buffer import Buffer, BufferItem, _add_value_records
2 | import weakref
3 |
4 |
5 | class VariationAwareBufferItem(BufferItem):
6 | def prep_glyph(self, font):
7 | super().prep_glyph(font)
8 | # # Interpolate width
9 | if not hasattr(self.buffer(), "vf"):
10 | return
11 | vf = self.buffer().vf
12 | if vf:
13 | glyphs = [vf.masters[master][self.glyph] for master in vf.master_order]
14 | widthset = {vf.master_order[i]: glyphs[i].width for i in range(len(vf.masters))}
15 | self.position.xAdvance = vf.interpolate_tuples(widthset, self.buffer().location)
16 |
17 | @classmethod
18 | def new_unicode(klass, codepoint, buffer=None):
19 | self = klass()
20 | self.codepoint = codepoint
21 | self.glyph = None
22 | self.feature_masks = {}
23 | self.buffer = weakref.ref(buffer)
24 | return self
25 |
26 | @classmethod
27 | def new_glyph(klass, glyph, font, buffer=None):
28 | self = klass()
29 | self.codepoint = None
30 | self.glyph = glyph
31 | self.buffer = weakref.ref(buffer)
32 | self.feature_masks = {}
33 | self.prep_glyph(font)
34 | return self
35 |
36 | def add_position(self, vr2):
37 | if not hasattr(self.buffer(), "vf"):
38 | return super().add_position(vr2)
39 | vf = self.buffer().vf
40 | if vf:
41 | vr2 = vr2.get_value_for_location(vf, self.buffer().location)
42 | _add_value_records(self.position, vr2)
43 |
44 | class VariationAwareBuffer(Buffer):
45 | itemclass = VariationAwareBufferItem
46 |
47 | def store_glyphs(self, glyphs):
48 | self.items = [self.itemclass.new_glyph(g, self.font, self) for g in glyphs]
49 |
50 | def store_unicode(self, unistring):
51 | self.items = [self.itemclass.new_unicode(ord(char), self) for char in unistring ]
52 |
--------------------------------------------------------------------------------
/Flux/ThirdParty/HTMLDelegate.py:
--------------------------------------------------------------------------------
1 | # https://stackoverflow.com/questions/30175644/pyqt-listview-with-html-rich-text-delegate-moves-text-bit-out-of-placepic-and-c
2 |
3 | from PyQt5.QtCore import *
4 | from PyQt5.QtWidgets import *
5 | from PyQt5.QtGui import *
6 | import sys
7 |
8 | class HTMLDelegate(QStyledItemDelegate):
9 | def __init__(self, parent=None):
10 | super().__init__()
11 | self.doc = QTextDocument(self)
12 |
13 | def paint(self, painter, option, index):
14 | painter.save()
15 |
16 | options = QStyleOptionViewItem(option)
17 |
18 | self.initStyleOption(options, index)
19 | self.doc.setHtml(options.text)
20 | options.text = ""
21 |
22 | style = QApplication.style() if options.widget is None \
23 | else options.widget.style()
24 | style.drawControl(QStyle.CE_ItemViewItem, options, painter)
25 |
26 | ctx = QAbstractTextDocumentLayout.PaintContext()
27 |
28 | if option.state & QStyle.State_Selected:
29 | ctx.palette.setColor(QPalette.Text, option.palette.color(
30 | QPalette.Active, QPalette.HighlightedText))
31 | else:
32 | ctx.palette.setColor(QPalette.Text, option.palette.color(
33 | QPalette.Active, QPalette.Text))
34 |
35 | textRect = style.subElementRect(
36 | QStyle.SE_ItemViewItemText, options)
37 |
38 | if index.column() != 0:
39 | textRect.adjust(5, 0, 0, 0)
40 |
41 | thefuckyourshitup_constant = 4
42 | margin = (option.rect.height() - options.fontMetrics.height()) // 2
43 | margin = margin - thefuckyourshitup_constant
44 | textRect.setTop(textRect.top() + margin)
45 |
46 | painter.translate(textRect.topLeft())
47 | painter.setClipRect(textRect.translated(-textRect.topLeft()))
48 | self.doc.documentLayout().draw(painter, ctx)
49 |
50 | painter.restore()
51 |
52 | def sizeHint(self, option, index):
53 | return QSize(self.doc.idealWidth(), self.doc.size().height())
54 |
--------------------------------------------------------------------------------
/Flux/UI/qhbshapetrace.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtGui import QBrush, QColor
2 | from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem
3 | import uharfbuzz as hb
4 |
5 |
6 | class QHBShapeTrace(QTreeWidget):
7 | def __init__(self, font, text):
8 | self.font = font
9 | self.trace = []
10 | super(QHBShapeTrace, self).__init__()
11 | self.setColumnCount(2)
12 | self.set_text(text)
13 |
14 | def set_text(self, text):
15 | newtrace = []
16 | self.clear()
17 | buf = hb.Buffer()
18 | buf.add_str(text)
19 | buf.guess_segment_properties()
20 | buf.set_message_func(self.process_msg)
21 | self.stack = [QTreeWidgetItem(["GSUB", ""])]
22 | self.addTopLevelItem(self.stack[0])
23 | hb.shape(self.font.vharfbuzz.hbfont, buf)
24 |
25 | def routine_name(self, msg):
26 | lu = int(msg[13:])
27 | name = ""
28 | # import code; code.interact(local=locals())
29 | info = self.font.lookup_info(self.stack[0].text(0),lu)
30 | if info.feature:
31 | name = info.feature + ": "
32 | if info.address:
33 | name = name + info.address[0] + ": "
34 | if info.name:
35 | name = name + info.name
36 | else:
37 | name = name + msg[6:]
38 | return name
39 |
40 | def process_msg(self, msg, buf):
41 | buffernow = self.font.vharfbuzz.serialize_buf(buf)
42 | print(msg, buffernow)
43 | if msg.startswith("start table GPOS"):
44 | self.stack = [QTreeWidgetItem(["GPOS", ""])]
45 | self.addTopLevelItem(self.stack[0])
46 | elif msg.startswith("start lookup "):
47 | c = QTreeWidgetItem([self.routine_name(msg), buffernow])
48 | self.stack[-1].addChild(c)
49 | self.stack.append(c)
50 | elif msg.startswith("end lookup "):
51 | if self.stack[-1].text(1) == buffernow:
52 | self.stack[-1].setForeground(0, QBrush(QColor(128,128,128,128)))
53 | self.stack[-1].setForeground(1, QBrush(QColor(128,128,128,128)))
54 | else:
55 | self.stack[-1].setText(1, buffernow)
56 | self.stack.pop()
57 | return True
58 |
--------------------------------------------------------------------------------
/attic/ttfontinfo.py:
--------------------------------------------------------------------------------
1 | from collections import namedtuple
2 | from fontFeatures.ttLib import unparse
3 | from vharfbuzz import Vharfbuzz
4 | import fontFeatures
5 | from fontTools.ttLib import TTFont
6 |
7 | LookupInfo = namedtuple("LookupInfo", ["name", "language", "feature", "address"])
8 |
9 |
10 | class TTFontInfo:
11 | def __init__(self, filename):
12 | self.filename = filename
13 | self.font = TTFont(filename)
14 | self.vharfbuzz = Vharfbuzz(filename)
15 | self.fontfeatures = unparse(self.font)
16 | self.setup_lookups()
17 |
18 | def setup_lookups(self):
19 | self._all_lookups = []
20 | self._lookup_info = {}
21 | self._features = self.fontfeatures.features
22 | for routine in self.fontfeatures.routines:
23 | table, lid = routine.address[0:2]
24 | extra = routine.address[2:]
25 | self._lookup_info[(table, int(lid))] = LookupInfo(
26 | routine.name, None, None, extra
27 | )
28 | self._all_lookups.append(routine)
29 | for key, routines in self._features.items():
30 | for routine in routines:
31 | table, lid = routine.address[0:2]
32 | extra = routine.address[2:]
33 | print(key, routine, lid)
34 | self._lookup_info[(table, int(lid))] = LookupInfo(
35 | routine.name, None, key, extra
36 | )
37 | if routine not in self._all_lookups:
38 | self._all_lookups.append(routine)
39 | for chain in self.fontfeatures.allRules(fontFeatures.Chaining):
40 | for routinelist in chain.lookups:
41 | if not routinelist:
42 | continue
43 | for routine in routinelist:
44 | table, lid = routine.address[0:2]
45 | extra = routine.address[2:]
46 | self._lookup_info[(table, int(lid))] = LookupInfo(
47 | routine.name, None, key, None
48 | )
49 | if routine not in self._all_lookups:
50 | self._all_lookups.append(routine)
51 |
52 | @property
53 | def glyph_classes(self):
54 | return self.fontfeatures.namedClasses
55 |
56 | @property
57 | def all_lookups(self):
58 | return self._all_lookups
59 |
60 | def lookup_info(self, table, lid): # name, language, feature, address
61 | return self._lookup_info[(table, lid)]
62 |
63 | @property
64 | def features(self):
65 | return self._features
66 |
--------------------------------------------------------------------------------
/Flux/computedroutine.py:
--------------------------------------------------------------------------------
1 | from fontFeatures import Routine
2 | from lxml import etree
3 |
4 |
5 | class ComputedRoutine(Routine):
6 | def __init__(self, **kwargs):
7 | self.parameters = {}
8 | self.plugin = ""
9 | self._rules = []
10 | if "parameters" in kwargs:
11 | self.parameters = kwargs["parameters"]
12 | del kwargs["parameters"]
13 | super().__init__(**kwargs)
14 |
15 | @property
16 | def okay(self):
17 | if hasattr(self, "module"):
18 | return True
19 | assert self.project.editor
20 | return self.plugin in self.project.editor.plugins
21 |
22 | @property
23 | def rules(self):
24 | if not self._rules:
25 | assert self.project
26 | if not self.okay:
27 | return []
28 | if not hasattr(self, "module"):
29 | mod = self.project.editor.plugins[self.plugin]
30 | else:
31 | mod = self.module
32 | rules = mod.rulesFromComputedRoutine(self)
33 | for r in rules:
34 | r.computed = True
35 | self._rules = rules
36 | return self._rules
37 |
38 | @rules.setter
39 | def rules(self, value):
40 | pass
41 |
42 | def reify(self):
43 | newroutine = Routine(name=self.name)
44 | newroutine.flags = self.flags
45 | newroutine.languages = self.languages
46 | newroutine.rules = self.rules # Magic
47 | for r in newroutine.rules:
48 | delattr(r, "computed")
49 |
50 | newroutine.markFilteringSet = self.markFilteringSet
51 | newroutine.markAttachmentSet = self.markAttachmentSet
52 | return newroutine
53 |
54 | def toXML(self):
55 | root = etree.Element("routine")
56 | root.attrib["computed"] = "true"
57 | root.attrib["plugin"] = self.plugin
58 | if self.flags:
59 | root.attrib["flags"] = str(self.flags)
60 | if self.address:
61 | root.attrib["address"] = str(self.address)
62 | if self.name:
63 | root.attrib["name"] = self.name
64 | for k, v in self.parameters.items():
65 | param = etree.Element("parameter")
66 | param.attrib["key"] = k
67 | param.attrib["value"] = v
68 | root.append(param)
69 |
70 | return root
71 |
72 | @classmethod
73 | def fromXML(klass, el):
74 | routine = klass(
75 | address=el.get("address"),
76 | name=el.get("name"),
77 | flags=(int(el.get("flags") or 0)),
78 | )
79 | routine.plugin = el.get("plugin")
80 | for p in el:
81 | routine.parameters[p.attrib["key"]] = p.attrib["value"]
82 | return routine
83 |
--------------------------------------------------------------------------------
/.github/workflows/buildapp.yaml:
--------------------------------------------------------------------------------
1 | name: Build application
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: macOS-latest
9 | steps:
10 | - uses: actions/checkout@v2
11 | - uses: apple-actions/import-codesign-certs@v1
12 | with:
13 | p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
14 | p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
15 | - name: Set up gon
16 | run: |
17 | brew tap mitchellh/gon
18 | brew install mitchellh/gon/gon
19 | - name: Set up Python 3.8
20 | uses: actions/setup-python@v2
21 | with:
22 | python-version: 3.8
23 | - name: Rebuild lxml from source
24 | run: |
25 | pip install --no-binary :all: lxml
26 | env:
27 | STATIC_DEPS: "true"
28 | - name: Install dependencies
29 | run: |
30 | python -m pip install --upgrade pip
31 | pip install -r requirements.txt
32 | - name: Run py2app
33 | run: python3 setup.py py2app
34 | - name: Thin package
35 | run: |
36 | rm -rf dist/Flux.app/Contents/Resources/lib/python3.8/PyQt5/Qt/lib/Qt{WebEngine,Designer,Quick}*
37 | rm -rf dist/Flux.app/Contents/Resources/lib/python3.8/PyQt5/Qt/qml/
38 | - name: Codesign package
39 | run: |
40 | codesign -s "Developer ID Application: Simon Cozens (GHYRZM4TBD)" -v --deep --timestamp --entitlements entitlements.plist -o runtime `find dist/Flux.app -name '*.so' -or -name '*.dylib' -or -name '*.framework'` || true
41 | codesign -s "Developer ID Application: Simon Cozens (GHYRZM4TBD)" -v --deep --timestamp --entitlements entitlements.plist -o runtime `find dist/Flux.app -type f | grep 'framework/Versions/5/'` || true
42 | codesign -s "Developer ID Application: Simon Cozens (GHYRZM4TBD)" -v --deep --timestamp --entitlements entitlements.plist -o runtime dist/Flux.app
43 | - name: Package app
44 | run: |
45 | ditto -c -k --keepParent "dist/Flux.app" dist/Flux.zip
46 | - name: Notarize app
47 | run: |
48 | gon gon.json
49 | env:
50 | AC_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
51 | AC_PROVIDER: ${{ secrets.NOTARIZE_PROVIDER }}
52 | - name: Staple app
53 | run: |
54 | xcrun stapler staple dist/Flux.app
55 | xcrun stapler validate dist/Flux.app
56 | - name: Repackage stapled app
57 | run: |
58 | rm -rf dist/Flux.zip
59 | ditto -c -k --keepParent "dist/Flux.app" dist/Flux.zip
60 | - name: Archive production artifacts
61 | uses: actions/upload-artifact@v2
62 | with:
63 | name: application
64 | path: dist/Flux.zip
65 | - name: Trash Python packages
66 | run: rm -rf /Users/runner/hostedtoolcache/Python/3.8.6/x64/lib/python3.8/site-packages/
67 | - name: Test it can run
68 | run: |
69 | mkdir test
70 | cd test
71 | unzip ../dist/Flux.zip
72 | ./Flux.app/Contents/MacOS/Flux &
73 | sleep 15
74 | ps auxw | grep Flux.app | grep -v grep
75 |
--------------------------------------------------------------------------------
/Flux/ThirdParty/QFlowLayout.py:
--------------------------------------------------------------------------------
1 | # Adapted from https://gist.github.com/Cysu/7461066
2 | from PyQt5 import QtCore, QtGui
3 | from PyQt5.QtWidgets import (
4 | QSizePolicy,
5 | QLayout
6 | )
7 |
8 | class QFlowLayout(QLayout):
9 | def __init__(self, parent=None, margin=0, spacing=-1):
10 | super(QFlowLayout, self).__init__(parent)
11 | self.margin = margin
12 |
13 | if parent is not None:
14 | self.setMargin(margin)
15 |
16 | self.setSpacing(spacing)
17 |
18 | self.itemList = []
19 |
20 | def __del__(self):
21 | item = self.takeAt(0)
22 | while item:
23 | item = self.takeAt(0)
24 |
25 | def addItem(self, item):
26 | self.itemList.append(item)
27 |
28 | def count(self):
29 | return len(self.itemList)
30 |
31 | def itemAt(self, index):
32 | if index >= 0 and index < len(self.itemList):
33 | return self.itemList[index]
34 |
35 | return None
36 |
37 | def takeAt(self, index):
38 | if index >= 0 and index < len(self.itemList):
39 | return self.itemList.pop(index)
40 |
41 | return None
42 |
43 | def expandingDirections(self):
44 | return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0))
45 |
46 | def hasHeightForWidth(self):
47 | return True
48 |
49 | def heightForWidth(self, width):
50 | height = self._doLayout(QtCore.QRect(0, 0, width, 0), True)
51 | return height
52 |
53 | def setGeometry(self, rect):
54 | super(QFlowLayout, self).setGeometry(rect)
55 | self._doLayout(rect, False)
56 |
57 | def sizeHint(self):
58 | return self.minimumSize()
59 |
60 | def minimumSize(self):
61 | size = QtCore.QSize()
62 |
63 | for item in self.itemList:
64 | size = size.expandedTo(item.minimumSize())
65 |
66 | size += QtCore.QSize(2 * self.margin, 2 * self.margin)
67 | return size
68 |
69 | def _doLayout(self, rect, testOnly):
70 | x = rect.x()
71 | y = rect.y()
72 | lineHeight = 0
73 |
74 | for item in self.itemList:
75 | wid = item.widget()
76 | spaceX = self.spacing() + wid.style().layoutSpacing(
77 | QSizePolicy.PushButton,
78 | QSizePolicy.PushButton,
79 | QtCore.Qt.Horizontal)
80 |
81 | spaceY = self.spacing() + wid.style().layoutSpacing(
82 | QSizePolicy.PushButton,
83 | QSizePolicy.PushButton,
84 | QtCore.Qt.Vertical)
85 |
86 | nextX = x + item.sizeHint().width() + spaceX
87 | if nextX - spaceX > rect.right() and lineHeight > 0:
88 | x = rect.x()
89 | y = y + lineHeight + spaceY
90 | nextX = x + item.sizeHint().width() + spaceX
91 | lineHeight = 0
92 |
93 | if not testOnly:
94 | item.setGeometry(
95 | QtCore.QRect(QtCore.QPoint(x, y), item.sizeHint()))
96 |
97 | x = nextX
98 | lineHeight = max(lineHeight, item.sizeHint().height())
99 |
100 | return y + lineHeight - rect.y()
101 |
--------------------------------------------------------------------------------
/Flux/Plugins/NameBasedFeature.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QLabel,
3 | QDialog,
4 | QLineEdit,
5 | QGroupBox,
6 | QCompleter,
7 | QFormLayout,
8 | QComboBox,
9 | QMessageBox,
10 | QCheckBox,
11 | )
12 | from PyQt5.QtGui import QValidator
13 | from Flux.Plugins import FluxPlugin
14 | import re
15 | import fontFeatures
16 |
17 |
18 | plugin_name = "Name-Based Features"
19 |
20 |
21 | class NameBasedFeature:
22 | def __init__(self, project):
23 | self.project = project
24 | self.glyphnames = self.project.font.keys()
25 |
26 | def matches(self):
27 | return [g for g in self.glyphnames if re.search(self.glyphSuffixRE + "$", g)]
28 |
29 | def applicable(self):
30 | return len(self.matches()) > 0
31 |
32 | def transform(self, right):
33 | left = [re.sub(self.glyphSuffixRE, "", g) for g in right]
34 | return left
35 |
36 | def apply(self):
37 | right = self.matches()
38 | left = self.transform(right)
39 | left2, right2 = [], []
40 | routine = fontFeatures.Routine(name=self.name.title().replace(" ", ""))
41 | for l, r in zip(left, right):
42 | if l in self.glyphnames and r in self.glyphnames:
43 | routine.addRule(fontFeatures.Substitution([[l]], [[r]]))
44 | self.project.fontfeatures.routines.extend([routine])
45 | self.project.fontfeatures.addFeature(self.feature, [routine])
46 |
47 |
48 | class SlashZero(NameBasedFeature):
49 | glyphSuffixRE = ".zero"
50 | feature = "zero"
51 | name = "slash zero"
52 |
53 | class StandardLigature(NameBasedFeature):
54 | glyphSuffixRE = ".liga|^fi$|^fl$|^f_f(_[il])?$"
55 | feature = "liga"
56 | name = "standard ligatures"
57 |
58 | def apply(self):
59 | right = self.matches()
60 | routine = fontFeatures.Routine(name=self.name.title().replace(" ", ""))
61 | for r in right:
62 | left = r.replace(".liga","").split("_")
63 | if r == "fl" or r == "fi":
64 | left = list(r)
65 | if all(l in self.glyphnames for l in left) and r in self.glyphnames:
66 | routine.addRule(fontFeatures.Substitution([ [l] for l in left], [[r]]))
67 | self.project.fontfeatures.routines.extend([routine])
68 | self.project.fontfeatures.addFeature(self.feature, [routine])
69 |
70 | class SmallCaps(NameBasedFeature):
71 | glyphSuffixRE = ".sc"
72 | feature = "smcp"
73 | name = "small caps"
74 |
75 | def transform(self, right):
76 | left = super().transform(right)
77 | return [g[0].lower() + g[1:] for g in left]
78 |
79 |
80 | class CapToSmallCaps(NameBasedFeature):
81 | glyphSuffixRE = ".sc"
82 | feature = "c2sc"
83 | name = "caps to small caps"
84 |
85 |
86 | class Dialog(FluxPlugin):
87 | tests = [SlashZero, SmallCaps, CapToSmallCaps, StandardLigature]
88 |
89 | def createForm(self):
90 | form = QGroupBox()
91 | layout = QFormLayout()
92 |
93 | self.boxes = []
94 |
95 | for testclass in self.tests:
96 | test = testclass(self.project)
97 | if test.applicable():
98 | box = QCheckBox()
99 | box.setChecked(True)
100 | box.test = test
101 | layout.addRow(QLabel(f"Derive {testclass.name} feature?"), box)
102 | self.boxes.append(box)
103 | else:
104 | layout.addRow(
105 | QLabel(
106 | f"No {testclass.glyphSuffixRE} glyphs found for {testclass.name}"
107 | )
108 | )
109 |
110 | form.setLayout(layout)
111 | return form
112 |
113 | def accept(self):
114 | for b in self.boxes:
115 | if b.isChecked():
116 | b.test.apply()
117 | return super().accept()
118 |
--------------------------------------------------------------------------------
/Flux/ThirdParty/QGridLayout.py:
--------------------------------------------------------------------------------
1 |
2 | # Adapted from https://gist.github.com/Cysu/7461066
3 | from PyQt5 import QtCore, QtGui
4 | from PyQt5.QtWidgets import (
5 | QWidget,
6 | QApplication,
7 | QHBoxLayout,
8 | QLineEdit,
9 | QVBoxLayout,
10 | QPushButton,
11 | QCompleter,
12 | QLabel,
13 | QSizePolicy,
14 | QDialog,
15 | QDialogButtonBox,
16 | QLayout,
17 | QGridLayout,
18 | QStyle
19 | )
20 | class QFlowGridLayout(QLayout):
21 | def __init__(self, parent=None, margin=0, spacing=-1):
22 | super(QFlowGridLayout, self).__init__(parent)
23 | self.margin = margin
24 |
25 | if parent is not None:
26 | self.setContentsMargins(margin, margin, margin, margin)
27 |
28 | self.setSpacing(spacing)
29 |
30 | self.itemList = []
31 |
32 | def __del__(self):
33 | item = self.takeAt(0)
34 | while item:
35 | item = self.takeAt(0)
36 |
37 | def addItem(self, item):
38 | self.itemList.append(item)
39 |
40 | def count(self):
41 | return len(self.itemList)
42 |
43 | def itemAt(self, index):
44 | if index >= 0 and index < len(self.itemList):
45 | return self.itemList[index]
46 |
47 | return None
48 |
49 | def takeAt(self, index):
50 | if index >= 0 and index < len(self.itemList):
51 | return self.itemList.pop(index)
52 |
53 | return None
54 |
55 | def expandingDirections(self):
56 | return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0))
57 |
58 | def hasHeightForWidth(self):
59 | return True
60 |
61 | def heightForWidth(self, width):
62 | height = self._doLayout(QtCore.QRect(0, 0, width, 0), True)
63 | return height
64 |
65 | def setGeometry(self, rect):
66 | super(QFlowGridLayout, self).setGeometry(rect)
67 | self._doLayout(rect, False)
68 |
69 | def sizeHint(self):
70 | return self.minimumSize()
71 |
72 | def minimumSize(self):
73 | size = QtCore.QSize()
74 |
75 | for item in self.itemList:
76 | size = size.expandedTo(item.minimumSize())
77 |
78 | size += QtCore.QSize(2 * self.margin, 2 * self.margin)
79 | return size
80 |
81 | def _doLayout(self, rect, testOnly):
82 | x = rect.x()
83 | y = rect.y()
84 | lineHeight = 0
85 | # SC hacks. I am assuming we are a grid, and all spaceX/spaceY is
86 | # the same. So we only do this once.
87 |
88 | # Find first visible item
89 | item = None
90 | for i in self.itemList:
91 | if i.widget().isVisible():
92 | item = i
93 | break
94 | if not item:
95 | return 0
96 | wid = item.widget()
97 | spaceX = self.spacing() + wid.style().layoutSpacing(
98 | QSizePolicy.PushButton,
99 | QSizePolicy.PushButton,
100 | QtCore.Qt.Horizontal)
101 |
102 | spaceY = self.spacing() + wid.style().layoutSpacing(
103 | QSizePolicy.PushButton,
104 | QSizePolicy.PushButton,
105 | QtCore.Qt.Vertical)
106 |
107 | itemSize = item.sizeHint()
108 | itemWidth = itemSize.width()
109 | itemHeight = itemSize.height()
110 |
111 | for item in self.itemList:
112 | if not item.widget().isVisible():
113 | continue
114 |
115 | nextX = x + itemWidth + spaceX
116 | if nextX - spaceX > rect.right() and lineHeight > 0:
117 | x = rect.x()
118 | y = y + lineHeight + spaceY
119 | nextX = x + itemWidth + spaceX
120 | lineHeight = 0
121 |
122 | if not testOnly:
123 | item.setGeometry(
124 | QtCore.QRect(QtCore.QPoint(x, y), itemSize))
125 |
126 | x = nextX
127 | lineHeight = max(lineHeight, itemHeight)
128 |
129 | return y + lineHeight - rect.y()
130 |
131 |
132 | if __name__ == '__main__':
133 |
134 | import sys
135 |
136 | class Window(QWidget):
137 | def __init__(self):
138 | super(Window, self).__init__()
139 |
140 | flowLayout = QFlowGridLayout()
141 | flowLayout.addWidget(QPushButton("Short"))
142 | flowLayout.addWidget(QPushButton("Longer"))
143 | flowLayout.addWidget(QPushButton("Different text"))
144 | flowLayout.addWidget(QPushButton("More text"))
145 | flowLayout.addWidget(QPushButton("Even longer button text"))
146 | self.setLayout(flowLayout)
147 |
148 | self.setWindowTitle("Flow Layout")
149 |
150 | app = QApplication(sys.argv)
151 | mainWin = Window()
152 | mainWin.show()
153 | sys.exit(app.exec_())
154 |
--------------------------------------------------------------------------------
/Flux/ThirdParty/QFlowGridLayout.py:
--------------------------------------------------------------------------------
1 |
2 | # Adapted from https://gist.github.com/Cysu/7461066
3 | from PyQt5 import QtCore, QtGui
4 | from PyQt5.QtWidgets import (
5 | QWidget,
6 | QApplication,
7 | QHBoxLayout,
8 | QLineEdit,
9 | QVBoxLayout,
10 | QPushButton,
11 | QCompleter,
12 | QLabel,
13 | QSizePolicy,
14 | QDialog,
15 | QDialogButtonBox,
16 | QLayout,
17 | QGridLayout,
18 | QStyle
19 | )
20 | class QFlowGridLayout(QLayout):
21 | def __init__(self, parent=None, margin=0, spacing=-1):
22 | super(QFlowGridLayout, self).__init__(parent)
23 | self.margin = margin
24 |
25 | if parent is not None:
26 | self.setContentsMargins(margin, margin, margin, margin)
27 |
28 | self.setSpacing(spacing)
29 |
30 | self.itemList = []
31 |
32 | def __del__(self):
33 | item = self.takeAt(0)
34 | while item:
35 | item = self.takeAt(0)
36 |
37 | def addItem(self, item):
38 | self.itemList.append(item)
39 |
40 | def count(self):
41 | return len(self.itemList)
42 |
43 | def itemAt(self, index):
44 | if index >= 0 and index < len(self.itemList):
45 | return self.itemList[index]
46 |
47 | return None
48 |
49 | def takeAt(self, index):
50 | if index >= 0 and index < len(self.itemList):
51 | return self.itemList.pop(index)
52 |
53 | return None
54 |
55 | def expandingDirections(self):
56 | return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0))
57 |
58 | def hasHeightForWidth(self):
59 | return True
60 |
61 | def heightForWidth(self, width):
62 | height = self._doLayout(QtCore.QRect(0, 0, width, 0), True)
63 | return height
64 |
65 | def setGeometry(self, rect):
66 | super(QFlowGridLayout, self).setGeometry(rect)
67 | self._doLayout(rect, False)
68 |
69 | def sizeHint(self):
70 | return self.minimumSize()
71 |
72 | def minimumSize(self):
73 | size = QtCore.QSize()
74 |
75 | for item in self.itemList:
76 | size = size.expandedTo(item.minimumSize())
77 |
78 | size += QtCore.QSize(2 * self.margin, 2 * self.margin)
79 | return size
80 |
81 | def _doLayout(self, rect, testOnly):
82 | x = rect.x()
83 | y = rect.y()
84 | lineHeight = 0
85 | # SC hacks. I am assuming we are a grid, and all spaceX/spaceY is
86 | # the same. So we only do this once.
87 |
88 | # Find first visible item
89 | item = None
90 | for i in self.itemList:
91 | if i.widget().isVisible():
92 | item = i
93 | break
94 | if not item:
95 | return 0
96 | wid = item.widget()
97 | spaceX = self.spacing() + wid.style().layoutSpacing(
98 | QSizePolicy.PushButton,
99 | QSizePolicy.PushButton,
100 | QtCore.Qt.Horizontal)
101 |
102 | spaceY = self.spacing() + wid.style().layoutSpacing(
103 | QSizePolicy.PushButton,
104 | QSizePolicy.PushButton,
105 | QtCore.Qt.Vertical)
106 |
107 | itemSize = item.sizeHint()
108 | itemWidth = itemSize.width()
109 | itemHeight = itemSize.height()
110 |
111 | for item in self.itemList:
112 | if not item.widget().isVisible():
113 | continue
114 |
115 | nextX = x + itemWidth + spaceX
116 | if nextX - spaceX > rect.right() and lineHeight > 0:
117 | x = rect.x()
118 | y = y + lineHeight + spaceY
119 | nextX = x + itemWidth + spaceX
120 | lineHeight = 0
121 |
122 | if not testOnly:
123 | item.setGeometry(
124 | QtCore.QRect(QtCore.QPoint(x, y), itemSize))
125 |
126 | x = nextX
127 | lineHeight = max(lineHeight, itemHeight)
128 |
129 | return y + lineHeight - rect.y()
130 |
131 |
132 | if __name__ == '__main__':
133 |
134 | import sys
135 |
136 | class Window(QWidget):
137 | def __init__(self):
138 | super(Window, self).__init__()
139 |
140 | flowLayout = QFlowGridLayout()
141 | flowLayout.addWidget(QPushButton("Short"))
142 | flowLayout.addWidget(QPushButton("Longer"))
143 | flowLayout.addWidget(QPushButton("Different text"))
144 | flowLayout.addWidget(QPushButton("More text"))
145 | flowLayout.addWidget(QPushButton("Even longer button text"))
146 | self.setLayout(flowLayout)
147 |
148 | self.setWindowTitle("Flow Layout")
149 |
150 | app = QApplication(sys.argv)
151 | mainWin = Window()
152 | mainWin.show()
153 | sys.exit(app.exec_())
154 |
--------------------------------------------------------------------------------
/Flux/Plugins/RegexSubstitution.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QLabel,
3 | QDialog,
4 | QLineEdit,
5 | QGroupBox,
6 | QCompleter,
7 | QFormLayout,
8 | QComboBox,
9 | QMessageBox,
10 | QCheckBox,
11 | QVBoxLayout,
12 | QTextEdit,
13 | QWidget
14 | )
15 | from PyQt5.QtGui import QValidator
16 | from Flux.Plugins import FluxPlugin
17 | from Flux.UI.qglyphname import QGlyphName
18 | import re
19 | import fontFeatures
20 | from PyQt5.QtCore import Qt
21 | import sys
22 | from Flux.computedroutine import ComputedRoutine
23 |
24 |
25 | plugin_name = "Regular Expression Substitution"
26 |
27 |
28 | class REValidator(QValidator):
29 | def __init__(self):
30 | super().__init__()
31 |
32 | def validate(self, s, pos):
33 | try:
34 | re.compile(s)
35 | except Exception as e:
36 | return (QValidator.Intermediate, s, pos)
37 | return (QValidator.Acceptable, s, pos)
38 |
39 | def fixup(self, s):
40 | # Trim multiple spaces?
41 | pass
42 |
43 | class Dialog(FluxPlugin):
44 |
45 | def __init__(self, project):
46 | self.project = project
47 | self.glyphnames = self.project.font.keys()
48 | self.routine = None
49 | super().__init__(project)
50 |
51 | def createForm(self):
52 | window = QWidget()
53 | window_layout = QVBoxLayout(window)
54 |
55 | before = QWidget()
56 | before_layout = QFormLayout(before)
57 |
58 | self.routine_name = QLineEdit()
59 | before_layout.addRow(QLabel(f"Routine name"), self.routine_name)
60 |
61 | self.before = QGlyphName(self.project, allow_classes=True, multiple=True)
62 | before_layout.addRow(QLabel(f"Match glyphs before"), self.before)
63 | window_layout.addWidget(before)
64 |
65 | form = QGroupBox()
66 | form_layout = QFormLayout(form)
67 |
68 | self.filter = QLineEdit()
69 | self.filter.setPlaceholderText("Regular expression")
70 | self.filter.setValidator(REValidator())
71 |
72 | self.match = QLineEdit()
73 | self.match.setPlaceholderText("Regular expression")
74 | self.match.setValidator(REValidator())
75 |
76 | self.replace = QLineEdit()
77 |
78 | form_layout.addRow(QLabel(f"Match glyphs"), self.filter)
79 | form_layout.addRow(QLabel(f"Replace ..."), self.match)
80 | form_layout.addRow(QLabel(f"With ..."), self.replace)
81 | window_layout.addWidget(form)
82 |
83 | after = QWidget()
84 | after_layout = QFormLayout(after)
85 | self.after = QGlyphName(self.project, allow_classes=True, multiple=True)
86 | after_layout.addRow(QLabel(f"Match glyphs after"), self.after)
87 | window_layout.addWidget(after)
88 |
89 | self.preview = QTextEdit()
90 | window_layout.addWidget(self.preview)
91 |
92 | self.before.changed.connect(self.recompute)
93 | self.filter.textChanged.connect(self.recompute)
94 | self.match.textChanged.connect(self.recompute)
95 | self.replace.textChanged.connect(self.recompute)
96 | self.after.changed.connect(self.recompute)
97 |
98 | return window
99 |
100 | def parameters(self):
101 | return {
102 | "routine": self.routine_name.text(),
103 | "before": self.before.text(),
104 | "filter": self.filter.text(),
105 | "match": self.match.text(),
106 | "replace": self.replace.text(),
107 | "after": self.after.text(),
108 | }
109 |
110 | def recompute(self):
111 | if not self.filter.hasAcceptableInput() or not self.match.hasAcceptableInput():
112 | return
113 | p = self.parameters()
114 | self.routine = ComputedRoutine(name=p["routine"], parameters = p)
115 | self.routine.plugin = __name__
116 | self.routine.project = self.project
117 | self.routine.module = sys.modules[__name__]
118 | self.preview.setText(self.routine.asFea())
119 |
120 |
121 | def accept(self):
122 | self.project.fontfeatures.routines.extend([self.routine])
123 | return super().accept()
124 |
125 | # Disable enter
126 | def keyPressEvent(self, evt):
127 | if evt.key() == Qt.Key_Enter or evt.key() == Qt.Key_Return:
128 | return
129 | return super().keyPressEvent(evt)
130 |
131 |
132 | def rulesFromComputedRoutine(routine):
133 | p = routine.parameters
134 | glyphnames = routine.project.font.keys()
135 | rules = []
136 | for g in glyphnames:
137 | if not re.search(p["filter"], g):
138 | continue
139 | try:
140 | new = re.sub(p["match"], p["replace"], g)
141 | except:
142 | continue
143 | if new not in glyphnames:
144 | continue
145 | sub = fontFeatures.Substitution( [[g]], [[new]])
146 | # XXX classes
147 | if p["before"]:
148 | sub.input = [ [glyph] for glyph in p["before"].split() ] + sub.input
149 | if p["after"]:
150 | sub.input.extend([ [glyph] for glyph in p["after"].split() ])
151 | rules.append(sub)
152 | return rules
153 |
--------------------------------------------------------------------------------
/Flux/constants.py:
--------------------------------------------------------------------------------
1 |
2 | FEATURE_DESCRIPTIONS = {
3 | 'aalt': "Access All Alternates",
4 | 'abvf': "Above-base Forms",
5 | 'abvm': "Above-base Mark Positioning",
6 | 'abvs': "Above-base Substitutions",
7 | 'afrc': "Alternative Fractions",
8 | 'akhn': "Akhands",
9 | 'blwf': "Below-base Forms",
10 | 'blwm': "Below-base Mark Positioning",
11 | 'blws': "Below-base Substitutions",
12 | 'calt': "Contextual Alternates",
13 | 'case': "Case-Sensitive Forms",
14 | 'ccmp': "Glyph Composition / Decomposition",
15 | 'cfar': "Conjunct Form After Ro",
16 | 'chws': "Contextual Half-width Spacing",
17 | 'cjct': "Conjunct Forms",
18 | 'clig': "Contextual Ligatures",
19 | 'cpct': "Centered CJK Punctuation",
20 | 'cpsp': "Capital Spacing",
21 | 'cswh': "Contextual Swash",
22 | 'curs': "Cursive Positioning",
23 | 'c2pc': "Petite Capitals From Capitals",
24 | 'c2sc': "Small Capitals From Capitals",
25 | 'dist': "Distances",
26 | 'dlig': "Discretionary Ligatures",
27 | 'dnom': "Denominators",
28 | 'dtls': "Dotless Forms",
29 | 'expt': "Expert Forms",
30 | 'falt': "Final Glyph on Line Alternates",
31 | 'fin2': "Terminal Forms #2",
32 | 'fin3': "Terminal Forms #3",
33 | 'fina': "Terminal Forms",
34 | 'flac': "Flattened accent forms",
35 | 'frac': "Fractions",
36 | 'fwid': "Full Widths",
37 | 'half': "Half Forms",
38 | 'haln': "Halant Forms",
39 | 'halt': "Alternate Half Widths",
40 | 'hist': "Historical Forms",
41 | 'hkna': "Horizontal Kana Alternates",
42 | 'hlig': "Historical Ligatures",
43 | 'hngl': "Hangul",
44 | 'hojo': "Hojo Kanji Forms (JIS X 0212-1990 Kanji Forms)",
45 | 'hwid': "Half Widths",
46 | 'init': "Initial Forms",
47 | 'isol': "Isolated Forms",
48 | 'ital': "Italics",
49 | 'jalt': "Justification Alternates",
50 | 'jp78': "JIS78 Forms",
51 | 'jp83': "JIS83 Forms",
52 | 'jp90': "JIS90 Forms",
53 | 'jp04': "JIS2004 Forms",
54 | 'kern': "Kerning",
55 | 'lfbd': "Left Bounds",
56 | 'liga': "Standard Ligatures",
57 | 'ljmo': "Leading Jamo Forms",
58 | 'lnum': "Lining Figures",
59 | 'locl': "Localized Forms",
60 | 'ltra': "Left-to-right alternates",
61 | 'ltrm': "Left-to-right mirrored forms",
62 | 'mark': "Mark Positioning",
63 | 'med2': "Medial Forms #2",
64 | 'medi': "Medial Forms",
65 | 'mgrk': "Mathematical Greek",
66 | 'mkmk': "Mark to Mark Positioning",
67 | 'mset': "Mark Positioning via Substitution",
68 | 'nalt': "Alternate Annotation Forms",
69 | 'nlck': "NLC Kanji Forms",
70 | 'nukt': "Nukta Forms",
71 | 'numr': "Numerators",
72 | 'onum': "Oldstyle Figures",
73 | 'opbd': "Optical Bounds",
74 | 'ordn': "Ordinals",
75 | 'ornm': "Ornaments",
76 | 'palt': "Proportional Alternate Widths",
77 | 'pcap': "Petite Capitals",
78 | 'pkna': "Proportional Kana",
79 | 'pnum': "Proportional Figures",
80 | 'pref': "Pre-Base Forms",
81 | 'pres': "Pre-base Substitutions",
82 | 'pstf': "Post-base Forms",
83 | 'psts': "Post-base Substitutions",
84 | 'pwid': "Proportional Widths",
85 | 'qwid': "Quarter Widths",
86 | 'rand': "Randomize",
87 | 'rclt': "Required Contextual Alternates",
88 | 'rkrf': "Rakar Forms",
89 | 'rlig': "Required Ligatures",
90 | 'rphf': "Reph Forms",
91 | 'rtbd': "Right Bounds",
92 | 'rtla': "Right-to-left alternates",
93 | 'rtlm': "Right-to-left mirrored forms",
94 | 'ruby': "Ruby Notation Forms",
95 | 'rvrn': "Required Variation Alternates",
96 | 'salt': "Stylistic Alternates",
97 | 'sinf': "Scientific Inferiors",
98 | 'size': "Optical size",
99 | 'smcp': "Small Capitals",
100 | 'smpl': "Simplified Forms",
101 | 'ss01': "Stylistic Set 1",
102 | 'ss02': "Stylistic Set 2",
103 | 'ss03': "Stylistic Set 3",
104 | 'ss04': "Stylistic Set 4",
105 | 'ss05': "Stylistic Set 5",
106 | 'ss06': "Stylistic Set 6",
107 | 'ss07': "Stylistic Set 7",
108 | 'ss08': "Stylistic Set 8",
109 | 'ss09': "Stylistic Set 9",
110 | 'ss10': "Stylistic Set 10",
111 | 'ss11': "Stylistic Set 11",
112 | 'ss12': "Stylistic Set 12",
113 | 'ss13': "Stylistic Set 13",
114 | 'ss14': "Stylistic Set 14",
115 | 'ss15': "Stylistic Set 15",
116 | 'ss16': "Stylistic Set 16",
117 | 'ss17': "Stylistic Set 17",
118 | 'ss18': "Stylistic Set 18",
119 | 'ss19': "Stylistic Set 19",
120 | 'ss20': "Stylistic Set 20",
121 | 'ssty': "Math script style alternates",
122 | 'stch': "Stretching Glyph Decomposition",
123 | 'subs': "Subscript",
124 | 'sups': "Superscript",
125 | 'swsh': "Swash",
126 | 'titl': "Titling",
127 | 'tjmo': "Trailing Jamo Forms",
128 | 'tnam': "Traditional Name Forms",
129 | 'tnum': "Tabular Figures",
130 | 'trad': "Traditional Forms",
131 | 'twid': "Third Widths",
132 | 'unic': "Unicase",
133 | 'valt': "Alternate Vertical Metrics",
134 | 'vatu': "Vattu Variants",
135 | 'vchw': "Vertical Contextual Half-width Spacing",
136 | 'vert': "Vertical Writing",
137 | 'vhal': "Alternate Vertical Half Metrics",
138 | 'vjmo': "Vowel Jamo Forms",
139 | 'vkna': "Vertical Kana Alternates",
140 | 'vkrn': "Vertical Kerning",
141 | 'vpal': "Proportional Alternate Vertical Metrics",
142 | 'vrt2': "Vertical Alternates and Rotation",
143 | 'vrtr': "Vertical Alternates for Rotation",
144 | 'zero': "Slashed Zero",
145 | }
146 |
147 | for i in range(1,100):
148 | FEATURE_DESCRIPTIONS["cv%02i" % i] = "Character variant "+str(i)
149 |
--------------------------------------------------------------------------------
/Flux/UI/GlyphActions.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from lxml import etree
3 | from Flux.UI.qglyphname import QGlyphPicker, QGlyphBox
4 | from PyQt5.QtWidgets import (
5 | QWidget,
6 | QDialog,
7 | QFormLayout,
8 | QSpinBox,
9 | QApplication,
10 | QVBoxLayout,
11 | QLabel,
12 | QDialogButtonBox,
13 | QComboBox,
14 | )
15 | from PyQt5.QtCore import Qt
16 | from typing import Optional
17 |
18 |
19 | @dataclass
20 | class GlyphAction:
21 | glyph: str
22 | width: Optional[int] = None
23 | category: Optional[str] = None
24 | duplicate_from: Optional[str] = None
25 |
26 | def toXML(self):
27 | root = etree.Element("glyphaction")
28 | root.attrib["glyph"] = self.glyph
29 | if self.duplicate_from:
30 | root.attrib["duplicate_from"] = self.duplicate_from
31 | if self.width is not None:
32 | root.attrib["width"] = str(self.width)
33 | if self.category:
34 | root.attrib["category"] = self.category
35 | return root
36 |
37 | @classmethod
38 | def fromXML(klass, el):
39 | return klass(
40 | glyph=el.get("glyph"),
41 | duplicate_from=el.get("duplicate_from"),
42 | width=int(el.get("width")),
43 | category=el.get("category"),
44 | )
45 |
46 | def perform(self, font):
47 | if self.duplicate_from:
48 | font[self.glyph] = font[self.duplicate_from].copy()
49 | if self.width is not None:
50 | font[self.glyph].width = self.width
51 | if self.category:
52 | font[self.glyph].set_category(self.category)
53 |
54 | def doesSomething(self, font):
55 | effect = self.duplicate_from is not None
56 | if self.width is not None and self.width != font[self.glyph].width:
57 | effect = True
58 | if self.category is not None and self.category != font[self.glyph].category:
59 | effect = True
60 | return effect
61 |
62 |
63 | class QGlyphActionDialog(QDialog):
64 | def __init__(self, project, glyphname):
65 | super(QDialog, self).__init__()
66 | v_box_1 = QVBoxLayout()
67 | self.project = project
68 | self.glyph = self.project.font[glyphname]
69 |
70 | self.formWidget = QWidget()
71 | self.formLayout = QFormLayout()
72 | self.formWidget.setLayout(self.formLayout)
73 |
74 | self.widthLine = QSpinBox()
75 | self.widthLine.setMinimum(0)
76 | self.widthLine.setMaximum(2000)
77 | self.widthLine.setValue(self.glyph.width)
78 |
79 | self.categoryCB = QComboBox()
80 | items = ["base", "ligature", "mark"]
81 | self.categoryCB.addItems(items)
82 | if self.glyph.category in items:
83 | self.categoryCB.setCurrentIndex(items.index(self.glyph.category))
84 |
85 | self.formLayout.addRow(QLabel(glyphname))
86 | self.formLayout.addRow(QLabel("Width"), self.widthLine)
87 | self.formLayout.addRow(QLabel("Category"), self.categoryCB)
88 |
89 | v_box_1.addWidget(self.formWidget)
90 |
91 | buttons = QDialogButtonBox(
92 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self
93 | )
94 | buttons.accepted.connect(self.accept)
95 | buttons.rejected.connect(self.reject)
96 | v_box_1.addWidget(buttons)
97 | self.setLayout(v_box_1)
98 |
99 | def accept(self):
100 | self.action = self.project.glyphactions.get(
101 | self.glyph.name, GlyphAction(glyph=self.glyph.name)
102 | )
103 | newWidth = self.widthLine.value()
104 | if newWidth != self.glyph.width:
105 | self.action.width = newWidth
106 | newCategory = self.categoryCB.currentText()
107 | if newCategory != self.glyph.category:
108 | self.action.category = newCategory
109 | return super().accept()
110 |
111 |
112 | class QGlyphActionBox(QGlyphBox):
113 | def mouseDoubleClickEvent(self, event):
114 | dialog = QGlyphActionDialog(self.parent.project, self.glyph)
115 | result = dialog.exec_()
116 | if result:
117 | action = dialog.action
118 | if action.doesSomething(self.parent.project.font):
119 | print(etree.tostring(action.toXML()))
120 | action.perform(self.parent.project.font)
121 | self.parent.project.glyphactions[action.glyph] = action
122 |
123 |
124 | class QGlyphActionPicker(QGlyphPicker):
125 | def setupGrid(self):
126 | self.clearLayout(self.qgrid)
127 | for g in self.project.font.keys():
128 | w = QGlyphActionBox(self, g)
129 | self.widgets[g] = w
130 |
131 | @classmethod
132 | def pickGlyph(self, project):
133 | dialog = QGlyphActionPicker(project)
134 | result = dialog.exec_()
135 | if dialog.selected:
136 | return dialog.selected.glyph
137 |
138 |
139 | if __name__ == "__main__":
140 | from Flux.project import FluxProject
141 | import sys
142 |
143 | app = 0
144 | if QApplication.instance():
145 | app = QApplication.instance()
146 | else:
147 | app = QApplication(sys.argv)
148 | proj = FluxProject("qalam.fluxml")
149 | QGlyphActionPicker.pickGlyph(proj)
150 | sys.exit(app.exec_())
151 |
--------------------------------------------------------------------------------
/Flux/UI/qattachmenteditor.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QWidget,
3 | QApplication,
4 | QVBoxLayout,
5 | QLabel,
6 | QComboBox,
7 | QListWidget,
8 | QCheckBox
9 | )
10 | from PyQt5.QtCore import pyqtSlot
11 | from fontFeatures.shaperLib.Shaper import Shaper
12 | from fontFeatures.shaperLib.Buffer import Buffer
13 | from .qruleeditor import QRuleEditor
14 | from fontFeatures import Attachment
15 | import sys
16 |
17 | class QAttachmentEditor(QRuleEditor):
18 | @pyqtSlot()
19 | def changeRepresentativeString(self):
20 | l = self.sender()
21 | glyph = l.currentItem().text()
22 | if l.role == "base":
23 | self.representative_string[0] = glyph
24 | else:
25 | self.representative_string[1] = glyph
26 |
27 | self.resetBuffer()
28 |
29 | @pyqtSlot()
30 | def updateRule(self):
31 | combo = self.sender()
32 | if combo.role == "mark":
33 | self.rule.mark_name = combo.currentText()
34 | self.rule.marks = self.project.fontfeatures.anchors[self.rule.mark_name]
35 | else:
36 | self.rule.base_name = combo.currentText()
37 | self.rule.bases = self.project.fontfeatures.anchors[self.rule.base_name]
38 | self.arrangeSlots()
39 | self.representative_string = self.makeRepresentativeString()
40 | self.resetBuffer()
41 |
42 | def makeFeatureButtons(self):
43 | self.clearLayout(self.featureButtonLayout)
44 | for f in self.availableFeatures:
45 | self.selectedFeatures.append(f)
46 | featureButton = QCheckBox(f)
47 | featureButton.setChecked(False)
48 | featureButton.stateChanged.connect(self.resetBuffer)
49 | self.featureButtonLayout.addWidget(featureButton)
50 |
51 | def makeASlot(self, anchorname, title):
52 | slot = QWidget()
53 | slotLayout = QVBoxLayout()
54 | slot.setLayout(slotLayout)
55 |
56 | label = QLabel(title[0].upper() + title[1:])
57 | slotLayout.addWidget(label)
58 |
59 | anchorChooser = QComboBox()
60 | anchors = list(sorted(self.project.fontfeatures.anchors.keys()))
61 | for a in anchors:
62 | anchorChooser.addItem(a)
63 | if anchorname in anchors:
64 | anchorChooser.setCurrentIndex(anchors.index(anchorname))
65 | anchorChooser.role = title
66 | anchorChooser.currentIndexChanged.connect(self.updateRule)
67 | slotLayout.addWidget(anchorChooser)
68 |
69 | slotLayout.addStretch()
70 |
71 | glyphList = QListWidget()
72 | if anchorname in self.project.fontfeatures.anchors:
73 | for g in self.project.fontfeatures.anchors[anchorname]:
74 | glyphList.addItem(g)
75 | glyphList.role = title
76 | glyphList.currentItemChanged.connect(self.changeRepresentativeString)
77 | slotLayout.addWidget(glyphList)
78 | return slot
79 |
80 | def resetBuffer(self):
81 | # We're not going to display the feature code because it's horrible.
82 | before = self.makeBuffer("before")
83 | if before and len(before.items) == 2:
84 | before.items[0].color = (255,120,120)
85 | before.items[-1].color = (120,255,120)
86 | if self.rule and self.rule.base_name and self.rule.mark_name:
87 | before.items[0].anchor = self.rule.bases[self.representative_string[0]]
88 | before.items[1].anchor = self.rule.marks[self.representative_string[1]]
89 | self.outputview_before.set_buf(before)
90 | self.outputview_after.set_buf(self.makeBuffer("after"))
91 |
92 |
93 | def arrangeSlots(self):
94 | self.clearLayout(self.slotview)
95 | if not self.rule:
96 | return
97 |
98 | self.slotview.addWidget(self.makeASlot(self.rule.base_name, "base"))
99 | self.slotview.addStretch()
100 | self.slotview.addWidget(self.makeASlot(self.rule.mark_name, "mark"))
101 |
102 | def makeRepresentativeString(self):
103 | inputglyphs = []
104 | if not self.rule or not self.rule.bases or not self.rule.marks:
105 | return inputglyphs
106 |
107 | inputglyphs = [ list(self.rule.bases.keys())[0], list(self.rule.marks.keys())[0] ]
108 |
109 | # We use this representative string to guess information about
110 | # how the *real* shaping process will take place; buffer direction
111 | # and script, and hence choice of complex shaper, and hence from
112 | # that choice of features to be processed.
113 | unicodes = [self.project.font.codepointForGlyph(x) for x in inputglyphs]
114 | unicodes = [x for x in unicodes if x]
115 | tounicodes = "".join(map (chr, unicodes))
116 | bufferForGuessing = Buffer(self.project.font, unicodes = tounicodes)
117 | self.buffer_direction = bufferForGuessing.direction
118 | self.buffer_script = bufferForGuessing.script
119 | # print("Guessed buffer direction ", self.buffer_direction)
120 | # print("Guessed buffer script ", self.buffer_script)
121 | shaper = Shaper(self.project.fontfeatures, self.project.font)
122 | shaper.execute(bufferForGuessing)
123 | self.availableFeatures = []
124 | for stage in shaper.stages:
125 | if not isinstance(stage, list):
126 | continue
127 | for f in stage:
128 | if f not in self.availableFeatures and f in self.project.fontfeatures.features:
129 | self.availableFeatures.append(f)
130 | self.makeFeatureButtons()
131 |
132 | return inputglyphs
133 |
134 | if __name__ == "__main__":
135 | from Flux.project import FluxProject
136 |
137 | app = 0
138 | if QApplication.instance():
139 | app = QApplication.instance()
140 | else:
141 | app = QApplication(sys.argv)
142 |
143 | w = QWidget()
144 | w.resize(510, 210)
145 | v_box_1 = QVBoxLayout()
146 |
147 | proj = FluxProject("qalam.fluxml")
148 | proj.fontfeatures.features["mark"] = [proj.fontfeatures.routines[2]]
149 | proj.fontfeatures.features["curs"] = [proj.fontfeatures.routines[1]]
150 |
151 | rule = Attachment(
152 | "top", "_top", proj.fontfeatures.anchors["top"], proj.fontfeatures.anchors["_top"]
153 | )
154 | v_box_1.addWidget(QAttachmentEditor(proj, None, rule))
155 |
156 | w.setLayout(v_box_1)
157 |
158 | w.show()
159 | sys.exit(app.exec_())
160 |
--------------------------------------------------------------------------------
/attic/vharfbuzz.py:
--------------------------------------------------------------------------------
1 | """A user-friendlier way to use Harfbuzz in Python."""
2 |
3 | import uharfbuzz as hb
4 | from fontTools.ttLib import TTFont
5 | import re
6 |
7 |
8 | class Vharfbuzz:
9 | def __init__(self, filename):
10 | """Opens a font file and gets ready to shape text."""
11 | self.filename = filename
12 | with open(self.filename, "rb") as fontfile:
13 | self.fontdata = fontfile.read()
14 | self.ttfont = TTFont(filename)
15 | self.glyphOrder = self.ttfont.getGlyphOrder()
16 | self.prepare_shaper()
17 | self.shapers = None
18 | self.drawfuncs = None
19 |
20 | def prepare_shaper(self):
21 | face = hb.Face(self.fontdata)
22 | font = hb.Font(face)
23 | upem = face.upem
24 | self.upem = upem
25 | font.scale = (upem, upem)
26 | hb.ot_font_set_funcs(font)
27 | self.hbfont = font
28 |
29 | def make_message_handling_function(self, buf, onchange):
30 | self.history = {"GSUB": [], "GPOS": []}
31 | self.lastLookupID = None
32 | def handle_message(msg, buf2):
33 | print(msg)
34 | m = re.match("start lookup (\\d+)", msg)
35 | if m:
36 | lookupid = int(m[1])
37 | self.history[self.stage].append(self.serialize_buf(buf2))
38 |
39 | m = re.match("end lookup (\\d+)", msg)
40 | if m:
41 | lookupid = int(m[1])
42 | # if self.serialize_buf(buf2) != self.history[self.stage][-1]:
43 | onchange(self, self.stage, lookupid, self._copy_buf(buf2))
44 | self.history[self.stage].pop()
45 | if msg.startswith("start GPOS stage"):
46 | self.stage = "GPOS"
47 | return True
48 |
49 | return handle_message
50 |
51 | def shape(self, text, onchange=None):
52 | """Shapes a text
53 |
54 | This shapes a piece of text, return a uharfbuzz `Buffer` object.
55 |
56 | Additionally, if an `onchange` function is provided, this will be called
57 | every time the buffer changes *during* shaping, with the following arguments:
58 |
59 | - ``self``: the vharfbuzz object.
60 | - ``stage``: either "GSUB" or "GPOS"
61 | - ``lookupid``: the current lookup ID
62 | - ``buffer``: a copy of the buffer as a list of lists (glyphname, cluster, position)
63 | """
64 |
65 | self.prepare_shaper()
66 | buf = hb.Buffer()
67 | buf.add_str(text)
68 | buf.guess_segment_properties()
69 | self.stage = "GSUB"
70 | if onchange:
71 | f = self.make_message_handling_function(buf, onchange)
72 | buf.set_message_func(f)
73 | hb.shape(self.hbfont, buf, shapers=self.shapers)
74 | self.stage = "GPOS"
75 | return buf
76 |
77 | def _copy_buf(self, buf):
78 | # Or at least the bits we care about
79 | outs = []
80 | for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
81 | l = [self.glyphOrder[info.codepoint], info.cluster]
82 | if self.stage == "GPOS":
83 | l.append(pos.position)
84 | else:
85 | l.append(None)
86 | outs.append(l)
87 | return outs
88 |
89 | def serialize_buf(self, buf):
90 | """Returns the contents of the given buffer in a string format similar to
91 | that used by hb-shape."""
92 | outs = []
93 | for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
94 | outs.append("%s=%i" % (self.glyphOrder[info.codepoint], info.cluster))
95 | if self.stage == "GPOS":
96 | outs[-1] = outs[-1] + "+%i" % (pos.position[2])
97 | if self.stage == "GPOS" and (pos.position[0] != 0 or pos.position[1] != 0):
98 | outs[-1] = outs[-1] + "@<%i,%i>" % (pos.position[0], pos.position[1])
99 | return "|".join(outs)
100 |
101 | def setup_svg_draw_funcs(self):
102 | if self.drawfuncs:
103 | return
104 |
105 | def move_to(x, y, c):
106 | c["output_string"] = c["output_string"] + f"M{x},{y}"
107 |
108 | def line_to(x, y, c):
109 | c["output_string"] = c["output_string"] + f"L{x},{y}"
110 |
111 | def cubic_to(c1x, c1y, c2x, c2y, x, y, c):
112 | c["output_string"] = (
113 | c["output_string"] + f"C{c1x},{c1y} {c2x},{c2y} {x},{y}"
114 | )
115 |
116 | def quadratic_to(c1x, c1y, x, y, c):
117 | c["output_string"] = c["output_string"] + f"Q{c1x},{c1y} {x},{y}"
118 |
119 | def close_path(c):
120 | c["output_string"] = c["output_string"] + "Z"
121 |
122 | self.drawfuncs = hb.DrawFuncs.create()
123 | self.drawfuncs.set_move_to_func(move_to)
124 | self.drawfuncs.set_line_to_func(line_to)
125 | self.drawfuncs.set_cubic_to_func(cubic_to)
126 | self.drawfuncs.set_quadratic_to_func(quadratic_to)
127 | self.drawfuncs.set_close_path_func(close_path)
128 |
129 | def glyph_to_svg_path(self, gid):
130 | if not hasattr(hb, "DrawFuncs"):
131 | raise ValueError(
132 | "glyph_to_svg_path requires uharfbuzz with draw function support"
133 | )
134 |
135 | self.setup_svg_draw_funcs()
136 | container = {"output_string": ""}
137 | self.drawfuncs.draw_glyph(self.hbfont, gid, container)
138 | return container["output_string"]
139 |
140 | def buf_to_svg(self, buf):
141 | x_cursor = 0
142 | y_cursor = 0
143 | paths = []
144 | svg = ""
145 | for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
146 | glyph_path = self.glyph_to_svg_path(info.codepoint)
147 | dx, dy = pos.position[0], pos.position[1]
148 | p = (
149 | f'\n'
151 | )
152 | svg += p
153 | x_cursor += pos.position[2]
154 | y_cursor += pos.position[3]
155 |
156 | svg = (
157 | (
158 | f'\n"
163 | )
164 | return svg
165 |
166 |
167 | # v = Vharfbuzz("/Users/simon/Library/Fonts/SourceSansPro-Regular.otf")
168 | # buf = v.shape("ABCj")
169 | # svg = v.buf_to_svg(buf)
170 | # import cairosvg
171 | # cairosvg.svg2png(bytestring=svg, write_to="foo.png")
172 |
--------------------------------------------------------------------------------
/Flux/UI/qbufferrenderer.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import QPoint, QMargins, Qt, QRectF
2 | from PyQt5.QtGui import QGlyphRun, QPainter, QRawFont, QColor, QTransform, QPainterPath, QPen
3 | from PyQt5.QtWidgets import QWidget, QGraphicsScene, QGraphicsPathItem, QGraphicsView
4 | import glyphsLib
5 | import darkdetect
6 |
7 | inkcolor = (0,0,0)
8 | if darkdetect.isDark():
9 | inkcolor = (255,255,255)
10 |
11 | class QBufferRenderer(QGraphicsView):
12 | def __init__(self, project, buf=None):
13 | super(QBufferRenderer, self).__init__()
14 |
15 | self.project = project
16 | self.buf = buf
17 | self.location = None
18 | self.margins = QMargins(25, 25, 25, 25)
19 | self.setRenderHint(QPainter.Antialiasing)
20 | self.setRenderHint(QPainter.HighQualityAntialiasing)
21 | self.scene = QGraphicsScene(self)
22 | self.setScene(self.scene)
23 | self.set_scene_from_buf()
24 |
25 | def set_scene_from_buf(self):
26 | self.scene.clear()
27 | xcursor = 0
28 | if self.buf is not None and len(self.buf.items) > 0:
29 | items = self.buf.items
30 | if self.buf.direction == "RTL":
31 | items = list(reversed(items))
32 | for g in items:
33 | color = inkcolor
34 | if hasattr(g, "color"):
35 | color = g.color
36 | glyph = g.glyph
37 | if hasattr(g, "anchor"):
38 | self.drawCross(self.scene, xcursor + g.anchor[0], g.anchor[1], color)
39 | if not glyph in self.project.font:
40 | continue
41 | self.drawGlyph(self.scene, glyph, xcursor + (g.position.xPlacement or 0), (g.position.yPlacement or 0), color)
42 | # else:
43 | xcursor = xcursor + g.position.xAdvance
44 | self.fitInView(self.scene.sceneRect(), Qt.KeepAspectRatio)
45 |
46 | def getGlyphContours(self, glyphname):
47 | if self.location:
48 | vf = self.project.variations
49 | interpolated_glyph = self.interpolate(glyphname)
50 | paths = list(interpolated_glyph.contours)
51 | else:
52 | paths = list(self.project.font[glyphname].contours)
53 | return paths
54 |
55 | def decomposedPaths(self, glyphname, item=None):
56 | paths = self.getGlyphContours(glyphname)
57 | for ix, c in enumerate(self.project.font[glyphname].components):
58 |
59 | glyph = c.baseGlyph
60 | componentPaths = [x.copy() for x in self.getGlyphContours(glyph)]
61 | if self.location:
62 | transformation = self.interpolate_component_transformation(glyphname, ix)
63 | else:
64 | transformation = c.transformation
65 | for cp in componentPaths:
66 | cp.transformBy(transformation)
67 | paths.append(cp)
68 | return paths
69 |
70 | def drawCross(self, scene, x, y, color):
71 | path = QPainterPath()
72 | path.moveTo(x-50, y)
73 | path.lineTo(x+50, y)
74 | path.moveTo(x, y-50)
75 | path.lineTo(x, y+50)
76 | line = QGraphicsPathItem()
77 | p = QPen( QColor(*color) )
78 | p.setWidth(5)
79 | line.setPen(p )
80 | line.setPath(path)
81 | reflect = QTransform(1,0,0,-1,0,0)
82 | line.setTransform(reflect)
83 | scene.addItem(line)
84 |
85 | def set_location(self, location):
86 | self.location = self.project.variations.normalize(location)
87 | self.set_scene_from_buf()
88 |
89 | def interpolate(self, glyphname):
90 | vf = self.project.variations
91 | glyphs = [vf.masters[master][glyphname] for master in vf.master_order]
92 | glyph = self.project.font[glyphname]
93 | for pid, paths in enumerate(zip(*[g.contours for g in glyphs])):
94 | for pointid,points in enumerate(zip(*[p.points for p in paths])):
95 | target_point = glyph.contours[pid].points[pointid]
96 | pointset = {vf.master_order[i]: (points[i].x,points[i].y) for i in range(len(vf.masters))}
97 | (interpolated) = vf.interpolate_tuples(pointset, self.location, normalized=True)
98 | target_point.x, target_point.y = interpolated
99 | return glyph
100 |
101 | def interpolate_component_transformation(self, glyphname, ix):
102 | vf = self.project.variations
103 | transformations = {
104 | master: vf.masters[master][glyphname].components[ix].transformation
105 | for master in vf.master_order
106 | }
107 | return vf.interpolate_tuples(transformations, self.location, normalized=True)
108 |
109 | def drawGlyph(self, scene, glyph, offsetX=0, offsetY=0, color=(255,255,255)):
110 | path = QPainterPath()
111 | path.setFillRule(Qt.WindingFill)
112 | for c in self.decomposedPaths(glyph):
113 | segs = c.segments
114 | path.moveTo(segs[-1].points[-1].x, segs[-1].points[-1].y)
115 | for seg in segs:
116 | tuples = [(a.x, a.y) for a in seg.points]
117 | flattuples = list(sum(tuples,()))
118 | if len(tuples) == 2:
119 | path.quadTo(*flattuples)
120 | elif len(tuples) == 3:
121 | path.cubicTo(*flattuples)
122 | else:
123 | path.lineTo(*flattuples)
124 |
125 | line = QGraphicsPathItem()
126 | line.setBrush( QColor(*color) )
127 | p = QPen()
128 | p.setStyle(Qt.NoPen)
129 | line.setPen(p)
130 | line.setPath(path)
131 | reflect = QTransform(1,0,0,-1,0,0)
132 | reflect.translate(offsetX, offsetY)
133 | # print(f"Drawing {glyph} at offset {offsetX} {offsetY}")
134 | line.setTransform(reflect)
135 | scene.addItem(line)
136 |
137 | def set_buf(self, buf):
138 | self.buf = buf
139 | self.set_scene_from_buf()
140 |
141 | def resizeEvent(self, e):
142 | self.fitInView(self.scene.sceneRect(), Qt.KeepAspectRatio)
143 |
144 | if __name__ == "__main__":
145 | from Flux.project import FluxProject
146 | from PyQt5.QtWidgets import QApplication, QVBoxLayout
147 | from fontFeatures.shaperLib.Buffer import Buffer
148 | import sys
149 |
150 | app = 0
151 | if QApplication.instance():
152 | app = QApplication.instance()
153 | else:
154 | app = QApplication(sys.argv)
155 |
156 | w = QWidget()
157 | w.resize(510, 210)
158 | v_box_1 = QVBoxLayout()
159 |
160 | proj = FluxProject.new("Rajdhani.glyphs")
161 | buf = Buffer(proj.font, unicodes = "ABC")
162 | buf.map_to_glyphs()
163 | v_box_1.addWidget(QBufferRenderer(proj,buf))
164 |
165 | w.setLayout(v_box_1)
166 |
167 | w.show()
168 | sys.exit(app.exec_())
169 |
--------------------------------------------------------------------------------
/Flux/ThirdParty/qtoaster.py:
--------------------------------------------------------------------------------
1 | # https://stackoverflow.com/questions/59251823/is-there-an-equivalent-of-toastr-for-pyqt
2 |
3 | from PyQt5 import QtCore, QtWidgets, QtGui
4 |
5 | class QToaster(QtWidgets.QFrame):
6 | closed = QtCore.pyqtSignal()
7 |
8 | def __init__(self, *args, **kwargs):
9 | super(QToaster, self).__init__(*args, **kwargs)
10 | QtWidgets.QHBoxLayout(self)
11 |
12 | self.setSizePolicy(QtWidgets.QSizePolicy.Maximum,
13 | QtWidgets.QSizePolicy.Maximum)
14 |
15 | self.setStyleSheet('''
16 | QToaster {
17 | border: 1px solid black;
18 | border-radius: 4px;
19 | background: palette(window);
20 | }
21 | ''')
22 | # alternatively:
23 | # self.setAutoFillBackground(True)
24 | # self.setFrameShape(self.Box)
25 |
26 | self.timer = QtCore.QTimer(singleShot=True, timeout=self.hide)
27 |
28 | if self.parent():
29 | self.opacityEffect = QtWidgets.QGraphicsOpacityEffect(opacity=0)
30 | self.setGraphicsEffect(self.opacityEffect)
31 | self.opacityAni = QtCore.QPropertyAnimation(self.opacityEffect, b'opacity')
32 | # we have a parent, install an eventFilter so that when it's resized
33 | # the notification will be correctly moved to the right corner
34 | self.parent().installEventFilter(self)
35 | else:
36 | # there's no parent, use the window opacity property, assuming that
37 | # the window manager supports it; if it doesn't, this won'd do
38 | # anything (besides making the hiding a bit longer by half a second)
39 | self.opacityAni = QtCore.QPropertyAnimation(self, b'windowOpacity')
40 | self.opacityAni.setStartValue(0.)
41 | self.opacityAni.setEndValue(1.)
42 | self.opacityAni.setDuration(100)
43 | self.opacityAni.finished.connect(self.checkClosed)
44 |
45 | self.corner = QtCore.Qt.TopLeftCorner
46 | self.margin = 10
47 |
48 | def checkClosed(self):
49 | # if we have been fading out, we're closing the notification
50 | if self.opacityAni.direction() == self.opacityAni.Backward:
51 | self.close()
52 |
53 | def restore(self):
54 | # this is a "helper function", that can be called from mouseEnterEvent
55 | # and when the parent widget is resized. We will not close the
56 | # notification if the mouse is in or the parent is resized
57 | self.timer.stop()
58 | # also, stop the animation if it's fading out...
59 | self.opacityAni.stop()
60 | # ...and restore the opacity
61 | if self.parent():
62 | self.opacityEffect.setOpacity(1)
63 | else:
64 | self.setWindowOpacity(1)
65 |
66 | def hide(self):
67 | # start hiding
68 | self.opacityAni.setDirection(self.opacityAni.Backward)
69 | self.opacityAni.setDuration(500)
70 | self.opacityAni.start()
71 |
72 | def eventFilter(self, source, event):
73 | if source == self.parent() and event.type() == QtCore.QEvent.Resize:
74 | self.opacityAni.stop()
75 | parentRect = self.parent().rect()
76 | geo = self.geometry()
77 | if self.corner == QtCore.Qt.TopLeftCorner:
78 | geo.moveTopLeft(
79 | parentRect.topLeft() + QtCore.QPoint(self.margin, self.margin))
80 | elif self.corner == QtCore.Qt.TopRightCorner:
81 | geo.moveTopRight(
82 | parentRect.topRight() + QtCore.QPoint(-self.margin, self.margin))
83 | elif self.corner == QtCore.Qt.BottomRightCorner:
84 | geo.moveBottomRight(
85 | parentRect.bottomRight() + QtCore.QPoint(-self.margin, -self.margin))
86 | else:
87 | geo.moveBottomLeft(
88 | parentRect.bottomLeft() + QtCore.QPoint(self.margin, -self.margin))
89 | self.setGeometry(geo)
90 | self.restore()
91 | self.timer.start()
92 | return super(QToaster, self).eventFilter(source, event)
93 |
94 | def enterEvent(self, event):
95 | self.restore()
96 |
97 | def leaveEvent(self, event):
98 | self.timer.start()
99 |
100 | def closeEvent(self, event):
101 | # we don't need the notification anymore, delete it!
102 | self.deleteLater()
103 |
104 | def resizeEvent(self, event):
105 | super(QToaster, self).resizeEvent(event)
106 | # if you don't set a stylesheet, you don't need any of the following!
107 | if not self.parent():
108 | # there's no parent, so we need to update the mask
109 | path = QtGui.QPainterPath()
110 | path.addRoundedRect(QtCore.QRectF(self.rect()).translated(-.5, -.5), 4, 4)
111 | self.setMask(QtGui.QRegion(path.toFillPolygon(QtGui.QTransform()).toPolygon()))
112 | else:
113 | self.clearMask()
114 |
115 | @staticmethod
116 | def showMessage(parent, message,
117 | icon=QtWidgets.QStyle.SP_MessageBoxInformation,
118 | corner=QtCore.Qt.TopLeftCorner, margin=10, closable=True,
119 | timeout=5000, desktop=False, parentWindow=True):
120 |
121 | if parent and parentWindow:
122 | parent = parent.window()
123 |
124 | if not parent or desktop:
125 | self = QToaster(None)
126 | self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint |
127 | QtCore.Qt.BypassWindowManagerHint)
128 | # This is a dirty hack!
129 | # parentless objects are garbage collected, so the widget will be
130 | # deleted as soon as the function that calls it returns, but if an
131 | # object is referenced to *any* other object it will not, at least
132 | # for PyQt (I didn't test it to a deeper level)
133 | self.__self = self
134 |
135 | currentScreen = QtWidgets.QApplication.primaryScreen()
136 | if parent and parent.window().geometry().size().isValid():
137 | # the notification is to be shown on the desktop, but there is a
138 | # parent that is (theoretically) visible and mapped, we'll try to
139 | # use its geometry as a reference to guess which desktop shows
140 | # most of its area; if the parent is not a top level window, use
141 | # that as a reference
142 | reference = parent.window().geometry()
143 | else:
144 | # the parent has not been mapped yet, let's use the cursor as a
145 | # reference for the screen
146 | reference = QtCore.QRect(
147 | QtGui.QCursor.pos() - QtCore.QPoint(1, 1),
148 | QtCore.QSize(3, 3))
149 | maxArea = 0
150 | for screen in QtWidgets.QApplication.screens():
151 | intersected = screen.geometry().intersected(reference)
152 | area = intersected.width() * intersected.height()
153 | if area > maxArea:
154 | maxArea = area
155 | currentScreen = screen
156 | parentRect = currentScreen.availableGeometry()
157 | else:
158 | self = QToaster(parent)
159 | parentRect = parent.rect()
160 |
161 | self.timer.setInterval(timeout)
162 |
163 | # use Qt standard icon pixmaps; see:
164 | # https://doc.qt.io/qt-5/qstyle.html#StandardPixmap-enum
165 | if isinstance(icon, QtWidgets.QStyle.StandardPixmap):
166 | labelIcon = QtWidgets.QLabel()
167 | self.layout().addWidget(labelIcon)
168 | icon = self.style().standardIcon(icon)
169 | size = self.style().pixelMetric(QtWidgets.QStyle.PM_SmallIconSize)
170 | labelIcon.setPixmap(icon.pixmap(size))
171 |
172 | self.label = QtWidgets.QLabel(message)
173 | self.layout().addWidget(self.label)
174 |
175 | if closable:
176 | self.closeButton = QtWidgets.QToolButton()
177 | self.layout().addWidget(self.closeButton)
178 | closeIcon = self.style().standardIcon(
179 | QtWidgets.QStyle.SP_TitleBarCloseButton)
180 | self.closeButton.setIcon(closeIcon)
181 | self.closeButton.setAutoRaise(True)
182 | self.closeButton.clicked.connect(self.close)
183 |
184 | self.timer.start()
185 |
186 | # raise the widget and adjust its size to the minimum
187 | self.raise_()
188 | self.adjustSize()
189 |
190 | self.corner = corner
191 | self.margin = margin
192 |
193 | geo = self.geometry()
194 | # now the widget should have the correct size hints, let's move it to the
195 | # right place
196 | if corner == QtCore.Qt.TopLeftCorner:
197 | geo.moveTopLeft(
198 | parentRect.topLeft() + QtCore.QPoint(margin, margin))
199 | elif corner == QtCore.Qt.TopRightCorner:
200 | geo.moveTopRight(
201 | parentRect.topRight() + QtCore.QPoint(-margin, margin))
202 | elif corner == QtCore.Qt.BottomRightCorner:
203 | geo.moveBottomRight(
204 | parentRect.bottomRight() + QtCore.QPoint(-margin, -margin))
205 | else:
206 | geo.moveBottomLeft(
207 | parentRect.bottomLeft() + QtCore.QPoint(margin, -margin))
208 |
209 | self.setGeometry(geo)
210 | self.show()
211 | self.opacityAni.start()
212 |
--------------------------------------------------------------------------------
/Flux/Plugins/Arabic.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QLabel,
3 | QDialog,
4 | QLineEdit,
5 | QGroupBox,
6 | QCompleter,
7 | QFormLayout,
8 | QComboBox,
9 | QMessageBox,
10 | QCheckBox
11 | )
12 | from PyQt5.QtGui import QValidator
13 | from Flux.Plugins import FluxPlugin
14 | import re
15 | import fontFeatures
16 |
17 |
18 | plugin_name = "Arabic Positionals"
19 |
20 |
21 | class REValidator(QValidator):
22 | def __init__(self):
23 | super().__init__()
24 |
25 | def validate(self, s, pos):
26 | try:
27 | re.compile(s)
28 | except Exception as e:
29 | return (QValidator.Invalid, s, pos)
30 | return (QValidator.Acceptable, s, pos)
31 |
32 | def fixup(self, s):
33 | # Trim multiple spaces?
34 | pass
35 |
36 |
37 | class Dialog(FluxPlugin):
38 | def createForm(self):
39 | form = QGroupBox("Arabic parameters")
40 | layout = QFormLayout()
41 | naming, regexps = self.detect_naming_scheme()
42 | if naming:
43 | message = f"It looks like you're using the {naming} naming scheme. "
44 | message = message + "If that's not correct, p"
45 | else:
46 | message = "P"
47 | regexps = {"isol": "", "init": "", "medi": "", "fina": ""}
48 | message = (
49 | message
50 | + "lease enter regular expressions below which will match the appropriate glyph classes"
51 | )
52 | label = QLabel(message)
53 | label.setWordWrap(True)
54 | layout.addRow(label)
55 |
56 | self.isol_re = QLineEdit()
57 | self.isol_re.setText(regexps["isol"])
58 | self.isol_re.setValidator(REValidator())
59 |
60 | self.init_re = QLineEdit()
61 | self.init_re.setText(regexps["init"])
62 | self.init_re.setValidator(REValidator())
63 |
64 | self.medi_re = QLineEdit()
65 | self.medi_re.setText(regexps["medi"])
66 | self.medi_re.setValidator(REValidator())
67 |
68 | self.fina_re = QLineEdit()
69 | self.fina_re.setText(regexps["fina"])
70 | self.fina_re.setValidator(REValidator())
71 |
72 | layout.addRow(QLabel("Base glyphs"), self.isol_re)
73 | layout.addRow(QLabel("Initial glyphs"), self.init_re)
74 | layout.addRow(QLabel("Medial glyphs"), self.medi_re)
75 | layout.addRow(QLabel("Final glyphs"), self.fina_re)
76 |
77 | self.doCursive = QCheckBox()
78 | self.doCursive.setChecked(True)
79 |
80 | layout.addRow(QLabel("Do cursive attachment?"), self.doCursive)
81 | form.setLayout(layout)
82 | return form
83 |
84 | def accept(self):
85 | glyphnames = self.project.font.keys()
86 | isol_re = self.isol_re.text()
87 | init_re = self.init_re.text()
88 | medi_re = self.medi_re.text()
89 | fina_re = self.fina_re.text()
90 |
91 | init_class = []
92 | medi_class = []
93 | fina_class = []
94 | init_rules = fontFeatures.Routine(name="Init")
95 | medi_rules = fontFeatures.Routine(name="Medi")
96 | fina_rules = fontFeatures.Routine(name="Fina")
97 | # We know these are valid REs
98 | arabic_glyphs = [
99 | g
100 | for g in glyphnames
101 | if re.search(init_re, g) or re.search(medi_re, g) or re.search(fina_re, g)
102 | ]
103 | for g in glyphnames:
104 | m = re.search(isol_re, g)
105 | if not m:
106 | continue
107 | if m.groups():
108 | base_name = g.replace(m[1], "")
109 | else:
110 | base_name = g
111 | for g2 in arabic_glyphs:
112 | m = re.search(init_re, g2)
113 | if not m or not m.groups():
114 | continue
115 | base_init = g2.replace(m[1], "")
116 | if base_init == base_name:
117 | init_class.append(g2)
118 | init_rules.addRule(fontFeatures.Substitution([[g]], [[g2]]))
119 | break
120 |
121 | for g2 in arabic_glyphs:
122 | m = re.search(medi_re, g2)
123 | if not m or not m.groups():
124 | continue
125 | base_medi = g2.replace(m[1], "")
126 | if base_medi == base_name:
127 | medi_class.append(g2)
128 | medi_rules.addRule(fontFeatures.Substitution([[g]], [[g2]]))
129 | break
130 |
131 | for g2 in arabic_glyphs:
132 | m = re.search(fina_re, g2)
133 | if not m or not m.groups():
134 | continue
135 | base_fina = g2.replace(m[1], "")
136 | if base_fina == base_name:
137 | fina_class.append(g2)
138 | fina_rules.addRule(fontFeatures.Substitution([[g]], [[g2]]))
139 | break
140 |
141 | warnings = []
142 | if len(init_class) < 10 or len(init_class) > len(glyphnames) / 2:
143 | warnings.append(
144 | f"Init regexp '{init_re} matched a surprising number of glyphs ({len(init_class)})"
145 | )
146 | if len(medi_class) < 10 or len(medi_class) > len(glyphnames) / 2:
147 | warnings.append(
148 | f"Medi regexp '{medi_re} matched a surprising number of glyphs ({len(medi_class)})"
149 | )
150 | if len(fina_class) < 10 or len(fina_class) > len(glyphnames) / 2:
151 | warnings.append(
152 | f"Fina regexp '{fina_re} matched a surprising number of glyphs ({len(fina_class)})"
153 | )
154 |
155 | if len(warnings) and self.show_warnings(warnings) == QMessageBox.Cancel:
156 | return
157 |
158 | self.project.fontfeatures.routines.extend([init_rules, medi_rules, fina_rules])
159 | self.project.fontfeatures.addFeature("init", [init_rules])
160 | self.project.fontfeatures.addFeature("medi", [medi_rules])
161 | self.project.fontfeatures.addFeature("fina", [fina_rules])
162 | if not "init" in self.project.glyphclasses:
163 | self.project.glyphclasses["init"] = {
164 | "type": "automatic",
165 | "predicates": [
166 | {"type": "name", "comparator": "matches", "value": init_re}
167 | ],
168 | }
169 | if not "medi" in self.project.glyphclasses:
170 | self.project.glyphclasses["medi"] = {
171 | "type": "automatic",
172 | "predicates": [
173 | {"type": "name", "comparator": "matches", "value": medi_re}
174 | ],
175 | }
176 | if not "fina" in self.project.glyphclasses:
177 | self.project.glyphclasses["fina"] = {
178 | "type": "automatic",
179 | "predicates": [
180 | {"type": "name", "comparator": "matches", "value": fina_re}
181 | ],
182 | }
183 |
184 | if self.doCursive.isChecked():
185 | exitdict = {}
186 | entrydict = {}
187 | for g in glyphnames:
188 | anchors = self.project.font[g].anchors
189 | if not anchors:
190 | continue
191 | entry = [a for a in anchors if a.name == "entry"]
192 | exit = [a for a in anchors if a.name == "exit"]
193 | if len(entry):
194 | entrydict[g] = (entry[0].x, entry[0].y)
195 | if len(exit):
196 | exitdict[g] = (exit[0].x, exit[0].y)
197 | s = fontFeatures.Attachment(
198 | base_name="entry",
199 | mark_name="exit",
200 | bases=entrydict,
201 | marks=exitdict,
202 | )
203 | r = fontFeatures.Routine(name="CursiveAttachment", rules=[s])
204 | self.project.fontfeatures.routines.extend([r])
205 | self.project.fontfeatures.addFeature("curs", [r])
206 |
207 | return super().accept()
208 |
209 | def detect_naming_scheme(self):
210 | glyphnames = self.project.font.keys()
211 | schemas = {
212 | "Glyphs": {
213 | "isol": "-ar$",
214 | "init": "-ar(.init)$",
215 | "medi": "-ar(.medi)$",
216 | "fina": "-ar(.fina)",
217 | },
218 | "Qalmi": {
219 | "isol": "(u1)$",
220 | "init": "(i1)$",
221 | "medi": "(m1)$",
222 | "fina": "(f1)$",
223 | },
224 | }
225 | for schema_name, res in schemas.items():
226 | match_isols = len([g for g in glyphnames if re.search(res["isol"], g)])
227 | match_inits = len([g for g in glyphnames if re.search(res["init"], g)])
228 | match_medis = len([g for g in glyphnames if re.search(res["medi"], g)])
229 | match_finas = len([g for g in glyphnames if re.search(res["fina"], g)])
230 | if (
231 | match_isols > 1
232 | and match_inits > 1
233 | and match_medis > 1
234 | and match_finas > 1
235 | ):
236 | return schema_name, res
237 | return None, None
238 |
239 | def show_warnings(self, warnings):
240 | msg = QMessageBox()
241 | msg.setIcon(QMessageBox.Warning)
242 |
243 | msg.setText("\n".join(warnings))
244 | msg.setWindowTitle("Arabic Positionals")
245 | msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
246 |
247 | return msg.exec_()
248 |
--------------------------------------------------------------------------------
/Flux/UI/qglyphname.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QWidget,
3 | QApplication,
4 | QHBoxLayout,
5 | QLineEdit,
6 | QVBoxLayout,
7 | QPushButton,
8 | QCompleter,
9 | QLabel,
10 | QDialog,
11 | QDialogButtonBox,
12 | QScrollArea,
13 | QGridLayout,
14 | QStyle,
15 | QFrame,
16 | QTextEdit,
17 |
18 | )
19 | from Flux.ThirdParty.QFlowGridLayout import QFlowGridLayout
20 | from fontFeatures.shaperLib.Buffer import Buffer
21 | from Flux.UI.qbufferrenderer import QBufferRenderer
22 | from PyQt5.QtCore import Qt, pyqtSignal, QStringListModel, QMargins
23 | from PyQt5.QtGui import QValidator, QTextOption
24 |
25 | import darkdetect
26 |
27 | if darkdetect.isDark():
28 | selected_style = "background-color: #322b2b;"
29 | deselected_style = "background-color: #322b2b;"
30 | else:
31 | selected_style = "background-color: #322b2b;"
32 |
33 |
34 | class GlyphNameValidator(QValidator):
35 | def __init__(self, parent):
36 | super().__init__()
37 | self.glyphSet = parent.project.font.keys()
38 | self.classSet = parent.project.glyphclasses.keys()
39 | self.multiple = parent.multiple
40 | self.allow_classes = parent.allow_classes
41 |
42 | def validate(self, s, pos):
43 | if s in self.glyphSet:
44 | return (QValidator.Acceptable, s, pos)
45 | if not self.multiple and " " in s:
46 | print("Invalid (space)")
47 | return (QValidator.Invalid, s, pos)
48 | if not self.allow_classes and "@" in s:
49 | print("Invalid (class)")
50 | return (QValidator.Invalid, s, pos)
51 | # XXX Other things not acceptable in glyphs here?
52 | if self.multiple:
53 | allOk = True
54 | for g in s.split():
55 | if g[0] == "@" and g[1:] not in self.classSet:
56 | allOk = False
57 | elif g[0] != "@" and g not in self.glyphSet:
58 | allOk = False
59 | if allOk:
60 | return (QValidator.Acceptable, s, pos)
61 | if self.allow_classes and s[1:] in self.classSet: # And not multiple
62 | return (QValidator.Acceptable, s, pos)
63 |
64 | return (QValidator.Intermediate, s, pos)
65 |
66 | def fixup(self, s):
67 | # Trim multiple spaces?
68 | pass
69 |
70 | class QGlyphBox(QWidget):
71 | def __init__(self, parent, glyph):
72 | super().__init__()
73 | wlayout = QVBoxLayout()
74 | self.setLayout(wlayout)
75 | self.parent = parent
76 | self.glyph = glyph
77 | buf = Buffer(self.parent.project.font, glyphs = [glyph])
78 | renderer = QBufferRenderer(self.parent.project, buf)
79 | wlayout.addWidget(renderer)
80 | label = QTextEdit(glyph)
81 | label.setReadOnly(True)
82 | label.setFrameStyle(QFrame.NoFrame)
83 | label.setAlignment(Qt.AlignCenter)
84 | label.setLineWrapMode(QTextEdit.WidgetWidth)
85 | label.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
86 | wlayout.addWidget(label)
87 | renderer.resizeEvent(None)
88 | # self.setContentsMargins(QMargins(25, 25, 25, 25))
89 | self.setMaximumSize(100,100)
90 |
91 | def mousePressEvent(self, e):
92 | if self.parent.selected:
93 | self.parent.selected.deselect()
94 | self.select()
95 |
96 | def deselect(self):
97 | self.setStyleSheet("")
98 | print("Deselect called")
99 |
100 | def select(self):
101 | self.setStyleSheet(selected_style)
102 | self.parent.selected = self
103 |
104 | def hasHeightForWidth(self):
105 | return True
106 |
107 | def heightForWidth(self, w):
108 | return w
109 |
110 | class QGlyphPicker(QDialog):
111 | def __init__(self, project):
112 | super(QGlyphPicker, self).__init__()
113 | self.project = project
114 | self.selected = None
115 | v_box_1 = QVBoxLayout()
116 | self.qgrid = QFlowGridLayout()
117 |
118 | self.qgridWidget = QWidget()
119 | self.qgridWidget.setLayout(self.qgrid)
120 |
121 | self.scroll = QScrollArea()
122 | self.scroll.setWidget(self.qgridWidget)
123 | self.scroll.setWidgetResizable(True)
124 |
125 | v_box_1.addWidget(self.scroll)
126 |
127 | self.searchbar = QLineEdit()
128 | v_box_1.addWidget(self.searchbar)
129 | self.searchbar.textChanged.connect(self.filterGrid)
130 |
131 | buttons = QDialogButtonBox(
132 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
133 | Qt.Horizontal, self)
134 | buttons.accepted.connect(self.accept)
135 | buttons.rejected.connect(self.reject)
136 | v_box_1.addWidget(buttons)
137 | self.setLayout(v_box_1)
138 | self.widgets = {}
139 | self.setupGrid()
140 | self.drawGrid()
141 |
142 | def setupGrid(self):
143 | self.clearLayout(self.qgrid)
144 | for g in self.project.font.keys():
145 | w = QGlyphBox(self, g)
146 | self.widgets[g] = w
147 |
148 | def drawGrid(self):
149 | for g in self.project.font.keys():
150 | self.qgrid.addWidget(self.widgets[g])
151 |
152 | def filterGrid(self):
153 | t = self.searchbar.text()
154 | for g in self.project.font.keys():
155 | v = True
156 | if t and t not in g:
157 | v = False
158 | self.widgets[g].setVisible(v)
159 |
160 | def clearLayout(self, layout):
161 | if layout is not None:
162 | while layout.count():
163 | item = layout.takeAt(0)
164 | widget = item.widget()
165 | if widget is not None:
166 | widget.deleteLater()
167 | else:
168 | self.clearLayout(item.layout())
169 |
170 | @classmethod
171 | def pickGlyph(self, project):
172 | dialog = QGlyphPicker(project)
173 | result = dialog.exec_()
174 | if dialog.selected:
175 | return dialog.selected.glyph
176 |
177 | class MultiCompleter(QCompleter):
178 |
179 | def __init__(self, parent=None):
180 | super(MultiCompleter, self).__init__(parent)
181 |
182 | self.setCaseSensitivity(Qt.CaseInsensitive)
183 | self.setCompletionMode(QCompleter.PopupCompletion)
184 | self.setWrapAround(False)
185 |
186 | # Add texts instead of replace
187 | def pathFromIndex(self, index):
188 | path = QCompleter.pathFromIndex(self, index)
189 |
190 | lst = str(self.widget().text()).split(' ')
191 |
192 | if len(lst) > 1:
193 | path = '%s %s' % (' '.join(lst[:-1]), path)
194 |
195 | return path
196 |
197 | # Add operator to separate between texts
198 | def splitPath(self, path):
199 | path = str(path.split(' ')[-1]).lstrip(' ')
200 | return [path]
201 |
202 | class QGlyphName(QWidget):
203 | changed = pyqtSignal()
204 |
205 | def __init__(self, project, multiple = False, allow_classes = False, parent=None):
206 | self.project = project
207 | self.multiple = multiple
208 | self.allow_classes = allow_classes
209 | super(QGlyphName, self).__init__(parent)
210 | self.layout = QHBoxLayout()
211 | self.setLayout(self.layout)
212 |
213 | self.glyphline = QLineEdit()
214 | self.completermodel = QStringListModel()
215 | if self.allow_classes:
216 | self.completermodel.setStringList(list(self.project.font.keys()) + ["@"+x for x in self.project.glyphclasses.keys()])
217 | else:
218 | self.completermodel.setStringList(self.project.font.keys())
219 |
220 | self.glyphline.setValidator(GlyphNameValidator(self))
221 |
222 | if multiple:
223 | self.completer = MultiCompleter()
224 | else:
225 | self.completer = QCompleter()
226 | self.completer.setModel(self.completermodel)
227 | self.glyphline.setCompleter(self.completer)
228 | self.glyphline.textChanged.connect(lambda: self.changed.emit())
229 | self.glyphline.owner = self
230 | if self.allow_classes:
231 | self.setAcceptDrops(True)
232 |
233 | self.layout.addWidget(self.glyphline)
234 |
235 | self.glyphPickerButton = QPushButton("")
236 | self.glyphPickerButton.setIcon(self.style().standardIcon(QStyle.SP_FileDialogContentsView))
237 | self.glyphPickerButton.clicked.connect(self.launchGlyphPicker)
238 |
239 | self.layout.addWidget(self.glyphPickerButton)
240 |
241 | def appendText(self, text):
242 | if self.glyphline.text():
243 | self.glyphline.setText(self.glyphline.text() + " " + text)
244 | else:
245 | self.glyphline.setText(text)
246 |
247 | def text(self):
248 | return self.glyphline.text()
249 |
250 | def setText(self, f):
251 | g = self.glyphline.setText(f)
252 | return g
253 |
254 | def dropEvent(self, event):
255 | data = event.mimeData()
256 | if not data.hasText() or not data.text().startswith("@"):
257 | event.reject()
258 | return
259 | self.appendText(data.text())
260 | self.changed.emit()
261 | if not self.multiple:
262 | self.glyphline.returnPressed.emit()
263 |
264 | @property
265 | def returnPressed(self):
266 | self.changed.emit()
267 | print("Emit changed")
268 | return self.glyphline.returnPressed
269 |
270 | def launchGlyphPicker(self):
271 | result = QGlyphPicker.pickGlyph(self.project)
272 | if result:
273 | self.appendText(result)
274 |
275 |
276 | if __name__ == "__main__":
277 | from Flux.project import FluxProject
278 | import sys
279 |
280 | app = 0
281 | if QApplication.instance():
282 | app = QApplication.instance()
283 | else:
284 | app = QApplication(sys.argv)
285 |
286 | w = QWidget()
287 | w.resize(510, 210)
288 |
289 | proj = FluxProject("qalam.fluxml")
290 | foo = QGlyphName(proj)
291 | foo.setParent(w)
292 |
293 | w.show()
294 | sys.exit(app.exec_())
295 |
--------------------------------------------------------------------------------
/Flux/UI/classlist.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import (
2 | Qt,
3 | pyqtSlot,
4 | QModelIndex,
5 | QAbstractTableModel,
6 | QItemSelectionModel,
7 | QMimeData,
8 | QRect
9 | )
10 | from PyQt5.QtWidgets import QTreeView, QMenu, QStyledItemDelegate, QLineEdit
11 | from .glyphpredicateeditor import AutomatedGlyphClassDialog, GlyphClassPredicateTester, GlyphClassPredicate
12 | from .qglyphname import QGlyphName
13 |
14 | class GlyphNameDelegate(QStyledItemDelegate):
15 | def __init__(self, tree):
16 | super().__init__()
17 | self.project = tree.project
18 | self.model = tree.model()
19 |
20 | def createEditor(self, parent, option, index):
21 | # Check if index is actually non-computed class
22 | if self.model.isAutomatic(index) or index.column() == 0:
23 | return super().createEditor(parent, option, index)
24 | editor = QGlyphName(self.project, multiple = True, allow_classes = True)
25 | editor.setParent(parent)
26 | editor.setAttribute(Qt.WA_TranslucentBackground, False)
27 | editor.setAttribute(Qt.WA_OpaquePaintEvent, True)
28 | editor.layout.setContentsMargins(0,0,0,0)
29 | # editor = QLineEdit(parent)
30 | return editor
31 |
32 | def updateEditorGeometry(self, editor, option, index):
33 | r = QRect(option.rect)
34 | if editor.windowFlags() & Qt.Popup and editor.parent() is not None:
35 | r.setTopLeft(editor.parent().mapToGlobal(r.topLeft()))
36 | sizeHint = editor.sizeHint()
37 |
38 | if (r.width()"
97 | if role == Qt.ToolTipRole:
98 | if index.column() == 1 and self.isAutomatic(index):
99 | predicates = [GlyphClassPredicate(d) for d in self.getPredicates(index)]
100 | return " ".join(self.tester.test_all(predicates))
101 | return None
102 |
103 |
104 | def mimeData(self, indexes):
105 | mimedata = QMimeData()
106 | name = self.order[indexes[0].row()]
107 | mimedata.setText("@"+name)
108 | return mimedata
109 |
110 | def headerData(self, section, orientation, role=Qt.DisplayRole):
111 | """ Set the headers to be displayed. """
112 | if role != Qt.DisplayRole:
113 | return None
114 |
115 | if orientation == Qt.Horizontal:
116 | if section == 0:
117 | return "Name"
118 | elif section == 1:
119 | return "Contents"
120 | return None
121 |
122 | def insertRows(self, position, item=None, rows=1, index=QModelIndex()):
123 | """ Insert a row into the model. """
124 | self.beginInsertRows(QModelIndex(), position, position + rows - 1)
125 |
126 | if "" not in self.order:
127 | self.order.append("")
128 | self.glyphclasses[""] = {"type": "manual", "contents": []}
129 |
130 | self.endInsertRows()
131 | return True
132 |
133 | def appendRow(self):
134 | self.insertRows(len(self.order))
135 | return self.index(len(self.order) - 1, 0)
136 |
137 | def removeRows(self, indexes):
138 | positions = [i.row() for i in indexes if i.column() == 0]
139 | # print(positions)
140 | for i in reversed(sorted(positions)):
141 | self.removeRow(i)
142 |
143 | self.order = sorted(list(self.glyphclasses.keys()))
144 | # print("Computing order", self.order)
145 |
146 | def removeRow(self, position, rows=1, index=QModelIndex()):
147 | """ Remove a row from the model. """
148 | self.beginRemoveRows(QModelIndex(), position, position + rows - 1)
149 | assert rows == 1
150 | del self.glyphclasses[self.order[position]]
151 | # print("Deleting %s" % self.order[position])
152 | self.endRemoveRows()
153 | return True
154 |
155 | def setData(self, index, value, role=Qt.EditRole):
156 | """ Adjust the data (set it to ) depending on the given
157 | index and role.
158 | """
159 | if role != Qt.EditRole:
160 | return False
161 |
162 | if index.isValid() and 0 <= index.row() < len(self.order):
163 | name = self.order[index.row()]
164 | if index.column() == 0 and name != value:
165 | self.glyphclasses[value] = self.glyphclasses[name]
166 | del self.glyphclasses[name]
167 | elif index.column() == 1:
168 | self.glyphclasses[name]["contents"] = value.split(" ")
169 | else:
170 | return False
171 |
172 | self.order = sorted(list(self.glyphclasses.keys()))
173 | self.dataChanged.emit(index, index)
174 | return True
175 |
176 | return False
177 |
178 | def flags(self, index):
179 | """ Set the item flags at the given index. Seems like we're
180 | implementing this function just to see how it's done, as we
181 | manually adjust each tableView to have NoEditTriggers.
182 | """
183 | if not index.isValid():
184 | return Qt.ItemIsEnabled
185 | flag = Qt.ItemFlags(QAbstractTableModel.flags(self, index))
186 | flag = flag | Qt.ItemIsDragEnabled
187 | if index.column() == 0:
188 | return flag | Qt.ItemIsEditable
189 |
190 | name = self.order[index.row()]
191 | if self.glyphclasses[name]["type"] == "automatic":
192 | return flag
193 | else:
194 | return flag | Qt.ItemIsEditable
195 |
196 |
197 | class GlyphClassList(QTreeView):
198 | def __init__(self, project):
199 | super(QTreeView, self).__init__()
200 | self.project = project
201 | self.setModel(GlyphClassModel(self.project))
202 | self.setContextMenuPolicy(Qt.CustomContextMenu)
203 | self.setItemDelegate(GlyphNameDelegate(self))
204 | self.setDragEnabled(True)
205 | self.customContextMenuRequested.connect(self.contextMenu)
206 | self.doubleClicked.connect(self.doubleClickHandler)
207 |
208 | def contextMenu(self, position):
209 | indexes = self.selectedIndexes()
210 | menu = QMenu()
211 | if len(indexes) > 0:
212 | menu.addAction("Delete class", self.deleteClass)
213 | menu.addAction("Add class", self.addClass)
214 | menu.addAction("Add computed class", self.addComputedClass)
215 | menu.exec_(self.viewport().mapToGlobal(position))
216 |
217 | def doubleClickHandler(self, index):
218 | if self.model().isAutomatic(index) and index.column() == 1:
219 | predicates, result = AutomatedGlyphClassDialog.editDefinition(
220 | self.project, self.model().getPredicates(index)
221 | )
222 | if result:
223 | self.model().setPredicates(index, predicates)
224 |
225 | def update(self, index=QModelIndex()):
226 | # print(self.project.glyphclasses)
227 | self.model().order = list(sorted(self.project.glyphclasses.keys()))
228 | if index.isValid():
229 | self.model().dataChanged.emit(index, index)
230 | else:
231 | self.model().beginResetModel()
232 | self.model().dataChanged.emit(index, index)
233 | self.model().endResetModel()
234 | super().update()
235 |
236 | @pyqtSlot()
237 | def deleteClass(self):
238 | self.model().removeRows(self.selectedIndexes())
239 |
240 | @pyqtSlot()
241 | def addClass(self):
242 | index = self.model().appendRow()
243 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect)
244 | self.edit(index)
245 |
246 | @pyqtSlot()
247 | def addComputedClass(self):
248 | index = self.model().appendRow()
249 | self.model().glyphclasses[""] = {"type": "automatic"}
250 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect)
251 | self.edit(index)
252 |
253 |
--------------------------------------------------------------------------------
/Flux/project.py:
--------------------------------------------------------------------------------
1 | from lxml import etree
2 | from fontFeatures import FontFeatures, Routine, Substitution
3 | from babelfont import Babelfont
4 | from fontFeatures.feaLib import FeaUnparser
5 | from fontTools.feaLib.builder import Builder
6 | from fontTools.ttLib import TTFont
7 | from fontFeatures.ttLib import unparse
8 | from Flux.computedroutine import ComputedRoutine
9 | from Flux.dividerroutine import DividerRoutine
10 | from io import StringIO as UnicodeIO
11 | from Flux.UI.GlyphActions import GlyphAction
12 | from Flux.UI.glyphpredicateeditor import GlyphClassPredicateTester, GlyphClassPredicate
13 | from babelfont.variablefont import VariableFont
14 | import os
15 |
16 | class FluxProject:
17 |
18 | @classmethod
19 | def new(klass, fontfile, editor=None):
20 | self = FluxProject()
21 | self.fontfeatures = FontFeatures()
22 | self.fontfile = fontfile
23 | self.editor = editor
24 | if not self._load_fontfile():
25 | return
26 | self.glyphclasses = {}
27 | self.glyphactions = {}
28 | self.debuggingText = ""
29 | self.filename = None
30 |
31 | if self.fontfile.endswith(".ttf") or self.fontfile.endswith(".otf"):
32 | self._load_features_binary()
33 | else:
34 | self._load_features_source()
35 |
36 | for groupname, contents in self.font.groups.items():
37 | self.glyphclasses[groupname] = {
38 | "type": "manual",
39 | "contents": contents
40 | }
41 | self.fontfeatures.namedClasses.forceput(groupname, tuple(contents))
42 | # Load up the anchors too
43 | self._load_anchors()
44 | return self
45 |
46 | def __init__(self, file=None):
47 | if not file:
48 | return
49 | self.filename = file
50 | self.xml = etree.parse(file).getroot()
51 | dirname = os.path.dirname(file)
52 | self.fontfile = os.path.join(dirname,self.xml.find("source").get("file"))
53 | self.fontfeatures = FontFeatures()
54 | if not self._load_fontfile():
55 | return
56 | self.glyphactions = {}
57 | self.xmlToFontFeatures()
58 | text = self.xml.find("debuggingText")
59 | if text is not None:
60 | self.debuggingText = text.text
61 | else:
62 | self.debuggingText = ""
63 |
64 | self.glyphclasses = {} # Will sync to fontFeatures when building
65 | # XXX will it?
66 |
67 | glyphclasses = self.xml.find("glyphclasses")
68 | if glyphclasses is not None:
69 | for c in glyphclasses:
70 | thisclass = self.glyphclasses[c.get("name")] = {}
71 | if c.get("automatic") == "true":
72 | thisclass["type"] = "automatic"
73 | thisclass["predicates"] = [ dict(p.items()) for p in c.findall("predicate") ]
74 | self.fontfeatures.namedClasses[c.get("name")] = tuple(GlyphClassPredicateTester(self).test_all([
75 | GlyphClassPredicate(x) for x in thisclass["predicates"]
76 | ]))
77 | else:
78 | thisclass["type"] = "manual"
79 | thisclass["contents"] = [g.text for g in c]
80 | self.fontfeatures.namedClasses[c.get("name")] = tuple([g.text for g in c])
81 |
82 | # The font file is the authoritative source of the anchors, so load them
83 | # from the font file on load, in case they have changed.
84 | self._load_anchors()
85 | self._load_glyphactions()
86 |
87 | def _load_fontfile(self):
88 | try:
89 | if self.fontfile.endswith(".ufo") or self.fontfile.endswith("tf"):
90 | # Single master workflow
91 | self.font = Babelfont.open(self.fontfile)
92 | self.variations = None
93 | else:
94 | self.variations = VariableFont(self.fontfile)
95 | # We need a "scratch copy" because we will be trashing the
96 | # glyph data with our interpolations
97 | if len(self.variations.masters.keys()) == 1:
98 | self.font = list(self.variations.masters.values())[0]
99 | self.variations = None
100 | else:
101 | firstmaster = self.variations.designspace.sources[0].path
102 | if firstmaster:
103 | self.font = Babelfont.open(firstmaster)
104 | else: # Glyphs, fontlab?
105 | self.font = Babelfont.open(self.fontfile)
106 | except Exception as e:
107 | if self.editor:
108 | self.editor.showError("Couldn't open %s: %s" % (self.fontfile, e))
109 | else:
110 | raise e
111 | return False
112 | return True
113 |
114 | def _load_anchors(self):
115 | for g in self.font:
116 | for a in g.anchors:
117 | if not a.name in self.fontfeatures.anchors:
118 | self.fontfeatures.anchors[a.name] = {}
119 | self.fontfeatures.anchors[a.name][g.name] = (a.x, a.y)
120 |
121 | def _load_glyphactions(self):
122 | glyphactions = self.xml.find("glyphactions")
123 | if not glyphactions:
124 | return
125 | for xmlaction in glyphactions:
126 | g = GlyphAction.fromXML(xmlaction)
127 | self.glyphactions[g.glyph] = g
128 | g.perform(self.font)
129 |
130 | def _slotArray(self, el):
131 | return [[g.text for g in slot.findall("glyph")] for slot in list(el)]
132 |
133 | def xmlToFontFeatures(self):
134 | routines = {}
135 | warnings = []
136 | for xmlroutine in self.xml.find("routines"):
137 | if "computed" in xmlroutine.attrib:
138 | r = ComputedRoutine.fromXML(xmlroutine)
139 | r.project = self
140 | elif "divider" in xmlroutine.attrib:
141 | r = DividerRoutine.fromXML(xmlroutine)
142 | else:
143 | r = Routine.fromXML(xmlroutine)
144 | routines[r.name] = r
145 | self.fontfeatures.routines.append(r)
146 | for xmlfeature in self.xml.find("features"):
147 | # Temporary until we refactor fontfeatures
148 | featurename = xmlfeature.get("name")
149 | self.fontfeatures.features[featurename] = []
150 | for r in xmlfeature:
151 | routinename = r.get("name")
152 | if routinename in routines:
153 | self.fontfeatures.addFeature(featurename, [routines[routinename]])
154 | else:
155 | warnings.append("Lost routine %s referenced in feature %s" % (routinename, featurename))
156 | return warnings # We don't do anything with them yet
157 |
158 | def save(self, filename=None):
159 | if not filename:
160 | filename = self.filename
161 | flux = etree.Element("flux")
162 | etree.SubElement(flux, "source").set("file", self.fontfile)
163 | etree.SubElement(flux, "debuggingText").text = self.debuggingText
164 | glyphclasses = etree.SubElement(flux, "glyphclasses")
165 | for k,v in self.glyphclasses.items():
166 | self.serializeGlyphClass(glyphclasses, k, v)
167 | # Plugins
168 |
169 | # Features
170 | features = etree.SubElement(flux, "features")
171 | for k,v in self.fontfeatures.features.items():
172 | f = etree.SubElement(features, "feature")
173 | f.set("name", k)
174 | for routine in v:
175 | etree.SubElement(f, "routine").set("name", routine.name)
176 | # Routines
177 | routines = etree.SubElement(flux, "routines")
178 | for r in self.fontfeatures.routines:
179 | routines.append(r.toXML())
180 |
181 | # Glyph actions
182 | if self.glyphactions:
183 | f = etree.SubElement(flux, "glyphactions")
184 | for ga in self.glyphactions.values():
185 | f.append(ga.toXML())
186 |
187 | et = etree.ElementTree(flux)
188 | with open(filename, "wb") as out:
189 | et.write(out, pretty_print=True)
190 |
191 |
192 | def serializeGlyphClass(self, element, name, value):
193 | c = etree.SubElement(element, "class")
194 | c.set("name", name)
195 | if value["type"] == "automatic":
196 | c.set("automatic", "true")
197 | for pred in value["predicates"]:
198 | pred_xml = etree.SubElement(c, "predicate")
199 | for k, v in pred.items():
200 | pred_xml.set(k, v)
201 | else:
202 | c.set("automatic", "false")
203 | for glyph in value["contents"]:
204 | etree.SubElement(c, "glyph").text = glyph
205 | return c
206 |
207 | def saveFEA(self, filename):
208 | try:
209 | asfea = self.fontfeatures.asFea()
210 | with open(filename, "w") as out:
211 | out.write(asfea)
212 | return None
213 | except Exception as e:
214 | return str(e)
215 |
216 | def loadFEA(self, filename):
217 | unparsed = FeaUnparser(open(filename,"r"))
218 | self.fontfeatures = unparsed.ff
219 |
220 | def _load_features_binary(self):
221 | tt = TTFont(self.fontfile)
222 | self.fontfeatures = unparse(tt)
223 | print(self.fontfeatures.features)
224 |
225 | def _load_features_source(self):
226 | if self.font.features and self.font.features.text:
227 | try:
228 | unparsed = FeaUnparser(self.font.features.text)
229 | self.fontfeatures = unparsed.ff
230 | except Exception as e:
231 | print("Could not load feature file: %s" % e)
232 |
233 | def saveOTF(self, filename):
234 | try:
235 | self.font.save(filename)
236 | ttfont = TTFont(filename)
237 | featurefile = UnicodeIO(self.fontfeatures.asFea())
238 | builder = Builder(ttfont, featurefile)
239 | catmap = { "base": 1, "ligature": 2, "mark": 3, "component": 4 }
240 | for g in self.font:
241 | if g.category in catmap:
242 | builder.setGlyphClass_(None, g.name, catmap[g.category])
243 | builder.build()
244 | ttfont.save(filename)
245 | except Exception as e:
246 | print(e)
247 | return str(e)
248 |
--------------------------------------------------------------------------------
/Flux/UI/qshapingdebugger.py:
--------------------------------------------------------------------------------
1 | from .qbufferrenderer import QBufferRenderer
2 | from PyQt5.QtWidgets import (
3 | QSplitter,
4 | QLineEdit,
5 | QLabel,
6 | QTableWidget,
7 | QTableWidgetItem,
8 | QAbstractItemView,
9 | QSizePolicy,
10 | QHeaderView,
11 | QFormLayout,
12 | QVBoxLayout,
13 | QHBoxLayout,
14 | QSlider,
15 | QGroupBox,
16 | QCheckBox,
17 | QWidget,
18 | )
19 | from PyQt5.QtCore import Qt
20 | from Flux.ThirdParty.QFlowLayout import QFlowLayout
21 | from fontFeatures.shaperLib.Shaper import Shaper
22 | from fontFeatures.shaperLib.BaseShaper import BaseShaper
23 | from Flux.variations import VariationAwareBuffer, VariationAwareBufferItem
24 | from copy import copy, deepcopy
25 | import re
26 |
27 |
28 | valid_glyph_name_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-*:^|~"
29 |
30 | class QShapingDebugger(QSplitter):
31 | def __init__(self, editor, project):
32 | self.editor = editor
33 | self.project = project
34 | super(QSplitter, self).__init__()
35 | self.text = self.project.debuggingText or self.getReasonableTextForFont(self.project.font)
36 |
37 | # First box: Text and features
38 | self.firstbox = QWidget()
39 | self.firstboxLayout = QVBoxLayout()
40 | self.firstbox.setLayout(self.firstboxLayout)
41 |
42 | textbox = QLineEdit()
43 | textbox.setText(self.text)
44 | textbox.setMaximumHeight(textbox.height())
45 | textbox.textChanged[str].connect(self.textChanged)
46 |
47 | self.featuregroup = QGroupBox("Features")
48 | self.featuregrouplayout = QFlowLayout()
49 | self.featuregroup.setLayout(self.featuregrouplayout)
50 | self.features = {}
51 | self.fillFeatureGroup()
52 | self.firstboxLayout.addWidget(textbox)
53 | self.firstboxLayout.addWidget(self.featuregroup)
54 |
55 | # Second box: Variations
56 | self.secondbox = QWidget()
57 | self.secondboxLayout = QHBoxLayout()
58 | self.secondbox.setLayout(self.secondboxLayout)
59 | self.sliders = []
60 |
61 | if self.project.variations:
62 | for axis in self.project.variations.designspace.axes:
63 | self.secondboxLayout.addWidget(QLabel(axis.name))
64 | slider = QSlider(0x01)
65 | slider.name = axis.name
66 | slider.setMinimum(axis.map_forward(axis.minimum))
67 | slider.setMaximum(axis.map_forward(axis.maximum))
68 | self.sliders.append(slider)
69 |
70 | slider.valueChanged.connect(self.shapeText)
71 | self.secondboxLayout.addWidget(slider)
72 | # Third box: Output and renderer
73 | self.thirdbox = QWidget()
74 | self.thirdboxLayout = QVBoxLayout()
75 | self.thirdbox.setLayout(self.thirdboxLayout)
76 |
77 | self.shaperOutput = QLabel()
78 | self.shaperOutput.setWordWrap(True)
79 | sp = self.shaperOutput.sizePolicy()
80 | sp.setVerticalPolicy(QSizePolicy.Maximum)
81 | self.shaperOutput.setSizePolicy(sp)
82 |
83 | self.qbr = QBufferRenderer(project, VariationAwareBuffer(self.project.font))
84 | sp = self.thirdbox.sizePolicy()
85 | sp.setHorizontalPolicy(QSizePolicy.Maximum)
86 | sp.setVerticalPolicy(QSizePolicy.MinimumExpanding)
87 | self.thirdbox.setSizePolicy(sp)
88 |
89 | self.thirdboxLayout.addWidget(self.shaperOutput)
90 | self.thirdboxLayout.addWidget(self.qbr)
91 |
92 | # Third box: message table
93 | self.messageTable = QTableWidget()
94 | self.messageTable.setColumnCount(2)
95 | self.messageTable.verticalHeader().setVisible(False)
96 | self.messageTable.setHorizontalHeaderLabels(["message", "buffer"])
97 | header = self.messageTable.horizontalHeader()
98 | headerWidth = self.messageTable.viewport().size().width()
99 | header.resizeSection(0, headerWidth * 2 / 3)
100 | header.setStretchLastSection(True)
101 | self.messageTable.setSelectionBehavior(QAbstractItemView.SelectRows)
102 | self.messageTable.selectionModel().selectionChanged.connect(
103 | self.renderPartialTrace
104 | )
105 |
106 | self.setOrientation(Qt.Vertical)
107 | self.addWidget(self.firstbox)
108 | if self.project.variations:
109 | self.addWidget(self.secondbox)
110 | self.addWidget(self.thirdbox)
111 | self.addWidget(self.messageTable)
112 | self.fullBuffer = None
113 | self.lastBuffer = None
114 | self.shapeText()
115 |
116 | def clearLayout(self, layout):
117 | if layout is not None:
118 | while layout.count():
119 | item = layout.takeAt(0)
120 | widget = item.widget()
121 | if widget is not None:
122 | widget.deleteLater()
123 | else:
124 | self.clearLayout(item.layout())
125 |
126 | def fillFeatureGroup(self):
127 | prev = self.features
128 | fkeys = self.project.fontfeatures.features.keys()
129 | self.clearLayout(self.featuregrouplayout)
130 | self.features = {}
131 | for k in fkeys:
132 | box = self.features[k] = QCheckBox(k)
133 | box.setTristate()
134 | if k in prev:
135 | box.setCheckState(prev[k].checkState())
136 | else:
137 | box.setCheckState(Qt.PartiallyChecked)
138 | box.stateChanged.connect(self.shapeText)
139 | self.featuregrouplayout.addWidget(box)
140 |
141 | def update(self):
142 | self.fillFeatureGroup()
143 | self.shapeText()
144 |
145 | def buildBuffer(self):
146 | buf = VariationAwareBuffer(self.project.font)
147 | t = self.text
148 | i = 0
149 | while i < len(t):
150 | if t[i] == "/": # Start of glyph name
151 | i = i + 1
152 | glyphname = ""
153 | while i < len(t) and t[i] in valid_glyph_name_chars:
154 | glyphname += t[i]
155 | i = i + 1
156 | if len(glyphname) and glyphname in self.project.font:
157 | item = VariationAwareBufferItem.new_glyph(glyphname, self.project.font, buf)
158 | item.codepoint = self.project.font.codepointForGlyph(glyphname)
159 | buf.items.append(item)
160 | else:
161 | buf.items.extend([VariationAwareBufferItem.new_unicode(ord(x), buf) for x in "/"+glyphname])
162 | else:
163 | item = VariationAwareBufferItem.new_unicode(ord(t[i]), buf)
164 | i = i + 1
165 | buf.items.append(item)
166 | buf.guess_segment_properties()
167 | return buf
168 |
169 | def shapeText(self):
170 | features = []
171 | for k, box in self.features.items():
172 | if box.checkState() == Qt.PartiallyChecked:
173 | continue
174 | features.append({"tag": k, "value": box.isChecked()})
175 |
176 | buf = self.buildBuffer()
177 |
178 | self.messageTable.setRowCount(0)
179 | if not self.text:
180 | buf.clear_mask()
181 | self.qbr.set_buf(buf)
182 | self.fullBuffer = buf
183 | self.shaperOutput.setText(buf.serialize())
184 | return
185 | self.messageTable.clearSelection()
186 | self.lastBuffer = None
187 | self.skipped = []
188 | self.partialBuffers = {}
189 | shaper = Shaper(
190 | self.project.fontfeatures,
191 | self.project.font,
192 | message_function=self.addToTable,
193 | )
194 | self.prep_shaper(shaper, buf, features)
195 | shaper.execute(buf, features=features)
196 |
197 | self.qbr.set_buf(buf)
198 | self.fullBuffer = buf
199 | self.shaperOutput.setText(buf.serialize())
200 |
201 | def prep_shaper(self, shaper, buf, features):
202 | if not self.sliders:
203 | return
204 | buf.vf = self.project.variations
205 | loc = { slider.name: slider.value() for slider in self.sliders }
206 | buf.location = loc
207 |
208 | self.qbr.set_location(loc)
209 |
210 |
211 | def addToTable(self, msg, buffer=None, serialize_options=None):
212 | if msg.startswith("Before"):
213 | return
214 | if not buffer: # Easy one
215 | rowPosition = self.messageTable.rowCount()
216 | self.messageTable.insertRow(rowPosition)
217 | message_item = QTableWidgetItem(msg)
218 | self.messageTable.setItem(rowPosition, 0, message_item)
219 | return
220 |
221 | # Urgh
222 | b = BaseShaper(None, None, buffer)
223 | for i in range(0,len(buffer.items)):
224 | b.propagate_attachment_offsets(i)
225 |
226 | ser = buffer.serialize(additional=serialize_options)
227 |
228 | if self.lastBuffer == ser:
229 | m = re.match(r"After (\w+ \(\w+\))", msg)
230 | if m:
231 | self.skipped.append(m[1])
232 | return
233 | elif self.skipped:
234 | rowPosition = self.messageTable.rowCount()
235 | self.messageTable.insertRow(rowPosition)
236 | message_item = QTableWidgetItem(
237 | "Routines executed but had no effect: %s" % ",".join(self.skipped)
238 | )
239 | self.messageTable.setItem(rowPosition, 0, message_item)
240 | self.skipped = []
241 | self.lastBuffer = ser
242 | rowPosition = self.messageTable.rowCount()
243 | self.messageTable.insertRow(rowPosition)
244 | message_item = QTableWidgetItem(msg)
245 | self.messageTable.setItem(rowPosition, 0, message_item)
246 | self.partialBuffers[rowPosition] = (copy(buffer), msg)
247 | self.partialBuffers[rowPosition][0].items = deepcopy(buffer.items)
248 | buffer_item = QTableWidgetItem(ser)
249 | self.messageTable.setItem(rowPosition, 1, buffer_item)
250 |
251 | def renderPartialTrace(self):
252 | indexes = self.messageTable.selectedIndexes()
253 | if len(indexes) != 2:
254 | return
255 | row = indexes[0].row()
256 | if row in self.partialBuffers:
257 | buf, msg = self.partialBuffers[row]
258 | self.qbr.set_buf(buf)
259 | m = re.match(r"After (\w+) \((\w+)\)", msg)
260 | if m and self.editor:
261 | routine, feature = m[1], m[2]
262 | self.editor.fontfeaturespanel.lookuplist.highlight(routine)
263 | self.editor.fontfeaturespanel.featurelist.highlight(feature, routine)
264 |
265 | # else:
266 | # self.qbr.set_buf(self.fullBuffer)
267 |
268 | def textChanged(self, text):
269 | self.text = text
270 | self.project.debuggingText = text
271 | self.shapeText()
272 |
273 | def getReasonableTextForFont(self, font):
274 | text = ""
275 | if font.glyphForCodepoint(0x627, fallback=False): # Arabic
276 | text = text + "ابج "
277 | if font.glyphForCodepoint(0x915, fallback=False): # Devanagari
278 | text = text + "कचण "
279 | if font.glyphForCodepoint(0x61, fallback=False): # Latin
280 | text = text + "abc "
281 | return text.strip()
282 |
--------------------------------------------------------------------------------
/Flux/editor.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from PyQt5.QtWidgets import (
3 | QWidget,
4 | QVBoxLayout,
5 | QApplication,
6 | QHBoxLayout,
7 | QStackedWidget,
8 | QMenuBar,
9 | QAction,
10 | QFileDialog,
11 | QSplitter,
12 | QMessageBox
13 | )
14 | from PyQt5.QtCore import Qt, QSettings, QStandardPaths
15 | from Flux.UI.qfontfeatures import QFontFeaturesPanel
16 | from Flux.UI.qshapingdebugger import QShapingDebugger
17 | from Flux.UI.qruleeditor import QRuleEditor
18 | from Flux.UI.qattachmenteditor import QAttachmentEditor
19 | from Flux.project import FluxProject
20 | from Flux.ThirdParty.qtoaster import QToaster
21 | import Flux.Plugins
22 | import os.path, pkgutil, sys
23 | from functools import partial
24 | from Flux.UI.GlyphActions import QGlyphActionPicker
25 |
26 |
27 | class FluxEditor(QSplitter):
28 | def __init__(self, proj):
29 | super(QSplitter, self).__init__()
30 | self.settings = QSettings()
31 | geometry = self.settings.value('mainwindowgeometry', '')
32 | if geometry:
33 | self.restoreGeometry(geometry)
34 |
35 | self.mainMenu = QMenuBar(self)
36 | self.project = proj
37 | if not proj:
38 | self.openFluxOrFont() # Exits if there still isn't one
39 | self.project.editor = self
40 | self.loadPlugins()
41 | self.setWindowTitle("Flux - %s" % (self.project.filename or self.project.fontfile))
42 | self.setupFileMenu()
43 | self.setupEditMenu()
44 | self.setupPluginMenu()
45 | self.left = QWidget()
46 | self.right = QWidget()
47 | self.rebuild_ui()
48 |
49 | def rebuild_ui(self):
50 | self.v_box_1 = QVBoxLayout()
51 | self.fontfeaturespanel = QFontFeaturesPanel(self.project, self)
52 | self.v_box_1.addWidget(self.fontfeaturespanel)
53 |
54 | self.setOrientation(Qt.Horizontal)
55 |
56 | self.v_box_2 = QVBoxLayout()
57 | self.stack = QStackedWidget()
58 | self.shapingDebugger = QShapingDebugger(self, self.project)
59 | self.ruleEditor = QRuleEditor(self.project, self, None)
60 | self.attachmentEditor = QAttachmentEditor(self.project, self, None)
61 | self.stack.addWidget(self.shapingDebugger)
62 | self.stack.addWidget(self.ruleEditor)
63 | self.stack.addWidget(self.attachmentEditor)
64 | self.v_box_2.addWidget(self.stack)
65 |
66 | if self.left.layout():
67 | QWidget().setLayout(self.left.layout())
68 | QWidget().setLayout(self.right.layout())
69 | self.left.setLayout(self.v_box_1)
70 | self.right.setLayout(self.v_box_2)
71 | self.addWidget(self.left)
72 | self.addWidget(self.right)
73 |
74 | def loadPlugins(self):
75 | pluginpath = os.path.dirname(Flux.Plugins.__file__)
76 | if hasattr(sys, "frozen"):
77 | pluginpath = "lib/python3.8/flux/Plugins"
78 | pluginpath2 = os.path.join(QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)[0], "Plugins")
79 | plugin_loaders = pkgutil.iter_modules([pluginpath, pluginpath2])
80 | self.plugins = {}
81 | for loader, module_name, is_pkg in plugin_loaders:
82 | if is_pkg:
83 | continue
84 | _module = loader.find_module(module_name).load_module(module_name)
85 | _module.module_name = module_name
86 | self.plugins[module_name] = _module
87 |
88 |
89 | def setupFileMenu(self):
90 | openFile = QAction("&New Project", self)
91 | openFile.setShortcut("Ctrl+N")
92 | openFile.setStatusTip("New Project")
93 | openFile.triggered.connect(self.newProject)
94 |
95 | self.saveFile = QAction("&Save", self)
96 | self.saveFile.setShortcut("Ctrl+S")
97 | self.saveFile.setStatusTip("Save")
98 | self.saveFile.triggered.connect(self.file_save)
99 | if not hasattr(self.project, "filename"):
100 | self.saveFile.setEnabled(False)
101 |
102 | saveAsFile = QAction("&Save As...", self)
103 | saveAsFile.setStatusTip("Save As...")
104 | saveAsFile.triggered.connect(self.file_save_as)
105 |
106 | importFea = QAction("Import FEA", self)
107 | importFea.triggered.connect(self.importFEA)
108 |
109 | exportFea = QAction("Export FEA", self)
110 | exportFea.triggered.connect(self.exportFEA)
111 |
112 | exportOtf = QAction("Export OTF", self)
113 | exportOtf.triggered.connect(self.exportOTF)
114 |
115 | fileMenu = self.mainMenu.addMenu("&File")
116 | fileMenu.addAction(openFile)
117 | fileMenu.addAction(self.saveFile)
118 | fileMenu.addAction(saveAsFile)
119 | fileMenu.addSeparator()
120 | fileMenu.addAction(importFea)
121 | fileMenu.addSeparator()
122 | fileMenu.addAction(exportFea)
123 | fileMenu.addAction(exportOtf)
124 |
125 | def setupEditMenu(self):
126 | editMenu = self.mainMenu.addMenu("&Edit")
127 | glyphedit = QAction("Glyph editor", self)
128 | glyphedit.triggered.connect(lambda: QGlyphActionPicker.pickGlyph(self.project))
129 | editMenu.addAction(glyphedit)
130 |
131 | def setupPluginMenu(self):
132 | pluginMenu = self.mainMenu.addMenu("&Plugins")
133 | for plugin in self.plugins.values():
134 | p = QAction(plugin.plugin_name, self)
135 | p.triggered.connect(partial(self.runPlugin, plugin))
136 | pluginMenu.addAction(p)
137 | pluginMenu.addSeparator()
138 | dummy = QAction("Reload plugins", self)
139 | pluginMenu.addAction(dummy)
140 |
141 | def runPlugin(self,plugin):
142 | print(plugin.plugin_name)
143 | dialog = plugin.Dialog(self.project)
144 | result = dialog.exec_()
145 | if result:
146 | # Update everything
147 | self.update()
148 |
149 | def newProject(self):
150 | if self.project and self.isWindowModified():
151 | # Offer chance to save
152 | pass
153 | # Open the glyphs file
154 | glyphs = QFileDialog.getOpenFileName(
155 | self, "Open font file", filter="Font file (*.glyphs *.ufo *.otf *.ttf *.designspace)"
156 | )
157 | if not glyphs:
158 | return
159 | self.project = FluxProject.new(glyphs[0], editor=self)
160 | self.setWindowTitle("Flux - %s" % (self.project.filename or self.project.fontfile))
161 | self.rebuild_ui()
162 |
163 | def openFluxOrFont(self): # Exits if there still isn't one
164 | msg = QMessageBox()
165 | msg.setIcon(QMessageBox.Information)
166 |
167 | msg.setText("Please open a font file or a .fluxml file to get started")
168 | msg.setWindowTitle("Flux")
169 | msg.setStandardButtons(QMessageBox.Ok)
170 | msg.exec_()
171 |
172 | filename = QFileDialog.getOpenFileName(
173 | self, "Open Flux file", filter="Flux or font file (*.glyphs *.ufo *.otf *.ttf *.designspace *.fluxml)"
174 | )
175 | if not filename or not filename[0]:
176 | if not self.project:
177 | sys.exit(0)
178 | return
179 | if filename[0].endswith(".fluxml"):
180 | self.project = FluxProject(filename[0], editor=self)
181 | else:
182 | self.project = FluxProject.new(filename[0], editor=self)
183 | self.setWindowTitle("Flux - %s" % (self.project.filename or self.project.fontfile))
184 |
185 | def file_save_as(self):
186 | filename = QFileDialog.getSaveFileName(
187 | self, "Save File", filter="Flux projects (*.fluxml)"
188 | )
189 | if filename and filename[0]:
190 | self.project.filename = filename[0]
191 | self.project.save(filename[0])
192 | QToaster.showMessage(self, "Saved successfully", desktop=True)
193 | self.setWindowTitle("Flux - %s" % (self.project.filename or "New Project"))
194 | self.saveFile.setEnabled(True)
195 | self.setWindowModified(False)
196 |
197 | def file_save(self):
198 | if not self.project.filename:
199 | return self.file_save_as()
200 | self.project.save(self.project.filename)
201 | QToaster.showMessage(self, "Saved successfully", desktop=True)
202 | self.setWindowModified(False)
203 |
204 | def importFEA(self):
205 | filename = QFileDialog.getOpenFileName(
206 | self, "Open File", filter="AFDKO feature file (*.fea)"
207 | )
208 | if not filename:
209 | return
210 | res = self.project.loadFEA(filename[0])
211 | if res is None:
212 | QToaster.showMessage(self, "Imported successfully", desktop=True, parentWindow=False)
213 | self.update()
214 | else:
215 | QToaster.showMessage(self, "Failed to import: " + res, desktop=True)
216 |
217 | def exportFEA(self):
218 | filename = QFileDialog.getSaveFileName(
219 | self, "Save File", filter="AFDKO feature file (*.fea)"
220 | )
221 | if not filename:
222 | return
223 | res = self.project.saveFEA(filename[0])
224 | if res is None:
225 | QToaster.showMessage(self, "Saved successfully", desktop=True)
226 | else:
227 | QToaster.showMessage(self, "Failed to save: " + res, desktop=True)
228 |
229 | def exportOTF(self):
230 | filename = QFileDialog.getSaveFileName(
231 | self, "Save File", filter="OpenType Font (*.ttf)"
232 | )
233 | if not filename:
234 | return
235 | res = self.project.saveOTF(filename[0])
236 | if res is None:
237 | QToaster.showMessage(self, "Saved successfully", desktop=True)
238 | else:
239 | QToaster.showMessage(self, "Failed to save: " + res, desktop=True)
240 |
241 | def update(self):
242 | self.fontfeaturespanel.update()
243 | self.shapingDebugger.update()
244 | super().update()
245 |
246 | def reshape(self):
247 | self.shapingDebugger.update()
248 |
249 | def showRuleEditor(self, rule, index=None):
250 | self.ruleEditor.setRule(rule, index)
251 | self.stack.setCurrentIndex(1)
252 | pass
253 |
254 | def showAttachmentEditor(self, rule, index=None):
255 | self.attachmentEditor.setRule(rule, index)
256 | self.stack.setCurrentIndex(2)
257 | pass
258 |
259 | def showDebugger(self):
260 | self.stack.setCurrentIndex(0)
261 | pass
262 |
263 | def closeEvent(self, event):
264 | geometry = self.saveGeometry()
265 | self.settings.setValue('mainwindowgeometry', geometry)
266 | if not self.isWindowModified():
267 | event.accept()
268 | return
269 | quit_msg = "You have unsaved changes. Are you sure you want to exit the program?"
270 | reply = QMessageBox.question(self, 'Message',
271 | quit_msg, QMessageBox.Yes, QMessageBox.No)
272 |
273 | if reply == QMessageBox.Yes:
274 | event.accept()
275 | else:
276 | event.ignore()
277 |
278 | def showError(self, message):
279 | msg = QMessageBox()
280 | msg.setIcon(QMessageBox.Critical)
281 |
282 | msg.setText("Something went wrong")
283 | msg.setInformativeText(message)
284 | msg.setWindowTitle("Flux")
285 | msg.setStandardButtons(QMessageBox.Ok)
286 | msg.exec_()
287 |
288 |
--------------------------------------------------------------------------------
/Flux/UI/glyphpredicateeditor.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtCore import Qt, pyqtSlot, QModelIndex, QAbstractTableModel, QItemSelectionModel, pyqtSignal
2 | from PyQt5.QtGui import QStandardItem, QStandardItemModel
3 | from PyQt5.QtWidgets import QTreeView, QMenu,QComboBox, QHBoxLayout, QVBoxLayout, QPushButton, QLineEdit, QLabel, QDialog, QTextEdit, QDialogButtonBox
4 | import re
5 | from glyphtools import get_glyph_metrics
6 |
7 |
8 | PREDICATE_TYPES = {
9 | "Name": {"textbox": True, "comparator": False},
10 | "Is member of": {"textbox": False, "comparator": False},
11 | "Is category": {"textbox": False, "comparator": False},
12 | "width": {"textbox": True, "comparator": True},
13 | "height": {"textbox": True, "comparator": True},
14 | "depth": {"textbox": True, "comparator": True},
15 | "xMax": {"textbox": True, "comparator": True},
16 | "yMax": {"textbox": True, "comparator": True},
17 | "xMin": {"textbox": True, "comparator": True},
18 | "yMin": {"textbox": True, "comparator": True},
19 | "rise": {"textbox": True, "comparator": True},
20 | "Has anchor": {"textbox": True, "comparator": False}
21 | }
22 |
23 | class GlyphClassPredicate:
24 | def __init__(self, predicate_dict = {}):
25 | self.comparator = None
26 | self.combiner = None
27 | self.value = None
28 | self.metric = None
29 | if "type" in predicate_dict:
30 | self.type = predicate_dict["type"]
31 | if "comparator" in predicate_dict:
32 | self.comparator = predicate_dict["comparator"]
33 | if "value" in predicate_dict:
34 | self.value = predicate_dict["value"]
35 | if "metric" in predicate_dict: # Metric, really
36 | self.metric = predicate_dict["metric"]
37 | if "combiner" in predicate_dict:
38 | self.combiner = predicate_dict["combiner"]
39 |
40 | def test(self, glyphset, font, infocache):
41 | matches = []
42 | if self.type == "Name":
43 | # print(self.comparator, self.value)
44 | if self.comparator == "begins":
45 | matches = [x for x in glyphset if x.startswith(self.value)]
46 | elif self.comparator == "ends":
47 | matches = [x for x in glyphset if x.endswith(self.value)]
48 | elif self.comparator == "matches":
49 | try:
50 | matches = [x for x in glyphset if re.search(self.value,x)]
51 | except Exception as e:
52 | matches = []
53 |
54 | # XXX HasAnchor
55 | # XXX Is member of
56 | # XXX Is Category
57 |
58 | if self.metric:
59 | matches = []
60 | try:
61 | for g in glyphset:
62 | if g not in infocache:
63 | infocache[g] = { "metrics": get_glyph_metrics(font, g) }
64 |
65 | got = infocache[g]["metrics"][self.type]
66 | expected, comp = int(self.value), self.comparator
67 | if (comp == ">" and got > expected) or (comp == "<" and got < expected) or (comp == "=" and got == expected) or (comp == "<=" and got <= expected) or (comp == ">=" and got >= expected):
68 | matches.append(g)
69 | except Exception as e:
70 | print(e)
71 | pass
72 |
73 | return matches
74 |
75 | def to_dict(self):
76 | d = { "type": self.type }
77 | if self.comparator:
78 | d["comparator"] = self.comparator
79 | if self.combiner:
80 | d["combiner"] = self.combiner
81 | if self.value is not None:
82 | d["value"] = self.value
83 | if self.metric:
84 | d["metric"] = self.metric
85 | return d
86 |
87 | class GlyphClassPredicateTester:
88 | def __init__(self, project):
89 | self.project = project
90 | self.infocache = {}
91 | self.allGlyphs = self.project.font.keys()
92 |
93 | def test_all(self, predicates):
94 | matches = self.allGlyphs
95 | if len(predicates) > 0:
96 | matches = predicates[0].test(matches, self.project.font, self.infocache)
97 | for p in predicates[1:]:
98 | if p.combiner == "and":
99 | # Narrow down existing set
100 | matches = p.test(matches, self.project.font, self.infocache)
101 | else:
102 | thisPredicateMatches = p.test(self.allGlyphs, self.project.font, self.infocache)
103 | matches = set(matches) | set(thisPredicateMatches)
104 | return matches
105 |
106 | class GlyphClassPredicateRow(QHBoxLayout):
107 | changed = pyqtSignal()
108 |
109 | def __init__(self, editor, predicate = None):
110 | super(QHBoxLayout, self).__init__()
111 | # print("Initializing with ", arguments)
112 | self.editor = editor
113 | self.project = editor.project
114 | self.matches = []
115 | self.predicate = predicate
116 | self.predicateType = QComboBox()
117 | for n in PREDICATE_TYPES.keys():
118 | self.predicateType.addItem(n)
119 | self.predicateType.currentIndexChanged.connect(self.maybeChangeType)
120 | self.combiner = QComboBox()
121 | self.combiner.addItem("and")
122 | self.combiner.addItem("or")
123 | self.combiner.currentIndexChanged.connect(self.changed.emit)
124 | self.addWidget(self.combiner)
125 | self.addWidget(QLabel("Glyph"))
126 | self.addWidget(self.predicateType)
127 | self.changed.connect(self.serialize)
128 | self.changed.connect(lambda :self.editor.changed.emit())
129 | self.reset()
130 |
131 | def maybeChangeType(self):
132 | if self.predicateType.currentText() != self.predicate.type:
133 | self.predicate.type = self.predicateType.currentText()
134 | self.reset()
135 |
136 | def reset(self): # Call this when the type changes
137 | for i in reversed(range(3,self.count())): # We keep the combo boxes
138 | if self.itemAt(i).widget():
139 | self.itemAt(i).widget().setParent(None)
140 |
141 | typeIx = self.predicateType.findText(self.predicate.type)
142 | if typeIx != 1:
143 | self.predicateType.setCurrentIndex(typeIx)
144 | # print(self.arguments["type"])
145 | if self.predicate.type == "Name":
146 | self.nameCB = QComboBox()
147 | self.nameCB.addItems(["begins","ends","matches"])
148 | if self.predicate.comparator:
149 | ix = self.nameCB.findText(self.predicate.comparator)
150 | if ix != 1:
151 | self.nameCB.setCurrentIndex(ix)
152 | self.addWidget(self.nameCB)
153 | self.nameCB.currentIndexChanged.connect(self.changed.emit)
154 |
155 | if self.predicate.type == "Is category":
156 | self.categoryCB = QComboBox()
157 | self.categoryCB.addItems(["base","ligature","mark","component"])
158 | if self.arguments.value:
159 | ix = self.categoryCB.findText(self.predicate.value)
160 | if ix != 1:
161 | self.categoryCB.setCurrentIndex(ix)
162 |
163 | self.addWidget(self.categoryCB)
164 | self.categoryCB.currentIndexChanged.connect(self.changed.emit)
165 |
166 | if PREDICATE_TYPES[self.predicate.type]["comparator"]:
167 | self.comparator = QComboBox()
168 | self.comparator.addItems(["<","<=","=", ">=", ">"])
169 | if self.predicate.comparator:
170 | ix = self.comparator.findText(self.predicate.comparator)
171 | if ix != 1:
172 | self.comparator.setCurrentIndex(ix)
173 | self.comparator.currentIndexChanged.connect(self.changed.emit)
174 | self.addWidget(self.comparator)
175 |
176 | if PREDICATE_TYPES[self.predicate.type]["textbox"]:
177 | self.textBox = QLineEdit()
178 | if self.predicate.value:
179 | self.textBox.setText(self.predicate.value)
180 | elif PREDICATE_TYPES[self.predicate.type]["comparator"]:
181 | self.textBox.setText("200")
182 | self.textBox.textChanged.connect(self.changed.emit)
183 | self.addWidget(self.textBox)
184 | self.addStretch(999)
185 | self.plus = QPushButton("+")
186 | self.addWidget(self.plus)
187 | self.plus.clicked.connect(self.editor.addRow)
188 | self.minus = QPushButton("-")
189 | self.addWidget(self.minus)
190 | self.changed.emit()
191 |
192 | def serialize(self):
193 | self.predicate = GlyphClassPredicate()
194 | self.predicate.type = self.predicateType.currentText()
195 | self.predicate.combiner = self.combiner.currentText()
196 | if PREDICATE_TYPES[self.predicate.type]["textbox"]:
197 | self.predicate.value = self.textBox.text()
198 | if self.predicate.type == "Name":
199 | self.predicate.comparator = self.nameCB.currentText()
200 | elif self.predicate.type == "Is member of":
201 | #self.predicate.class = self.classCB.currentText()
202 | pass
203 | elif self.predicate.type == "Has anchor":
204 | #self.predicate.anchor = self.anchorCB.currentText()
205 | pass
206 | elif self.predicate.type == "Is category":
207 | self.predicate.category = self.categoryCB.currentText()
208 | pass
209 |
210 | if PREDICATE_TYPES[self.predicate.type]["comparator"]:
211 | self.predicate.metric = self.predicate.type
212 | self.predicate.comparator = self.comparator.currentText()
213 | # print(self.arguments)
214 |
215 | class AutomatedGlyphClassDialog(QDialog):
216 | def __init__(self, font, predicates = []):
217 | super(QDialog, self).__init__()
218 | self.font = font
219 | v_box_1 = QVBoxLayout()
220 | self.gpe = GlyphClassPredicateEditor(font, predicates)
221 | v_box_1.addLayout(self.gpe)
222 | self.qte = QTextEdit()
223 | v_box_1.addWidget(self.qte)
224 | buttons = QDialogButtonBox(
225 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
226 | Qt.Horizontal, self)
227 | buttons.accepted.connect(self.accept)
228 | buttons.rejected.connect(self.reject)
229 | v_box_1.addWidget(buttons)
230 | self.setLayout(v_box_1)
231 | self.gpe.changed.connect(self.update)
232 | self.update()
233 |
234 | def update(self):
235 | self.qte.setText(" ".join(sorted(self.gpe.matches)))
236 |
237 | def getPredicateRows(self):
238 | return self.gpe.predicateRows
239 |
240 | @staticmethod
241 | def editDefinition(project, predicates = []):
242 | dialog = AutomatedGlyphClassDialog(project, [ GlyphClassPredicate(p) for p in predicates])
243 | result = dialog.exec_()
244 | predicaterows = dialog.getPredicateRows()
245 | for x in predicaterows:
246 | x.serialize()
247 | predicates = [ x.predicate.to_dict() for x in predicaterows ]
248 | print(predicates)
249 | return (predicates, result == QDialog.Accepted)
250 |
251 | class GlyphClassPredicateEditor(QVBoxLayout):
252 | changed = pyqtSignal()
253 |
254 | def __init__(self, project, existingpredicates = []):
255 | super(QVBoxLayout, self).__init__()
256 | self.project = project
257 | predicateItems = []
258 | if len(existingpredicates) == 0:
259 | predicateItems.append(GlyphClassPredicateRow(self, GlyphClassPredicate({"type": "Name"})))
260 | for p in existingpredicates:
261 | predicateItems.append(GlyphClassPredicateRow(self, p))
262 | for p in predicateItems:
263 | self.addLayout(p)
264 | predicateItems[-1].minus.setEnabled(False)
265 | predicateItems[0].combiner.hide()
266 | self.matches = []
267 | self.tester = GlyphClassPredicateTester(self.project)
268 | self.changed.connect(self.testAll)
269 | self.testAll()
270 |
271 | def addRow(self):
272 | self.addLayout(GlyphClassPredicateRow(self, GlyphClassPredicate({"type": "Name"})))
273 |
274 | def testAll(self):
275 | self.matches = self.tester.test_all([x.predicate for x in self.predicateRows])
276 |
277 | @property
278 | def predicateRows(self):
279 | return [self.itemAt(i) for i in range(self.count())]
280 |
--------------------------------------------------------------------------------
/Flux/UI/featurelist.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QWidget,
3 | QApplication,
4 | QVBoxLayout,
5 | QTreeView,
6 | QMenu,
7 | QAbstractItemView,
8 | )
9 | from PyQt5.QtCore import (
10 | QAbstractItemModel,
11 | QItemSelectionModel,
12 | QModelIndex,
13 | Qt,
14 | pyqtSlot,
15 | QByteArray,
16 | QDataStream,
17 | QIODevice,
18 | QMimeData,
19 | QVariant,
20 | )
21 | from PyQt5.QtGui import QDrag
22 | import sys
23 | from fontFeatures import Routine
24 | from Flux.project import FluxProject
25 | from Flux.ThirdParty.HTMLDelegate import HTMLDelegate
26 | from Flux.constants import FEATURE_DESCRIPTIONS
27 |
28 |
29 | class FeatureList(QTreeView):
30 | def __init__(self, project, parent):
31 | super(QTreeView, self).__init__()
32 | self.project = project
33 | self.parent = parent
34 | self.setItemDelegate(HTMLDelegate())
35 | self.setModel(FeatureListModel(project))
36 | self.setContextMenuPolicy(Qt.CustomContextMenu)
37 | self.setSelectionBehavior(self.SelectRows)
38 | self.setDragEnabled(True)
39 | self.setAcceptDrops(True)
40 | self.setDropIndicatorShown(True)
41 | self.setDragDropMode(QAbstractItemView.DragDrop)
42 | self.customContextMenuRequested.connect(self.contextMenu)
43 | self.doubleClicked.connect(self.doubleClickHandler)
44 | self.model().dataChanged.connect(self.reshape)
45 |
46 | def highlight(self, feature, routine=None):
47 | self.collapseAll()
48 | featureRow = list(self.project.fontfeatures.features.keys()).index(feature)
49 | index = self.model().index(featureRow, 0)
50 | if featureRow:
51 | self.scrollTo(self.model().index(featureRow + 1, 0))
52 | self.setCurrentIndex(index)
53 | self.setExpanded(index, True)
54 | if routine:
55 | routines = [x.name for x in self.project.fontfeatures.features[feature]]
56 | routineRow = routines.index(routine)
57 | index = self.model().index(routineRow, 0, index)
58 | self.scrollTo(self.model().index(featureRow + 1, 0))
59 | self.setCurrentIndex(index)
60 | self.setExpanded(index, True)
61 | pass
62 |
63 | def reshape(self):
64 | self.parent.editor.reshape()
65 |
66 | def update(self):
67 | self.model().beginResetModel()
68 | self.model().endResetModel()
69 | super().update()
70 |
71 | def decode_data(self, bytearray):
72 |
73 | data = []
74 | item = {}
75 |
76 | ds = QDataStream(bytearray)
77 | while not ds.atEnd():
78 |
79 | row = ds.readInt32()
80 | column = ds.readInt32()
81 |
82 | map_items = ds.readInt32()
83 | for i in range(map_items):
84 |
85 | key = ds.readInt32()
86 |
87 | value = QVariant()
88 | ds >> value
89 | item[Qt.ItemDataRole(key)] = value.value()
90 |
91 | data.append(item)
92 |
93 | return data
94 |
95 | def dragEnterEvent(self, event):
96 | if (
97 | event.mimeData().hasFormat("application/x-qabstractitemmodeldatalist")
98 | and event.source() != self
99 | ):
100 | self.showDropIndicator()
101 | event.acceptProposedAction()
102 | else:
103 | return super().dragEnterEvent(event)
104 |
105 | def dragMoveEvent(self, event):
106 | if (
107 | event.mimeData().hasFormat("application/x-qabstractitemmodeldatalist")
108 | and event.source() != self
109 | ):
110 | self.showDropIndicator()
111 | event.acceptProposedAction()
112 | else:
113 | return super().dragMoveEvent(event)
114 |
115 | def dropEvent(self, event):
116 | data = event.mimeData()
117 | if event.source() == self:
118 | print("Local")
119 | event.setDropAction(Qt.MoveAction)
120 |
121 | return super(QTreeView, self).dropEvent(event)
122 | print("Foreign")
123 | if data.hasFormat("application/x-qabstractitemmodeldatalist"):
124 | ba = data.data("application/x-qabstractitemmodeldatalist")
125 | data_items = self.decode_data(ba)
126 | routineName = data_items[0][0]
127 | routine = self.model().routineCalled(routineName)
128 | insertPos = event.pos()
129 | destination = self.indexAt(event.pos())
130 | if self.model().indexIsFeature(destination):
131 | # Easy-peasy
132 | destFeature = list(self.project.fontfeatures.features.keys())[
133 | destination.row()
134 | ]
135 | routineList = self.project.fontfeatures.features[destFeature]
136 | print(f"Dropping {routineName} to end of {destFeature}")
137 | self.project.fontfeatures.features[destFeature].append(routine)
138 | self.model().dataChanged.emit(destination, destination)
139 | self.update()
140 | self.setExpanded(destination, True)
141 | self.parent.editor.setWindowModified(True)
142 | elif self.model().indexIsRoutine(destination):
143 | destParent = self.model().parent(destination)
144 | print(
145 | "Parent destination for this drop is: ",
146 | destParent.row(),
147 | destParent.column(),
148 | destParent.internalPointer(),
149 | )
150 | print("Destination inside parent is ", destination.row())
151 | self.model().insertRows(destination.row(), 1, destParent)
152 | self.model().dataChanged.emit(destination, destination)
153 | self.model().setData(destination, routineName)
154 | self.parent.editor.setWindowModified(True)
155 | else:
156 | event.reject()
157 | return
158 | event.accept()
159 |
160 | def contextMenu(self, position):
161 | indexes = self.selectedIndexes()
162 | menu = QMenu()
163 | menu.addAction("Add feature", self.addFeature)
164 | if len(indexes) > 0:
165 | if self.model().indexIsRoutine(indexes[0]):
166 | menu.addAction("Delete routine", self.deleteItem)
167 | else:
168 | menu.addAction("Delete feature", self.deleteItem)
169 | menu.exec_(self.viewport().mapToGlobal(position))
170 |
171 | def doubleClickHandler(self, index):
172 | pass
173 |
174 | @pyqtSlot()
175 | def addFeature(self):
176 | index = self.model().appendRow()
177 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect)
178 | self.parent.editor.setWindowModified(True)
179 | self.edit(index)
180 |
181 | @pyqtSlot()
182 | def deleteItem(self):
183 | # Check if routine is in use
184 | self.model().removeRowWithIndex(self.selectedIndexes()[0])
185 | self.parent.editor.setWindowModified(True)
186 | self.parent.editor.update()
187 |
188 | @pyqtSlot()
189 | def deleteRoutine(self):
190 | # Check if routine is in use
191 | self.model().removeRowWithIndex(self.selectedIndexes()[0])
192 | self.parent.editor.setWindowModified(True)
193 | self.parent.editor.update()
194 |
195 |
196 | class FeatureListModel(QAbstractItemModel):
197 | def __init__(self, proj, parent=None):
198 | super(FeatureListModel, self).__init__(parent)
199 | self.project = proj
200 | self.rootIndex = QModelIndex()
201 | self.retained_objects = {}
202 |
203 | # Horrific hack to avoid GC bug
204 | def makeSingleton(self, row):
205 | if not row in self.retained_objects:
206 | self.retained_objects[row] = {"row": row}
207 | return self.retained_objects[row]
208 |
209 | def headerData(self, section, orientation, role):
210 | if role != Qt.DisplayRole:
211 | return None
212 | return "Features"
213 |
214 | def rowCount(self, index=QModelIndex()):
215 | # print("Getting row count at index ", self.describeIndex(index))
216 | if index.row() == -1:
217 | return len(self.project.fontfeatures.features.keys())
218 | if index.isValid():
219 | item = index.internalPointer()
220 | if isinstance(item, list):
221 | return len(item)
222 | return 0
223 |
224 | def getFeatureNameAtRow(self, row):
225 | keys = list(self.project.fontfeatures.features.keys())
226 | return str(keys[row])
227 |
228 | def getRoutinesAtRow(self, row):
229 | values = list(self.project.fontfeatures.features.values())
230 | return values[row]
231 |
232 | def columnCount(self, index=QModelIndex()):
233 | return 1
234 |
235 | def describeIndex(self, index):
236 | if not index.isValid():
237 | return "root of tree"
238 | if self.indexIsFeature(index):
239 | frow = index.row()
240 | return f"feature at row {frow}"
241 | else:
242 | parentrow = index.internalPointer()["row"]
243 | return f"Item {index.row()} of feature at row {parentrow}"
244 |
245 | def parent(self, index):
246 | if not index.isValid():
247 | return QModelIndex()
248 | if isinstance(index.internalPointer(), list):
249 | return QModelIndex()
250 | else:
251 | row = index.internalPointer()["row"]
252 | return self.index(row, 0)
253 | return index.internalPointer()
254 |
255 | def index(self, row, column, parent=QModelIndex()):
256 | """ Returns the index of the item in the model specified by the given row, column and parent index """
257 | if not self.hasIndex(row, column, parent):
258 | return QModelIndex()
259 | if not parent.isValid():
260 | ix = self.createIndex(row, column, self.getRoutinesAtRow(row))
261 | else:
262 | ix = self.createIndex(row, column, self.makeSingleton(parent.row()))
263 | return ix
264 |
265 | def change_key(self, old, new):
266 | for _ in range(len(self.project.fontfeatures.features)):
267 | k, v = self.project.fontfeatures.features.popitem(False)
268 | self.project.fontfeatures.features[new if old == k else k] = v
269 |
270 | def setData(self, index, value, role=Qt.EditRole):
271 | print("Set data called", index, index.row(), index.column())
272 | print(
273 | "Parent ",
274 | self.parent(index),
275 | self.parent(index).row(),
276 | self.parent(index).column(),
277 | )
278 | print("Role was", role)
279 | print("Index was " + self.describeIndex(index))
280 | print("Value was ", value)
281 | print("Internal pointer", index.internalPointer())
282 | if self.indexIsFeature(index):
283 | print("Renaming a feature", index.internalPointer())
284 | self.dataChanged.emit(index, index)
285 | self.change_key(
286 | list(self.project.fontfeatures.features.keys())[index.row()], value
287 | )
288 | return True
289 | else:
290 | routines = self.getRoutinesAtRow(index.internalPointer()["row"])
291 | print(
292 | "Internal pointer of parent before set",
293 | self.parent(index).internalPointer(),
294 | )
295 | print(
296 | "Setting routine",
297 | index.row(),
298 | index.column(),
299 | routines,
300 | self.parent(index),
301 | )
302 | routines[index.row()] = self.routineCalled(value)
303 | self.dataChanged.emit(index, index)
304 | print(
305 | "Internal pointer of parent now", self.parent(index).internalPointer()
306 | )
307 | return True
308 |
309 | def routineCalled(self, value):
310 | return ([x for x in self.project.fontfeatures.routines if x.name == value])[0]
311 |
312 | def indexIsRoutine(self, index):
313 | return index.isValid() and isinstance(index.internalPointer(), dict)
314 |
315 | def getRoutine(self, index):
316 | routines = self.getRoutinesAtRow(index.internalPointer()["row"])
317 | return routines[index.row()]
318 |
319 | def indexIsFeature(self, index):
320 | return index.isValid() and isinstance(index.internalPointer(), list)
321 |
322 | def data(self, index, role=Qt.DisplayRole):
323 | if not index.isValid():
324 | return None
325 | if role == Qt.DisplayRole or role == Qt.EditRole:
326 | if self.indexIsFeature(index):
327 | featureName = self.getFeatureNameAtRow(index.row())
328 | if role == Qt.EditRole:
329 | return featureName
330 | featureDescription = FEATURE_DESCRIPTIONS.get(featureName, "")
331 | return f'{featureName} {featureDescription}'
332 | else:
333 | routine = self.getRoutine(index)
334 | if routine:
335 | return routine.name
336 | else:
337 | return ""
338 | return None
339 |
340 | def flags(self, index):
341 | if not index.isValid():
342 | return Qt.NoItemFlags
343 | flag = Qt.ItemFlags(QAbstractItemModel.flags(self, index))
344 | if self.indexIsFeature(index):
345 | flag = (
346 | flag | Qt.ItemIsEditable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
347 | )
348 | return flag | Qt.ItemIsDragEnabled
349 |
350 | def insertRows(self, row, count, parent=QModelIndex()):
351 | """ Insert a row into the model. """
352 | print("Inserting a row at row ", row)
353 | print("Parent= ", self.describeIndex(parent))
354 | self.beginInsertRows(parent, row, row + count)
355 | if not parent.isValid():
356 | self.project.fontfeatures.features[""] = []
357 | else:
358 | parent.internalPointer().insert(row, None)
359 | print("Internal pointer of parent is now", parent.internalPointer())
360 | self.endInsertRows()
361 | return True
362 |
363 | def appendRow(self):
364 | self.insertRows(len(self.project.fontfeatures.features.keys()), 1)
365 | return self.index(len(self.project.fontfeatures.features.keys()) - 1, 0)
366 |
367 | def removeRows(self, row, count, parent):
368 | assert count == 1
369 | index = self.index(row, 0, parent)
370 | return self.removeRowWithIndex(index)
371 |
372 | def removeRowWithIndex(self, index):
373 | print("Remove row called", index)
374 | self.beginRemoveRows(self.parent(index), index.row(), index.row())
375 | if self.indexIsFeature(index):
376 | key = list(self.project.fontfeatures.features.keys())[index.row()]
377 | del self.project.fontfeatures.features[key]
378 | else:
379 | routineList = self.getRoutinesAtRow(index.internalPointer()["row"])
380 | del routineList[index.row()]
381 | self.endRemoveRows()
382 | return True
383 |
384 | def addRule(self, ix, rule):
385 | # lookup = ix.internalPointer()
386 | # self.beginInsertRows(ix, ix.row(), ix.row())
387 | # lookup.rules.append(rule)
388 | # self.endInsertRows()
389 | return True
390 |
391 | def supportedDropActions(self):
392 | return Qt.MoveAction # | Qt.CopyAction
393 |
394 |
395 | if __name__ == "__main__":
396 | app = 0
397 | if QApplication.instance():
398 | app = QApplication.instance()
399 | else:
400 | app = QApplication(sys.argv)
401 |
402 | w = QWidget()
403 | w.resize(510, 210)
404 | v_box_1 = QVBoxLayout()
405 |
406 | proj = FluxProject("qalam.fluxml")
407 | tree = FeatureList(proj, None)
408 | v_box_1.addWidget(tree)
409 |
410 | w.setLayout(v_box_1)
411 |
412 | w.show()
413 | sys.exit(app.exec_())
414 |
--------------------------------------------------------------------------------
/Flux/UI/lookuplist.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QWidget,
3 | QApplication,
4 | QVBoxLayout,
5 | QTreeView,
6 | QMenu,
7 | QAbstractItemView,
8 | QCheckBox,
9 | QDialog,
10 | QDialogButtonBox,
11 | QLabel,
12 | QLineEdit,
13 | QMessageBox
14 | )
15 | from PyQt5.QtCore import (
16 | QAbstractItemModel,
17 | QItemSelectionModel,
18 | QModelIndex,
19 | Qt,
20 | pyqtSlot,
21 | QMimeData
22 | )
23 | from PyQt5.QtGui import QValidator
24 | import sys
25 | from fontFeatures import (
26 | Routine,
27 | Attachment,
28 | Substitution,
29 | Positioning,
30 | Chaining,
31 | ValueRecord,
32 | Rule
33 | )
34 | from Flux.project import FluxProject
35 | import re
36 | from Flux.ThirdParty.HTMLDelegate import HTMLDelegate
37 | from Flux.computedroutine import ComputedRoutine
38 | from Flux.dividerroutine import DividerRoutine
39 |
40 |
41 | class FeatureValidator(QValidator):
42 | def validate(self, s, pos):
43 | if re.search(r"[^a-z0-9]", s) or len(s) > 4:
44 | return (QValidator.Invalid, s, pos)
45 | if len(s) < 4:
46 | return (QValidator.Intermediate, s, pos)
47 | return (QValidator.Acceptable, s, pos)
48 |
49 |
50 | class LookupFlagEditor(QDialog):
51 |
52 | simpleChecks = [
53 | (0x02, "Ignore Base Glyphs"),
54 | (0x08, "Ignore Mark Glyphs"),
55 | (0x04, "Ignore Ligatures"),
56 | (0x01, "Cursive last glyph on baseline"),
57 | ]
58 |
59 | def __init__(self, routine):
60 | super(QDialog, self).__init__()
61 | self.checkboxes = []
62 | self.routine = routine
63 | self.flags = routine.flags
64 | layout = QVBoxLayout()
65 | for flagbit, description in self.simpleChecks:
66 | cb = QCheckBox(description)
67 | cb.flagbit = flagbit
68 | cb.stateChanged.connect(self.toggleBit)
69 | if routine.flags & flagbit:
70 | cb.setCheckState(Qt.Checked)
71 | layout.addWidget(cb)
72 |
73 | buttons = QDialogButtonBox(
74 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self
75 | )
76 | buttons.accepted.connect(self.accept)
77 | buttons.rejected.connect(self.reject)
78 | layout.addWidget(buttons)
79 | self.setLayout(layout)
80 |
81 | @pyqtSlot()
82 | def toggleBit(self):
83 | cb = self.sender()
84 | self.flags = self.flags ^ cb.flagbit
85 |
86 | def accept(self):
87 | self.routine.flags = self.flags
88 | super().accept()
89 |
90 |
91 | class AddToFeatureDialog(QDialog):
92 | def __init__(self):
93 | super(QDialog, self).__init__()
94 | self.featureEdit = QLineEdit()
95 | self.featureEdit.setValidator(FeatureValidator())
96 | layout = QVBoxLayout()
97 | layout.addWidget(QLabel("Add this routine to feature..."))
98 | layout.addWidget(self.featureEdit)
99 | buttons = QDialogButtonBox(
100 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self
101 | )
102 | buttons.accepted.connect(self.accept)
103 | buttons.rejected.connect(self.reject)
104 | layout.addWidget(buttons)
105 | self.setLayout(layout)
106 |
107 | def accept(self):
108 | if not self.featureEdit.hasAcceptableInput():
109 | return
110 | self.feature = self.featureEdit.text()
111 | return super().accept()
112 |
113 |
114 | class LookupList(QTreeView):
115 | def __init__(self, project, parent):
116 | super(QTreeView, self).__init__()
117 | self.project = project
118 | self.parent = parent
119 | self.setModel(LookupListModel(project, parent=self))
120 | self.setItemDelegate(HTMLDelegate())
121 | self.setContextMenuPolicy(Qt.CustomContextMenu)
122 | self.setDragEnabled(True)
123 | self.setAcceptDrops(True)
124 | self.setDefaultDropAction(Qt.TargetMoveAction)
125 | self.setDropIndicatorShown(True)
126 | self.setDragDropMode(QAbstractItemView.InternalMove)
127 | self.setDragDropOverwriteMode(False)
128 | self.customContextMenuRequested.connect(self.contextMenu)
129 | self.doubleClicked.connect(self.doubleClickHandler)
130 |
131 | def highlight(self, routineName):
132 | routineNames = [x.name for x in self.project.fontfeatures.routines]
133 | if not routineName in routineNames:
134 | return
135 | self.collapseAll()
136 | routineRow = routineNames.index(routineName)
137 | if routineRow:
138 | self.scrollTo(self.model().index(routineRow + 1, 0))
139 | self.setCurrentIndex(self.model().index(routineRow, 0))
140 | self.setExpanded(self.model().index(routineRow, 0), True)
141 | pass
142 |
143 | def update(self, index=QModelIndex()):
144 | if index.isValid():
145 | self.model().dataChanged.emit(index, index)
146 | else:
147 | self.model().beginResetModel()
148 | self.model().dataChanged.emit(index, index)
149 | self.model().endResetModel()
150 | super().update()
151 |
152 | def startDrag(self, dropActions):
153 | item = self.selectedIndexes()[0].internalPointer()
154 | if not isinstance(item, Routine):
155 | return
156 | super(QTreeView, self).startDrag(dropActions)
157 |
158 | def contextMenu(self, position):
159 | indexes = self.selectedIndexes()
160 | menu = QMenu()
161 | menu.addAction("Add routine", self.addRoutine)
162 | if indexes:
163 | thing = indexes[0].internalPointer()
164 | if isinstance(thing, Rule) and hasattr(thing, "computed"):
165 | pass
166 | elif isinstance(thing, Routine):
167 | menu.addAction("Delete routine", self.deleteItem)
168 | menu.addAction("Add to feature...", self.addToFeature)
169 | menu.addAction("Add separator", self.addSeparatorRoutine)
170 | if isinstance(thing, ComputedRoutine):
171 | menu.addAction("Reify", self.reify)
172 | else:
173 | menu.addAction("Add substitution rule", self.addSubRule)
174 | menu.addAction("Add positioning rule", self.addPosRule)
175 | menu.addAction("Add attachment rule", self.addAttRule)
176 | menu.addAction("Add chaining rule", self.addChainRule)
177 | menu.addAction("Set routine flags", self.setFlags)
178 | else:
179 | menu.addAction("Delete rule", self.deleteItem)
180 | menu.exec_(self.viewport().mapToGlobal(position))
181 |
182 | @pyqtSlot()
183 | def addSubRule(self):
184 | self.model().addRule(self.selectedIndexes()[0], Substitution([[]], [[]]))
185 | self.parent.editor.setWindowModified(True)
186 |
187 | @pyqtSlot()
188 | def addPosRule(self):
189 | self.model().addRule(
190 | self.selectedIndexes()[0], Positioning([[]], [ValueRecord()])
191 | )
192 | self.parent.editor.setWindowModified(True)
193 |
194 | @pyqtSlot()
195 | def addChainRule(self):
196 | self.model().addRule(self.selectedIndexes()[0], Chaining([[]], lookups=[[]]))
197 | self.parent.editor.setWindowModified(True)
198 |
199 | @pyqtSlot()
200 | def addAttRule(self):
201 | self.model().addRule(self.selectedIndexes()[0], Attachment("", ""))
202 | self.parent.editor.setWindowModified(True)
203 |
204 | @pyqtSlot()
205 | def addToFeature(self):
206 | index = self.selectedIndexes()[0]
207 | routine = index.internalPointer()
208 | dialog = AddToFeatureDialog()
209 | result = dialog.exec_()
210 | if result:
211 | self.project.fontfeatures.addFeature(dialog.feature, [routine])
212 | self.model().dataChanged.emit(index, index)
213 | self.parent.editor.setWindowModified(True)
214 | self.parent.editor.update()
215 |
216 | @pyqtSlot()
217 | def setFlags(self):
218 | index = self.selectedIndexes()[0]
219 | routine = index.internalPointer()
220 | dialog = LookupFlagEditor(routine)
221 | result = dialog.exec_()
222 | if result:
223 | self.model().dataChanged.emit(index, index)
224 | self.parent.editor.setWindowModified(True)
225 |
226 |
227 | @pyqtSlot()
228 | def reify(self):
229 | index = self.selectedIndexes()[0]
230 | msg = QMessageBox()
231 | msg.setIcon(QMessageBox.Question)
232 | msg.setWindowTitle("Are you sure?")
233 | msg.setText("A manual routine will no longer automatically respond to changes in your font. Do you still want to convert this to a manual routine?")
234 | msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
235 | retval = msg.exec_()
236 | if retval == QMessageBox.Yes:
237 | self._reify(index)
238 |
239 | def _reify(self, index):
240 | oldroutine = index.internalPointer()
241 | rindex = index.row()
242 | newroutine = oldroutine.reify()
243 | self.project.fontfeatures.routines[rindex] = newroutine
244 | for k,v in self.project.fontfeatures.features.items():
245 | self.project.fontfeatures.features[k] = [ newroutine if r==oldroutine else r for r in v]
246 |
247 | self.parent.editor.setWindowModified(True)
248 | self.parent.editor.update()
249 |
250 | def doubleClickHandler(self, index):
251 | if isinstance(index.internalPointer(), Routine):
252 | return
253 | if hasattr(index.internalPointer(), "computed"):
254 | index = index.parent()
255 | msg = QMessageBox()
256 | msg.setIcon(QMessageBox.Question)
257 | msg.setWindowTitle("Reify?")
258 | msg.setText("This is an automatic rule. To edit it manually, you need to convert its routine to a manual routine. Do you want to do this now?")
259 | msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
260 | retval = msg.exec_()
261 | if retval == QMessageBox.Yes:
262 | self._reify(index)
263 | return
264 | if isinstance(index.internalPointer(), Attachment):
265 | self.parent.editor.showAttachmentEditor(
266 | index.internalPointer(), index=index
267 | )
268 | return
269 | self.parent.editor.showRuleEditor(index.internalPointer(), index=index)
270 |
271 | @pyqtSlot()
272 | def addRoutine(self):
273 | if self.selectedIndexes():
274 | index = self.selectedIndexes()[0]
275 | self.model().insertRows(index.row()+1, 1)
276 | index = self.model().index(index.row()+1, 0)
277 | else:
278 | index = self.model().appendRow()
279 | self.selectionModel().select(index, QItemSelectionModel.ClearAndSelect)
280 | self.edit(index)
281 | self.parent.editor.setWindowModified(True)
282 |
283 | @pyqtSlot()
284 | def addSeparatorRoutine(self):
285 | index = self.selectedIndexes()[0]
286 | self.model().insertRows(index.row()+1, 1)
287 | self.project.fontfeatures.routines[index.row()+1] = DividerRoutine()
288 | self.parent.editor.setWindowModified(True)
289 |
290 | @pyqtSlot()
291 | def deleteItem(self):
292 | # XXX Check if routine is in use. Remove from Feature
293 | self.model().removeRows(self.selectedIndexes()[0].row(), 1, QModelIndex())
294 | self.parent.editor.setWindowModified(True)
295 |
296 |
297 | class LookupListModel(QAbstractItemModel):
298 | def __init__(self, proj, parent=None):
299 | super(LookupListModel, self).__init__(parent)
300 | self._parent = parent
301 | self.project = proj
302 |
303 | def headerData(self, section, orientation, role):
304 | if role != Qt.DisplayRole:
305 | return None
306 | return "Lookups"
307 |
308 | def rowCount(self, index=QModelIndex()):
309 | if index.row() == -1:
310 | return len(self.project.fontfeatures.routines)
311 | if index.isValid():
312 | item = index.internalPointer()
313 | if isinstance(item, Routine):
314 | item.editor = self._parent.parent.editor
315 | return len(item.rules)
316 | return 0
317 |
318 | def columnCount(self, index=QModelIndex()):
319 | return 1
320 |
321 | def supportedDropActions(self):
322 | return Qt.MoveAction
323 |
324 | def parent(self, index):
325 | if isinstance(index.internalPointer(), Routine):
326 | return QModelIndex()
327 | rule = index.internalPointer()
328 | # Now go find it
329 | for row, routine in enumerate(self.project.fontfeatures.routines):
330 | routine.editor = self._parent.parent.editor
331 | if rule in routine.rules:
332 | return self.createIndex(row, 0, routine)
333 | return QModelIndex()
334 |
335 | def index(self, row, column, index=QModelIndex()):
336 | """ Returns the index of the item in the model specified by the given row, column and parent index """
337 |
338 | if not self.hasIndex(row, column, index):
339 | return QModelIndex()
340 | # print(row, column, index.internalPointer())
341 | if not index.isValid():
342 | ix = self.createIndex(row, column, self.project.fontfeatures.routines[row])
343 | else:
344 | item = index.internalPointer()
345 | item.editor = self._parent.parent.editor
346 | ix = self.createIndex(row, column, item.rules[row])
347 | return ix
348 |
349 | def setData(self, index, value, role=Qt.EditRole):
350 | if role != Qt.EditRole:
351 | return False
352 |
353 | if index.isValid() and 0 <= index.row() < len(
354 | self.project.fontfeatures.routines
355 | ):
356 | if isinstance(self.project.fontfeatures.routines[index.row()], DividerRoutine):
357 | self.project.fontfeatures.routines[index.row()].comment = value
358 | else:
359 | self.project.fontfeatures.routines[index.row()].name = value
360 | # self.dataChanged.emit(index, index)
361 | return True
362 |
363 | return False
364 |
365 | def indexIsRoutine(self, index):
366 | item = index.internalPointer()
367 | return isinstance(item, Routine)
368 |
369 | def indexIsRule(self, index):
370 | item = index.internalPointer()
371 | return not isinstance(item, Routine)
372 |
373 | def describeFlags(self, routine):
374 | flags = []
375 | if not routine.flags:
376 | return ""
377 | if routine.flags & 0x2:
378 | flags.append("IgnoreBase")
379 | if routine.flags & 0x8:
380 | flags.append("IgnoreMark")
381 | if routine.flags & 0x4:
382 | flags.append("IgnoreLig")
383 | if routine.flags & 0x1:
384 | flags.append("RightToLeft")
385 | if len(flags):
386 | return " (" + ",".join(flags) + ")"
387 | return ""
388 |
389 | def data(self, index, role=Qt.DisplayRole):
390 | # print("Getting index ", index.row(), index.column(), index.internalPointer())
391 | if not index.isValid():
392 | return None
393 | item = index.internalPointer()
394 | if role == Qt.EditRole and self.indexIsRoutine(index) and isinstance(item,DividerRoutine):
395 | return item.comment
396 | if role == Qt.DisplayRole or role == Qt.EditRole:
397 | if self.indexIsRoutine(index):
398 | if isinstance(item, DividerRoutine):
399 | return f'{item.comment or "————"}'
400 | else:
401 | return (item.name or "")+ self.describeFlags(item)
402 | elif hasattr(item, "computed"):
403 | return f'{item.asFea()}'
404 | elif isinstance(item, Attachment):
405 | return (
406 | f'Attach {item.mark_name or "Nothing"} to {item.base_name or "Nothing"}'
407 | + self.describeFlags(item)
408 | )
409 | else:
410 | fea = item.asFea() or "" % item.__class__.__name__
411 | return fea.split("\n")[0]
412 | return None
413 |
414 | def flags(self, index):
415 | if not index.isValid():
416 | return Qt.ItemIsDropEnabled
417 | flag = Qt.ItemFlags(QAbstractItemModel.flags(self, index))
418 | if self.indexIsRoutine(index):
419 | return flag | Qt.ItemIsEditable | Qt.ItemIsDragEnabled
420 | return flag | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
421 |
422 | def insertRows(self, position, rows=1, parent=QModelIndex()):
423 | """ Insert a row into the model. """
424 | assert(rows == 1)
425 | self.beginInsertRows(QModelIndex(), position, position + rows - 1)
426 |
427 | self.project.fontfeatures.routines.insert(position,Routine(name="", rules=[]))
428 | self.endInsertRows()
429 | return True
430 |
431 | def appendRow(self):
432 | self.insertRows(len(self.project.fontfeatures.routines))
433 | return self.index(len(self.project.fontfeatures.routines) - 1, 0)
434 |
435 | def removeRows(self, row, count, parent):
436 | self.beginRemoveRows(parent, row, row+1)
437 | self.project.fontfeatures.routines.pop(row)
438 | self.endRemoveRows()
439 | return True
440 |
441 | def mimeData(self, indexes):
442 | mimeData = super().mimeData(indexes)
443 | mimeData.setText("row id: %i" % indexes[0].row())
444 | return mimeData
445 |
446 | def dropMimeData(self, data, action, destrow, column, parent):
447 | if parent.isValid():
448 | return False
449 | if not data.hasText() or not "row id:" in data.text():
450 | return False
451 | rowid = int(data.text()[7:])
452 | routines = self.project.fontfeatures.routines
453 | self.beginInsertRows(QModelIndex(), destrow, destrow)
454 | routines.insert(destrow, routines[rowid])
455 | self.endInsertRows()
456 | return True
457 |
458 | def removeRow(self, index):
459 | """ Remove a row from the model. """
460 | self.beginRemoveRows(self.parent(index), index.row(), index.row())
461 | if self.indexIsRoutine(index):
462 | del self.project.fontfeatures.routines[index.row()]
463 | else:
464 | lookup = self.parent(index).internalPointer()
465 | del lookup.rules[index.row()]
466 | self.endRemoveRows()
467 | return True
468 |
469 | def addRule(self, ix, rule):
470 | lookup = ix.internalPointer()
471 | self.beginInsertRows(ix, ix.row(), ix.row())
472 | lookup.rules.append(rule)
473 | self.endInsertRows()
474 | return True
475 |
476 |
477 | if __name__ == "__main__":
478 | from fluxproject import FluxProject
479 |
480 | app = 0
481 | if QApplication.instance():
482 | app = QApplication.instance()
483 | else:
484 | app = QApplication(sys.argv)
485 |
486 | w = QWidget()
487 | w.resize(510, 210)
488 | v_box_1 = QVBoxLayout()
489 |
490 | proj = FluxProject("qalam.fluxml")
491 |
492 | tree = LookupList(proj)
493 | v_box_1.addWidget(tree)
494 |
495 | w.setLayout(v_box_1)
496 |
497 | w.show()
498 | sys.exit(app.exec_())
499 |
--------------------------------------------------------------------------------
/Flux/UI/qruleeditor.py:
--------------------------------------------------------------------------------
1 | from PyQt5.QtWidgets import (
2 | QWidget,
3 | QApplication,
4 | QScrollArea,
5 | QHBoxLayout,
6 | QVBoxLayout,
7 | QSplitter,
8 | QLabel,
9 | QLineEdit,
10 | QSpinBox,
11 | QPushButton,
12 | QCheckBox,
13 | QComboBox,
14 | QDialog,
15 | QDialogButtonBox,
16 | QCompleter,
17 | QSizePolicy,
18 | QStyle
19 | )
20 | from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, QStringListModel, QSize
21 | from fontFeatures.shaperLib.Shaper import Shaper
22 | from .qbufferrenderer import QBufferRenderer
23 | from .qglyphname import QGlyphName
24 | from fontFeatures import (
25 | Positioning,
26 | ValueRecord,
27 | Substitution,
28 | Chaining,
29 | Rule,
30 | Routine,
31 | RoutineReference,
32 | )
33 | import sys
34 | import darkdetect
35 | from Flux.variations import VariationAwareBuffer, VariationAwareBufferItem
36 | from fontFeatures.shaperLib.Buffer import Buffer, BufferItem
37 |
38 |
39 | if darkdetect.isDark():
40 | precontext_style = "background-color: #322b2b"
41 | postcontext_style = "background-color: #2b2b32"
42 | else:
43 | precontext_style = "background-color: #ffaaaa"
44 | postcontext_style = "background-color: #aaaaff"
45 |
46 |
47 | class QValueRecordEditor(QWidget):
48 | changed = pyqtSignal()
49 | fieldnames = ["xPlacement", "yPlacement", "xAdvance", "yAdvance"]
50 | labelnames = ["Δx", "Δy", "+x", "+y"]
51 |
52 | def __init__(self, vr, vf=None, master=None):
53 | self.valuerecord = vr
54 | self.boxlayout = QHBoxLayout()
55 | self.boxes = []
56 | self.master = master
57 | self.vf = vf
58 | super(QWidget, self).__init__()
59 | self.add_boxes()
60 | self.prep_fields()
61 |
62 | def add_boxes(self):
63 | for ix, k in enumerate(self.fieldnames):
64 | t = QSpinBox()
65 | t.setSingleStep(10)
66 | t.setRange(-10000, 10000)
67 | t.valueChanged.connect(self.serialize)
68 | self.boxes.append(t)
69 | self.boxlayout.addWidget(t)
70 |
71 | def prep_fields(self):
72 | vr = self.valuerecord
73 | if self.master:
74 | vr = self.valuerecord.get_value_for_master(self.vf, self.master)
75 | for ix, k in enumerate(self.fieldnames):
76 | t = self.boxes[ix]
77 | t.setValue(getattr(vr, k) or 0)
78 | # label = QLabel(t)
79 | # label.setText(self.labelnames[ix])
80 | # label.move(label.x()+0,label.y()-50)
81 | self.setLayout(self.boxlayout)
82 |
83 | def change_master(self, master):
84 | self.serialize()
85 | self.master = master
86 | self.prep_fields()
87 |
88 | def serialize(self):
89 | value = {self.fieldnames[ix]: int(self.boxes[ix].value()) for ix in range(len(self.boxes))}
90 |
91 | if self.master:
92 | self.valuerecord.set_value_for_master(self.vf, self.master, ValueRecord(**value))
93 | else:
94 | for attr, val in value.items():
95 | setattr(self.valuerecord, attr, val)
96 | self.changed.emit()
97 |
98 |
99 | class QRuleEditor(QDialog):
100 | def __init__(self, project, editor, rule): # Rule is some fontFeatures object
101 | self.project = project
102 | self.editor = editor
103 | self.inputslots = []
104 | self.precontextslots = []
105 | self.postcontextslots = []
106 | self.outputslots = []
107 | self.buffer_direction = "RTL"
108 | self.buffer_script = "Latin"
109 | self.all_valuerecord_editors = []
110 | self.index = None
111 | if rule:
112 | self.backup_rule = Rule.fromXML(rule.toXML()) # Deep copy
113 | else:
114 | self.backup_rule = None
115 |
116 | super(QRuleEditor, self).__init__()
117 |
118 | splitter = QSplitter()
119 | self.slotview = QHBoxLayout()
120 | scroll = QScrollArea()
121 | scroll.setLayout(self.slotview)
122 |
123 | self.outputview_before = QBufferRenderer(project, VariationAwareBuffer(self.project.font))
124 | self.outputview_after = QBufferRenderer(project, VariationAwareBuffer(self.project.font))
125 | self.before_after = QWidget()
126 | self.before_after_layout_v = QVBoxLayout()
127 |
128 | self.asFea = QLabel()
129 |
130 | featureButtons = QWidget()
131 | self.featureButtonLayout = QHBoxLayout()
132 | featureButtons.setLayout(self.featureButtonLayout)
133 | self.selectedFeatures = []
134 |
135 | layoutarea = QWidget()
136 | self.before_after_layout_h = QHBoxLayout()
137 | self.before_after_layout_h.addWidget(self.outputview_before)
138 | self.before_after_layout_h.addWidget(self.outputview_after)
139 | layoutarea.setLayout(self.before_after_layout_h)
140 |
141 | if self.project.variations:
142 | self.master_selection = QComboBox()
143 | for mastername in self.project.variations.masters:
144 | self.master_selection.addItem(mastername)
145 | self.master_selection.currentTextChanged.connect(self.masterChanged)
146 | self.before_after_layout_v.addWidget(self.master_selection)
147 | else:
148 | self.master_selection = None
149 |
150 | self.before_after_layout_v.addWidget(featureButtons)
151 | self.before_after_layout_v.addWidget(self.asFea)
152 | self.before_after_layout_v.addWidget(layoutarea)
153 |
154 | self.before_after.setLayout(self.before_after_layout_v)
155 |
156 | splitter.setOrientation(Qt.Vertical)
157 | splitter.addWidget(scroll)
158 |
159 | splitter.addWidget(self.before_after)
160 | buttons = QDialogButtonBox(
161 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self
162 | )
163 | for button in buttons.buttons():
164 | button.setDefault(False)
165 | button.setAutoDefault(False)
166 | buttons.accepted.connect(self.accept)
167 | buttons.rejected.connect(self.reject)
168 | v_box_1 = QVBoxLayout()
169 | self.setLayout(v_box_1)
170 | v_box_1.addWidget(splitter)
171 | v_box_1.addWidget(buttons)
172 | self.setRule(rule)
173 |
174 | @property
175 | def currentMaster(self):
176 | if not self.master_selection:
177 | return None
178 | return self.master_selection.currentText()
179 |
180 | def masterChanged(self):
181 | for qvre in self.all_valuerecord_editors:
182 | qvre.change_master(self.currentMaster)
183 | self.resetBuffer()
184 |
185 | def keyPressEvent(self, evt):
186 | return
187 |
188 | def accept(self):
189 | self.editor.fontfeaturespanel.lookuplist.update(self.index)
190 | self.editor.setWindowModified(True)
191 | self.editor.showDebugger()
192 |
193 | def reject(self):
194 | for k in dir(self.backup_rule):
195 | self.rule = getattr(self.backup_rule, k)
196 | self.editor.fontfeaturespanel.lookuplist.update()
197 | self.editor.showDebugger()
198 |
199 | def setRule(self, rule, index=None):
200 | self.rule = rule
201 | self.index = index
202 | self.arrangeSlots()
203 | self.representative_string = self.makeRepresentativeString()
204 | self.resetBuffer()
205 |
206 | @property
207 | def location(self):
208 | sourceIndex = list(self.project.variations.masters.keys()).index(self.currentMaster)
209 | return self.project.variations.designspace.sources[sourceIndex].location
210 |
211 | def resetBuffer(self):
212 | if self.rule:
213 | try:
214 | self.asFea.setText(self.rule.asFea())
215 | except Exception as e:
216 | print("Can't serialize", e)
217 | self.outputview_before.set_buf(self.makeBuffer("before"))
218 | self.outputview_after.set_buf(self.makeBuffer("after"))
219 | if self.currentMaster:
220 | self.outputview_before.set_location(self.location)
221 | self.outputview_after.set_location(self.location)
222 |
223 | @pyqtSlot()
224 | def changeRepresentativeString(self):
225 | l = self.sender()
226 | if l.text().startswith("@"):
227 | self.representative_string[
228 | l.slotnumber
229 | ] = self.project.fontfeatures.namedClasses[l.text()[1:]][0]
230 | else:
231 | self.representative_string[l.slotnumber] = l.text()
232 |
233 | self.resetBuffer()
234 |
235 | @pyqtSlot()
236 | def replacementChanged(self):
237 | l = self.sender()
238 | replacements = l.text().split()
239 | self.rule.replacement = [[x] for x in replacements]
240 | self.resetBuffer()
241 |
242 | @pyqtSlot()
243 | def addGlyphToSlot(self):
244 | l = self.sender()
245 | glyphname = l.text()
246 | # Check for class names
247 | if (
248 | glyphname.startswith("@")
249 | and glyphname[1:] in self.project.fontfeatures.namedClasses.keys()
250 | ):
251 | # It's OK
252 | pass
253 | elif glyphname not in self.project.font.keys():
254 | print(f"{glyphname} not found")
255 | l.setText("")
256 | return
257 | print("Adding ", glyphname)
258 | l.owner.contents[l.owner.slotindex].append(glyphname)
259 | self.arrangeSlots()
260 | self.representative_string = self.makeRepresentativeString()
261 | self.resetBuffer()
262 |
263 | @pyqtSlot()
264 | def removeGlyphFromSlot(self):
265 | l = self.sender()
266 | del l.contents[l.slotindex][l.indexWithinSlot]
267 | self.arrangeSlots()
268 | self.representative_string = self.makeRepresentativeString()
269 | self.resetBuffer()
270 |
271 | @pyqtSlot()
272 | def addRemoveSlot(self):
273 | sender = self.sender()
274 | action = sender.text()
275 | if action == "<+":
276 | sender.contents.insert(0, [])
277 | # If these are input glyphs, add another replacement etc.
278 | if sender.contents == self.rule.shaper_inputs():
279 | if isinstance(self.rule, Positioning):
280 | self.rule.valuerecords.insert(0, ValueRecord())
281 | elif (
282 | isinstance(self.rule, Substitution)
283 | and len(self.rule.shaper_inputs()) == 1
284 | ):
285 | self.rule.replacement.insert(0, [])
286 | elif isinstance(self.rule, Chaining):
287 | self.rule.lookups.insert(0, [])
288 | elif action == "+>":
289 | sender.contents.append([])
290 | # If these are input glyphs, add another replacement etc.
291 | if sender.contents == self.rule.shaper_inputs():
292 | if isinstance(self.rule, Positioning):
293 | self.rule.valuerecords.append(ValueRecord())
294 | elif (
295 | isinstance(self.rule, Substitution)
296 | and len(self.rule.shaper_inputs()) == 1
297 | ):
298 | self.rule.replacement.append([])
299 | elif isinstance(self.rule, Chaining):
300 | self.rule.lookups.append([])
301 |
302 | elif action == "-":
303 | del sender.contents[self.sender().ix]
304 | self.arrangeSlots()
305 | self.representative_string = self.makeRepresentativeString()
306 | self.resetBuffer()
307 |
308 | def makeASlot(self, slotnumber, contents, style=None, editingWidgets=None):
309 | for ix, glyphslot in enumerate(contents):
310 | slot = QWidget()
311 | slotLayout = QVBoxLayout()
312 | if style:
313 | slot.setStyleSheet(style)
314 |
315 |
316 | scroll = QScrollArea()
317 | scroll.setWidgetResizable(True)
318 | scrollWidget = QWidget()
319 | scrollLayout = QVBoxLayout()
320 | scrollWidget.setLayout(scrollLayout)
321 | scroll.setWidget(scrollWidget)
322 | for ixWithinSlot, glyph in enumerate(glyphslot):
323 | glyphHolder = QWidget()
324 | glyphHolderLayout = QHBoxLayout()
325 | glyphHolder.setLayout(glyphHolderLayout)
326 | l = QPushButton(glyph)
327 | l.setDefault(False)
328 | l.setAutoDefault(False)
329 | l.slotnumber = slotnumber
330 | l.clicked.connect(self.changeRepresentativeString)
331 | glyphHolderLayout.addWidget(l)
332 |
333 | remove = QPushButton("x")
334 | remove.slotindex = ix
335 | remove.indexWithinSlot = ixWithinSlot
336 | remove.contents = contents
337 | remove.clicked.connect(self.removeGlyphFromSlot)
338 | glyphHolderLayout.addWidget(remove)
339 | scrollLayout.addWidget(glyphHolder)
340 |
341 | slotLayout.addWidget(scroll)
342 |
343 | # This is the part that adds a new glyph to a slot
344 | newglyph = QGlyphName(self.project, allow_classes=True)
345 | newglyph.slotindex = ix
346 | newglyph.contents = contents
347 | newglyph.glyphline.returnPressed.connect(self.addGlyphToSlot)
348 | slotLayout.addWidget(newglyph)
349 |
350 | slotLayout.addStretch()
351 | if editingWidgets and ix < len(editingWidgets):
352 | slotLayout.addWidget(editingWidgets[ix])
353 |
354 | pushbuttonsArea = QWidget()
355 | pushbuttonsLayout = QHBoxLayout()
356 | pushbuttonsArea.setLayout(pushbuttonsLayout)
357 | if ix == 0:
358 | addASlotLeft = QPushButton("<+")
359 | addASlotLeft.contents = contents
360 | addASlotLeft.clicked.connect(self.addRemoveSlot)
361 | pushbuttonsLayout.addWidget(addASlotLeft)
362 | pushbuttonsLayout.addStretch()
363 | if not (editingWidgets and len(contents) == 1):
364 | removeASlot = QPushButton("-")
365 | removeASlot.contents = contents
366 | removeASlot.ix = ix
367 | removeASlot.clicked.connect(self.addRemoveSlot)
368 | pushbuttonsLayout.addWidget(removeASlot)
369 | pushbuttonsLayout.addStretch()
370 | if ix == len(contents) - 1:
371 | addASlotRight = QPushButton("+>")
372 | addASlotRight.contents = contents
373 | addASlotRight.clicked.connect(self.addRemoveSlot)
374 | pushbuttonsLayout.addWidget(addASlotRight)
375 | slotLayout.addWidget(pushbuttonsArea)
376 |
377 | slotnumber = slotnumber + 1
378 |
379 | slot.setLayout(slotLayout)
380 | self.slotview.addWidget(slot)
381 | return slotnumber
382 |
383 | def lookupCombobox(self, current, warning):
384 | c = QComboBox()
385 | c.warning = warning
386 | names = [
387 | x.name
388 | for x in self.project.fontfeatures.routines
389 | if not hasattr(x, "comment")
390 | ]
391 | names = ["--- No lookup ---"] + names
392 | for name in names:
393 | c.addItem(name)
394 | if current in names:
395 | c.setCurrentIndex(names.index(current))
396 | self.setComboboxWarningIfNeeded(c)
397 | return c
398 |
399 | @pyqtSlot()
400 | def chainingLookupChanged(self):
401 | l = self.sender()
402 | if l.currentIndex() == 0:
403 | self.rule.lookups[l.ix] = []
404 | else:
405 | self.rule.lookups[l.ix] = [RoutineReference(name=l.currentText())]
406 | self.setComboboxWarningIfNeeded(l)
407 | self.resetBuffer()
408 |
409 | def changesGlyphstringLength(self, routine, depth=1):
410 | if depth > 10:
411 | return False
412 | for r in routine.rules:
413 | if isinstance(r, Substitution) and len(r.input) != len(r.replacement):
414 | return True
415 | elif isinstance(r, Chaining):
416 | for lus in r.lookups:
417 | for l in (lus or []):
418 | if self.changesGlyphstringLength(l.routine, depth+1):
419 | return True
420 | return False
421 |
422 | def setComboboxWarningIfNeeded(self, combobox):
423 | # Find routine
424 | rname = combobox.currentText()
425 | warningNeeded = False
426 | if rname:
427 | routine = None
428 | for r in self.project.fontfeatures.routines:
429 | if r.name == rname:
430 | routine = r
431 | if routine and self.changesGlyphstringLength(routine):
432 | stdicon = self.style().standardIcon(QStyle.SP_MessageBoxWarning)
433 | combobox.warning.setPixmap(stdicon.pixmap(stdicon.actualSize(QSize(16, 16))))
434 | combobox.warning.setToolTip("This lookup may change the length of the glyph stream. Subsequent lookups may not fire at the glyph slots you expect.")
435 | else:
436 | combobox.warning.clear()
437 | combobox.warning.setToolTip("")
438 |
439 | def addPrecontext(self):
440 | self.rule.precontext = [[]]
441 | self.arrangeSlots()
442 |
443 | def addPostcontext(self):
444 | self.rule.postcontext = [[]]
445 | self.arrangeSlots()
446 |
447 | def makeEditingWidgets(self):
448 | editingWidgets = []
449 | if isinstance(self.rule, Substitution):
450 | replacements = [x[0] for x in self.rule.replacement if x]
451 | widget = QGlyphName(
452 | self.project, multiple=len(self.rule.shaper_inputs()) < 2
453 | )
454 | widget.setText(" ".join(replacements) or "")
455 | widget.position = 0
456 | widget.returnPressed.connect(self.replacementChanged)
457 | editingWidgets.append(widget)
458 | else:
459 | for ix, i in enumerate(self.rule.shaper_inputs()):
460 | if isinstance(self.rule, Positioning):
461 | widget = QValueRecordEditor(self.rule.valuerecords[ix],
462 | vf=self.project.variations,
463 | master=self.currentMaster)
464 | widget.changed.connect(self.resetBuffer)
465 | editingWidgets.append(widget)
466 | self.all_valuerecord_editors.append(widget)
467 | elif isinstance(self.rule, Chaining):
468 | lookup = self.rule.lookups[ix] and self.rule.lookups[ix][0].name
469 | w = QWidget()
470 | wl = QHBoxLayout(w)
471 | w.setLayout(wl)
472 | warning = QLabel()
473 | widget = self.lookupCombobox(lookup, warning)
474 | widget.ix = ix
475 | widget.currentTextChanged.connect(self.chainingLookupChanged)
476 | wl.addWidget(widget)
477 | wl.addWidget(warning)
478 | editingWidgets.append(w)
479 | return editingWidgets
480 |
481 | def clearLayout(self, layout):
482 | if layout is not None:
483 | while layout.count():
484 | item = layout.takeAt(0)
485 | widget = item.widget()
486 | if widget is not None:
487 | widget.deleteLater()
488 | else:
489 | self.clearLayout(item.layout())
490 |
491 | def arrangeSlots(self):
492 | self.all_valuerecord_editors = []
493 | self.clearLayout(self.slotview)
494 | if not self.rule:
495 | return
496 |
497 | slotnumber = 0
498 |
499 | if not hasattr(self.rule, "precontext") or not self.rule.precontext:
500 | widget = QPushButton("<<+")
501 | widget.clicked.connect(self.addPrecontext)
502 | self.slotview.addWidget(widget)
503 | else:
504 | self.slotview.addStretch()
505 | slotnumber = self.makeASlot(
506 | slotnumber, self.rule.precontext, precontext_style
507 | )
508 |
509 | editingWidgets = self.makeEditingWidgets()
510 | slotnumber = self.makeASlot(
511 | slotnumber, self.rule.shaper_inputs(), editingWidgets=editingWidgets
512 | )
513 |
514 | if not hasattr(self.rule, "postcontext") or not self.rule.postcontext:
515 | widget = QPushButton("+>>")
516 | widget.clicked.connect(self.addPostcontext)
517 | self.slotview.addWidget(widget)
518 | else:
519 | self.makeASlot(slotnumber, self.rule.postcontext, postcontext_style)
520 |
521 | self.slotview.addStretch()
522 |
523 | def makeRepresentativeString(self):
524 | inputglyphs = []
525 | if not self.rule:
526 | return inputglyphs
527 | # "x and x[0]" thing because slots may be empty if newly added
528 | if hasattr(self.rule, "precontext"):
529 | inputglyphs.extend([x and x[0] for x in self.rule.precontext])
530 |
531 | inputglyphs.extend([x and x[0] for x in self.rule.shaper_inputs()])
532 |
533 | if hasattr(self.rule, "postcontext"):
534 | inputglyphs.extend([x and x[0] for x in self.rule.postcontext])
535 |
536 | representative_string = [x for x in inputglyphs if x]
537 | for ix, g in enumerate(representative_string):
538 | if (
539 | g.startswith("@")
540 | and g[1:] in self.project.fontfeatures.namedClasses.keys()
541 | ):
542 | representative_string[ix] = self.project.fontfeatures.namedClasses[
543 | g[1:]
544 | ][0]
545 |
546 | # We use this representative string to guess information about
547 | # how the *real* shaping process will take place; buffer direction
548 | # and script, and hence choice of complex shaper, and hence from
549 | # that choice of features to be processed.
550 | unicodes = [
551 | self.project.font.codepointForGlyph(x) for x in representative_string
552 | ]
553 | unicodes = [x for x in unicodes if x]
554 | tounicodes = "".join(map(chr, unicodes))
555 | bufferForGuessing = Buffer(self.project.font, unicodes=tounicodes)
556 | self.buffer_direction = bufferForGuessing.direction
557 | self.buffer_script = bufferForGuessing.script
558 | # print("Guessed buffer direction ", self.buffer_direction)
559 | # print("Guessed buffer script ", self.buffer_script)
560 | shaper = Shaper(self.project.fontfeatures, self.project.font)
561 | bufferForGuessing = Buffer(self.project.font, glyphs=representative_string)
562 | shaper.execute(bufferForGuessing)
563 | self.availableFeatures = []
564 | for stage in shaper.stages:
565 | if not isinstance(stage, list):
566 | continue
567 | for f in stage:
568 | if (
569 | f not in self.availableFeatures
570 | and f in self.project.fontfeatures.features
571 | ):
572 | self.availableFeatures.append(f)
573 | self.makeFeatureButtons()
574 |
575 | return representative_string
576 |
577 | def makeFeatureButtons(self):
578 | self.clearLayout(self.featureButtonLayout)
579 | for f in self.availableFeatures:
580 | self.selectedFeatures.append(f)
581 | featureButton = QCheckBox(f)
582 | featureButton.setChecked(True)
583 | featureButton.stateChanged.connect(self.resetBuffer)
584 | self.featureButtonLayout.addWidget(featureButton)
585 |
586 | def makeShaperFeatureArray(self):
587 | features = []
588 | for i in range(self.featureButtonLayout.count()):
589 | item = self.featureButtonLayout.itemAt(i).widget()
590 | features.append({"tag": item.text(), "value": item.isChecked()})
591 | return features
592 |
593 | def makeBuffer(self, before_after="before"):
594 | buf = VariationAwareBuffer(
595 | self.project.font,
596 | direction=self.buffer_direction,
597 | )
598 | if self.project.variations:
599 | buf.location = self.location
600 | buf.vf = self.project.variations
601 | buf.items = [VariationAwareBufferItem.new_glyph(g, self.project.font, buf) for g in self.representative_string]
602 | shaper = Shaper(self.project.fontfeatures, self.project.font)
603 |
604 | shaper.execute(buf, features=self.makeShaperFeatureArray())
605 | routine = Routine(rules=[self.rule])
606 | # print("Before shaping: ", buf.serialize())
607 | if before_after == "after" and self.rule:
608 | print("Before application: ", buf.serialize())
609 | print(self.rule.asFea())
610 | buf.clear_mask() # XXX
611 | try:
612 | routine.apply_to_buffer(buf)
613 | except Exception as e:
614 | print("Couldn't shape: " + str(e))
615 | print("After application: ", buf.serialize())
616 | return buf
617 |
618 |
619 | if __name__ == "__main__":
620 | from fluxproject import FluxProject
621 |
622 | app = 0
623 | if QApplication.instance():
624 | app = QApplication.instance()
625 | else:
626 | app = QApplication(sys.argv)
627 |
628 | w = QWidget()
629 | w.resize(510, 210)
630 | v_box_1 = QVBoxLayout()
631 |
632 | proj = FluxProject("qalam.fluxml")
633 | proj.fontfeatures.features["mark"] = [proj.fontfeatures.routines[2]]
634 | proj.fontfeatures.features["curs"] = [proj.fontfeatures.routines[1]]
635 |
636 | # v = ValueRecord(yPlacement=0)
637 | # rule = Positioning(
638 | # [["dda", "tda"]],
639 | # [v],
640 | # precontext=[["BEm2", "BEi3"]],
641 | # postcontext=[["GAFm1", "GAFf1"]],
642 | # )
643 | rule = Substitution(input_=[["space"]], replacement=[["space"]])
644 |
645 | v_box_1.addWidget(QRuleEditor(proj, None))
646 |
647 | w.setLayout(v_box_1)
648 |
649 | w.show()
650 | sys.exit(app.exec_())
651 |
--------------------------------------------------------------------------------