fonttools / fontbakery

🧁 A font quality assurance tool for everyone
https://fontbakery.readthedocs.io
Apache License 2.0
546 stars 99 forks source link

Font Bakery integration with font-editors #4128

Open Typedesigners opened 1 year ago

Typedesigners commented 1 year ago

Note: Original title of this issue was: "Font Bakery plugin for FontForge"

Hello together,

as a type designer, I like to work in FontForge. In FontForge, external Python scripts can be executed. Therefore, it would be ideal if one could programme a Font Bakery plugin for FontForge ( https://github.com/fontforge/fontforge ) So that one can start Font Bakery directly from FontForge. Unfortunately, I cannot program scripts and plug-ins myself. Maybe someone can programme that. It would be a good technical improvement for many type designers.

Thanks

felipesanches commented 1 year ago

Thanks, @Typedesigners!

I cannot work on this right now, as I have other tasks I'm working on at the moment. But I agree that font-editor integration would be something useful. There are many font editors in use nowadays, so I'd suggest anything done towards integration with editors be done in a way that allows it to work well in whichever editor the users like. So I would avoid work focused on a single font-editor.

Maybe we should invite to discuss this here, some of the people involved on editors such as: FontLab (@twardoch), RuneBender (@eliheuer), GlyphsApp (@schriftgestalt, @mekkablue), FontForge (@ctrlcctrlv), etc... to see what would be the ideal common interface between FontBakery and these editors.

Typedesigners commented 1 year ago

Thanks,

@felipesanches It is a very good idea to create the ideal common interface between Font Bakery and editors like FontForge, FontLab and GlyphsApp.

ctrlcctrlv commented 1 year ago

Sorry, I've no interest at this time. If I were to integrate/commission the integration of a suite that tests fonts for correctness it would not be one that tests fonts for conformance to FreeType and HarfBuzz and is based on real world results, not static analysis of the fonts. Could be called something like MFEKbugspray. I'm afraid fontbakery would get a binary name more like MFEKruthless-criticism-of-all-that-existsÂč.

Âč Now philosophy has become mundane, and the most striking proof of this is that philosophical consciousness itself has been drawn into the torment of the struggle, not only externally but also internally. But, if constructing the future and settling everything for all times are not our affair, it is all the more clear what we have to accomplish at present: I am referring to ruthless criticism of all that exists, ruthless both in the sense of not being afraid of the results it arrives at and in the sense of being just as little afraid of conflict with the powers that be.

Marx, Karl (1843). Letter to Arnold Ruge. Deutsch-Französische JahrbĂŒcher: Kreuznach.

davelab6 commented 1 year ago

What's a compliment 😊

schriftgestalt commented 1 year ago

We are already have plans in this direction.

Typedesigners commented 1 year ago

As a type designer, I need software that is easy to install, like FontForge, FontLab and Glyphs. This is not the case with Font Bakery. There is currently no graphical user interface. For this reason, I cannot use Font Bakery at the moment. FontForge has very good validation capabilities. FontLab also has an excellent FontAudit. From my point of view, it would be best if the Font Bakery could be easily installed and operated like any other software via a graphical user interface. As an alternative, a website would be conceivable through which one could use the Font Bakery without having to install it on one's own computer.

Typedesigners commented 1 year ago

@felipesanches It would be helpful if you could create a YouTube video tutorial that explains step by step how to install and use Font Bakery.

felipesanches commented 1 year ago

@felipesanches It would be helpful if you could create a YouTube video tutorial that explains step by step how to install and use Font Bakery.

Yes, maybe. But this is not the matter being discussed in this issue. Let's keep the issue focused on discussing what is needed for integrating fontbakery into font editors.

Typedesigners commented 1 year ago

Yes, maybe. But this is not the matter being discussed in this issue. Let's keep the issue focused on discussing what is needed for integrating fontbakery into font editors.

To answer this question, one needs computer scientists who can programme appropriate scripts. From my point of view, the goal will be to create a graphical interface for the Font Bakery that is displayed via the font editors. A good example for FontForge is the Curvatura plugin: https://github.com/linusromer/curvatura. If the Font Bakery could be integrated into FontForge in a comparable way, that would be great.

ctrlcctrlv commented 1 year ago

Not sure you quite need a computer scientist, just a scientist with a computer.

FontLaundry.py

https://user-images.githubusercontent.com/838783/235583464-91cc56d5-437b-4eb5-b4d0-c056f71f2f6a.mp4

#!/usr/bin/env python3
# FontLaundry - fontbakery GUI
##  Copyright (C) 2023 Fredrick R. Brennan <copypaste@kittens.ph>
##
##  This program is free software: you can redistribute it and/or modify
##  it under the terms of the GNU General Public License as published by
##  the Free Software Foundation, either version 3 of the License, or
##  (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##  GNU General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this program.  If not, see <https://www.gnu.org/licenses/>.

import sys

sys.modules["profile"] = None
import subprocess
import ansi2html
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QApplication,
    QDialog,
    QComboBox,
    QPushButton,
    QFileDialog,
    QTextBrowser,
    QVBoxLayout,
    QWidget,
)

class ProfileDialog(QDialog):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Select your preferred laundry")
        layout = QVBoxLayout()

        self.profile_combo = QComboBox()
        self.profile_combo.addItems(
            [
                "build-contributors",
                "check-adobefonts",
                "check-fontbureau",
                "check-fontval",
                "check-fontwerk",
                "check-googlefonts",
                "check-iso15008",
                "check-notofonts",
                "check-opentype",
                "check-profile",
                "check-proposals",
                "check-ufo-sources",
                "check-universal",
                "generate-glyphdata",
            ]
        )
        self.profile_combo.setCurrentText("check-opentype")
        layout.addWidget(self.profile_combo)

        self.ok_button = QPushButton("OK")
        self.ok_button.clicked.connect(self.close)
        layout.addWidget(self.ok_button)

        self.setLayout(layout)

    def selected_profile(self):
        return self.profile_combo.currentText()

app = QApplication(sys.argv)

# Check if font file was provided on command line
if len(sys.argv) > 1:
    selected_file = sys.argv[1]
else:
    # Open file dialog
    file_dialog = QFileDialog()
    file_dialog.setNameFilter("Font Files (*.ttf *.otf)")
    file_dialog.exec_()
    selected_file = file_dialog.selectedFiles()[0]

# Create and display window with fontbakery output
window = QWidget()
layout = QVBoxLayout()

text_browser = QTextBrowser()
text_browser.setOpenExternalLinks(True)
text_browser.setReadOnly(True)
text_browser.setWindowTitle("Font Laundry")

layout.addWidget(text_browser)
window.setLayout(layout)
window.show()

profile_dialog = ProfileDialog()

# Define function to run fontbakery and update text browser
def run_fontbakery():
    # Get fontbakery profile
    selected_profile = profile_dialog.selected_profile()

    # Call fontbakery and convert output to HTML
    try:
        cmd_output = subprocess.check_output(
            [
                sys.executable,
                "Lib/fontbakery/__main__.py",
                selected_profile,
                selected_file,
            ],
            stderr=subprocess.STDOUT,
        )
    except subprocess.CalledProcessError as e:
        cmd_output = e.output

    html_output = ansi2html.Ansi2HTMLConverter().convert(cmd_output.decode("utf-8"))

    # Update text browser with new output
    text_browser.setHtml(html_output)

# Run fontbakery with default options
run_fontbakery()

# Define function to update fontbakery output on profile change
def profile_changed():
    run_fontbakery()

# Create profile dialog and connect to profile_changed() function
profile_dialog = ProfileDialog()
profile_dialog.profile_combo.currentTextChanged.connect(profile_changed)
profile_dialog.show()

sys.exit(app.exec_())

Put in root of repository. Assumes PySide6 is installed. Could be made nicer via cxFreeze, just proof of concept.

schriftgestalt commented 1 year ago

I think it is much easier than you might think.

It might be a bit intimidating at first to use the command line. But I recommend to give it a try.

To run fontbakery, you need two commands:

Assuming you have installed python. (get it from python.org if you haven’t). The first one you only need once (copy paste the next line into the terminal and hit enter to run it):

pip install fontbakery

Then, to actually test a font copy paste the next line but don’t hit enter, yet.

fontbakery check-adobefonts -l FAIL 

then drag a font file into the terminal window and press enter. It will give you a pretty nicely formatted report about the font.

Instead of check-adobefonts, those profiles are available:

build-contributors
check-adobefonts
check-fontbureau
check-fontval
check-fontwerk
check-googlefonts
check-iso15008
check-notofonts
check-opentype
check-profile
check-proposals
check-ufo-sources
check-universal
generate-glyphdata
Typedesigners commented 1 year ago

Screenshot 2023-05-02 101410 Unfortunately, the installation of Font Bakery does not work.

schriftgestalt commented 1 year ago

But to get back to the actual topic. The script @ctrlcctrlv shows a way to run the test from a script. But in the most verbose way. I would like to have a functionality similar to this:

profile = fontbackery.profiles["check-adobefonts"]

for check in profile.checks:
    result = check.run()
    if result == "FAIL":
        print("check failed:", check.id, check.rationale)

I did look at the source code but there are too many layers intertwined. e.g. I found check_profile_main(). But it takes only the profile as an argument. The font path or other options are taken from the sys.args deeper down the pipeline (at least that is what I figured out).

schriftgestalt commented 1 year ago

The pip command has to be run from the command line directly, not from within python. so type exit() and then run the pip command again.

Typedesigners commented 1 year ago

The pip command has to be run from the command line directly, not from within python. so type exit() and then run the pip command again.

Screenshot 2023-05-02 103613 Unfortunately, the installation of Font Bakery does not work.

felipesanches commented 1 year ago

@Typedesigners, stop derailing the topic, please! If you need help installing fontbakery, you should open a separate issue for that!

There's a basic etiquette that you seem to not be aware of: The issue tracker is a tool to enable the work of the developers. It is not a general-purpose forum for chatting freely.

Typedesigners commented 1 year ago

@felipesanches Thank you for pointing this out. I have opened a new issue.

felipesanches commented 1 year ago

The script @ctrlcctrlv shows a way to run the test from a script. But in the most verbose way. I would like to have a functionality similar to this:

profile = fontbackery.profiles["check-adobefonts"]

for check in profile.checks:
    result = check.run()
    if result == "FAIL":
        print("check failed:", check.id, check.rationale)

I also felt the need for something similar to this, such as in the issue #3179

felipesanches commented 1 year ago

@schriftgestalt, also take a look at https://github.com/googlefonts/fontbakery/discussions/3621

Typedesigners commented 1 year ago

It would be desirable if, when integrating the Font Bakery into the font editors, the notes on the Font Bakery were translated into other languages. Especially in German, French, Spanish. I use FontForge in German, for example. In this respect, it would be practical to be able to use the Font Bakery in German as well. Nowadays it is actually a matter of course that software is available in several international languages. Whether Adobe or Affinity, they all offer multilingual software.

Typedesigners commented 1 year ago

It would be nice if the Font Bakery not only provided useful hints, but also offered the possibility to correct these hints directly in a font editor. The validation function in FontForge already offers this possibility. This is very practical. In FontLab, too, possible suggestions for improvement found with FontAudit can be implemented immediately with a click of the mouse.

davelab6 commented 1 year ago

The script @ctrlcctrlv shows a way to run the test from a script. But in the most verbose way. I would like to have a functionality similar to this:

profile = fontbackery.profiles["check-adobefonts"]

for check in profile.checks:
    result = check.run()
    if result == "FAIL":
        print("check failed:", check.id, check.rationale)

I also felt the need for something similar to this, such as in the issue #3179

@graphicore do you think it's possible to provide what @schriftgestalt sketches here?

graphicore commented 1 year ago

It could be possible or similar, however, not quite like that. Mainly because there's no input at all. At least the font(s) that is/are expected to be checked need to be passed somewhere. From there on, a generator could be created to emit that kind of check api. The other issue is, that the result that is computed by the check is not just a status like "FAIL", it can be multiple statuses which can describe multiple issues, there are also separate issues detected by one check, so reporting check.id and check.rationale may not be enough information to identify the actually detected issue. All statuses a check yields are summarized into the overall check result simply by taking the "worst" status as pars pro toto.

Depending on the input and the check arguments, a check may be executed more than once, e.g. once per input-font (like: for font of fonts: check(font)) or once for all input fonts (check(fonts)), that information is also relevant for the reporting of the result, as you want to know to which input the result belongs to.

The snipped above looks like, for some reason, it's interesting to drive the loop that executes each check in the caller, otherwise, maybe a reporter could be created, that reports finished check results to a callback. I also don't know why it is interesting to have a callable check.run() returned and call that explicitly, as we could have a result returned directly. With some fantasy, the returned check could however have some interesting qualities, like check.args could return the computed arguments that will go into the actual call to the actual check function (@felipesanches this could help with testing, especially if you could alter the arguments before calling check.run()).

profile = fontbakery.profiles["check-adobefonts"]
args = ['path/to/my/font']
# possibly also some options?
result_gen = we_need_to_write_this(profile, args)

for check in result_gen:
    result = check.run()
    if result == "FAIL":
        # insufficient
        print("check failed:", check.id, check.rationale)
schriftgestalt commented 1 year ago

To clarify my intended setup. There is a window that shows a list of all checks. There is a button to run them all. Then you can filter/sort for result type. So you end up with a bunch of failed tests. The idea is to be able to change stuff in the font and run individual checks again.

Mainly because there's no input at all. At least the font(s) that is/are expected to be checked need to be passed somewhere.

Of course. I omitted that for now.

Is there a way to know if a check is supposed to check a list of fonts or if it runs on individual fonts?

felipesanches commented 1 year ago

I think that an API should give us ways to setup an execution environment and then let us run the check (or checks) and let us query the results.

Nowadays all that is done via command-line arguments. A CLI interface will still be needed, but the check-runner should be decoupled from the command-line, so that the same features can be used by other programs, via an API, and the CLI would merely be a user of such API. Font-editors would be other users of that same API.

The execution environment

Running the check(s)

Querying the results

felipesanches commented 1 year ago

Another name for such execution environment would be context, which I believe is common nomenclature used in other APIs. Cairo, for instance, does have a context, frequently referred to as ctx on statements such as:

ctx = cairo.Context(surface)
graphicore commented 1 year ago

Is there a way to know if a check is supposed to check a list of fonts or if it runs on individual fonts?

Yes there is, for that API we could make it accessible. The keyword here is "iterargs" (iterated arguments) and we can tell which ones go into a check.

@felipesanches thanks for the analysis. I like the term context.

davelab6 commented 1 year ago

Good stuff. @felipesanches I understand @schriftgestalt is waiting for this and will be able to start as soon as it ready, so please prioritize this :)

anthrotype commented 1 year ago

I haven't followed all the discussion and don't know if this has been brought up already, but I'd like to point out a potential problem that arise when trying to import and run a complex application with dozens of dependencies such as fontbakery from within the Python interpreter embedded in a font editor. The sys.modules list is global, when a module is imported, it stays there for the entire execution of that interpreter, subsequent imports will reuse the same named module already loaded. Given that fontbakery (but the same arguments apply to other complex, multi-deps apps like fontmake) has several depedencies that may also be used/imported by other Glyphs.app plugins or macros, then you either have to impose that all the other plugins/macros also use the same dependency versions that fontbakery requires (which may be too restricting, but at least ensures fontbakery behaves the same way when run inside or outside the editor), or you don't care about managing the deps at all and then, worst case, fontbakery or some other plugin/macro also depending on some of those deps stops working or produces unexpected results. That's why I'd strongly recommend to not do this in the naive way of appending fontbakery with all its deps to the Glyphs.app Python's sys.path and import and run it as is... Instead, it'd be preferable to run fontbakery (or any similarly complex app) in its own independent python sub-process. The Glyphs.app Python 3 plugin includes a python3 executable that one can call with the usual subprocess.run(). Currently it is found in "~/Library/Application Support/Glyphs 3/Repositories/GlyphsPythonPlugin/Python.framework/Versions/Current/bin/python3". The sys.executable can't be used when running python scripts in a subprocess inside Glyphs.app (as one would normally do from CLI app), as it points to Glyphs.app itself (the Python framework is dynamically linked to the native app) and doing that would instead spawn an entire new Glyphs.app GUI bouncing on the dock.. Also the output from subprocess normally goes to the Glyphs.app's own stdout/stderr which are not visible (unless you launch Glyphs.app from the Terminal which nobody does), so you could either capture=True and print the subprocess' output after it has completed to the Macro panel output; or you can resort to a trick to have it printed line-by-line as the subprocess is running as I did here: https://github.com/anthrotype/glyphs-scripts/blob/99ff62b7bc1f73a101fdb04c0cc3678fd2e96bcf/export-font-with-fontmake.py#L52-L92

schriftgestalt commented 1 year ago

That is a good point.

anthrotype commented 1 year ago

the problem is every time one runs a script or macro, the global state of the embedded python interpreter (e.g. the list of modules imported, all the globals(), etc.) is modified and any subsequent runs of the same or different script/macro will start when the previous ended. Ideally each run of a script or macro would be independent of any previous one, as if the python interpreter was started afresh. I don't know how difficult would be to implement in Gyphs.app but I think FontLab has a way to "reset the macro system", not sure if that does exactly what I mean: https://help.fontlab.com/fontlab-vi/Scripting-panel/

anthrotype commented 1 year ago

in my experiments with a fontmake export script which I linked in my previous message, I was able at some point to use the multiprocessing module to "fork" the current Glyphs.app process and run my script in a separate instance of the app (running invisibly in the background), collecting its output in the main app's Macro panel as the script was running. That worked in the sense that any stuff that I imported within the other "sub-interpreter" did not "leak" in the main one, thus keeping the rest of the scripts/macros whose dependencies may overlap with my script safe from what my script imported or globally modified... But the problem with this approach is that, if one imports some module before, inside a different script or macro which does not use this self-contained sub-interpreter approach, which will later on also be imported by this forked sub-interpreter that is running e.g. fontmake (or fontbakery), then the import machinery in the sub-interpreter (this is really is a snapshot duplicate of the main Python interpreter at the time Glyphs.app process is forked) may assume that that some module had already been imported and reuse them -- with all the implications about using a different version of a dependency from the one your app has been tested with, etc. There's https://docs.python.org/3/library/importlib.html#importlib.reload to force reloading a module within the same python interpreter but it has lots of caveats and won't work reliably in all circumstances, so it's best avoided.

schriftgestalt commented 1 year ago

I was thinking a bit after your first comment.

I think for most scripts/plugins it is fine to have the same context. But I see your point for running this bigger machinery with a lot of dependences, that doesn't interact so much with the app/UI.

To avoid any contamination, it needs its own process. For thinks like running fontmake, running a script with a new instance of "python3" should work fine and the return values over the stdOut/Err should be working well enough.

For fontBackery, I have a bit stronger integration with the UI in mind. It needs a bit more sophisticated system. This sounds like a good reason to look into XPC. It means you run something like another app next to the main app and you can communicate with it – sending and receiving data.

Typedesigners commented 9 months ago

I have a FontLab 8 lifetime licence. FontLab 8 is a very good software with many creative functions for type design. Perhaps the Font Bakery functions could be integrated into FontAudit in FontLab 8 as an additional function in the future.