Advanced Python Visualisations - Custom Fonts

@carstenhaubold asked whether it was possible to use custom fonts in a Python View node by packaging the font within the workflow rather than requiring end-users to install the font on the client machine. The following describes two approaches for doing this, though each has its drawbacks.

Use Case

The Python View node provides developers with a high degree of flexibility to develop custom visualisations. Python provides a wide range of visualisation packages with many options, both for presentation of data and styling with custom themes. For example, Plotly provides a wide range of pre-packaged visualisations to which a customised theme can be applied to match an organisations branding.

Customisation of the visualisation might include use of colour, shapes, grid-lines and may also include the use of custom fonts. The use of custom fonts is widely used in design to give web sites a unique look and feel, and techniques have emerged over time so that end users do not need to install fonts on the client machine - if they do not exist on the clients machine then website code executed in the client’s browser fetches the fonts and displays them in the web page.

Whilst using fonts already installed on a clients machine in a Python View node generated visualisation is trivial, distributing font files with a component or fetching them from the web is more complicated.

Technical Architecture

The obvious solution would be to distribute the font file in a the data folder in the workflow. Code could be included in the JavaScript of the visual to load the font file from the data folder and then use that in the webpage. However, this fails for two reasons: The first is that all browsers are prohibited from accessing the local file system because of the security risk - imaging visiting a website that has permission to access every file on your computer would not be welcome; the second is that if the visual is running on a KNIME server the file would remain on the server, even though the code is running on a client computer, there would be no local copy of the font file.

Solution one is to put the font file somewhere that the browser can access using an http request. Ideally, this would be a URI address on the local web server that KNIME creates when serving the visuals. However, as the functionality does not exist in KNIME (yet) to add a URI path to the local server for distributing static assets another option is required. The option demonstrated below is to pull fonts from Google Fonts website, though any website would do provided it is accessible by the client.

Solution two is to encode the font file and embed it within the html page served from the internal server. This approach is more robust and allows clients that can’t access the internet to use custom fonts; however, it significantly increases the size of the file served to the clients browser. If there are multiple visuals in a dashboard then each visual is served its own font file which increases memory requirements and slows the system, potentially to the point that it is noticeable and disruptive for end users.

As stated above, the best option would be for KNIME to consider allowing developers to create a folder with a URI path into which static assets (JavaScript, Font Files, Images) could be placed. This would allow assets to be cached in the clients browser (as the URI path is fixed) and improve performance of the system.

Option 1 - Pull Fonts from Google Fonts API

The workflow for these examples is on KNIME Hub.

The relevant Python code to load fonts is shown below - this is an extract, the full code is in the workflow.

The approach is as follows:

  • Define the visualisation as a Plotly figure (fig) including defining any custom fonts that are required.
  • Instead of passing the fig to the knio.view convert the fig to html using the Plotly function pio.to_html. The important options are to include JavaScript and produce full html.
  • Use Python BeautifulSoup to manipulate the html. The first manipulation is to add css to the html head section which will import the font files from Google Fonts, the second manipulation adds the Doctype at the start of the html file which is required by KNIME view (it throws an error if it is missing).
  • The style string shown in the code below use an @import statement to access the Google Fonts api. The code for this statement and the subsequent font-family description is shown on the Google Fonts website.
  • Everything is consolidated into the html using Beautiful soup and then passed to knio.view as a string str(soup).
from bs4 import BeautifulSoup, Doctype
from pathlib import Path

# Plot the bar chart.
fig = px.bar(
    input_data,
    y=value_column,
    x=x_column,
    color=category_column,
    labels = {
        x_column: x_axis_title,
        value_column: value_axis_title,
        category_column: category_title
    }
)

# Use plotly to generate html (including javascript library)
html = pio.to_html(
                fig,
                include_plotlyjs=True,
                full_html=True, 
                default_width='100%',
                default_height='100%',
                validate=True,
                div_id="Chart")

# Convert to soup for processing.
soup = BeautifulSoup(html)

# Create a style tag and add inilne css text to import font from Google Fonts.
style = soup.new_tag("style")
style.string = f"""
@import url('https://fonts.googleapis.com/css2?family=Kalam:wght@300&display=swap');
@font-family: 'Kalam', cursive;
"""

# Append style to the html head section
soup.head.append(style)

# Insert Doctype html, required for KNIME View script
soup.insert(0, Doctype("html"))

# Assign the figure to the output_view variable
knio.output_view = knio.view_html(str(soup))

Option 2 - Encode The Font and Embed in the CSS

The second approach allows the fonts to be distributed with the component, but requires more work to convert the font file to a base64 encoded WOFF2 file.

As a rule, most font files are distributed as TrueType or OpenType format. This allows the fonts to be installed on a computer, however, the file format is bulky and not suitable for web distribution. Over time the file format WOFF2 emerged which compresses the font files reducing bandwidth requirement when transmitted over the internet. WOFF2 is a binary format so needs Base64 encoding if it is to be included as an inline string within the CSS portion of the html file we pass from the Python Script to the KNIME web server.

There are several tools available for doing this, though I used Webfont-generator. This takes as an input a TrueType font file and converts it to several different output formats, including the generation of a css file which includes the necessary css code and version of the font encoded as Base64 WOFF2 inline with the text. The downside of this tool is that it only runs on Linux (works fine in WSL2 on Windows).

An example CLI command to convert the Kalam Light true to font to an inline embedded css file would be:

./bin/generate-webfonts -o output Kalam-Light.ttf --format woff2:inline --css embed.css

The code is similar to that used for Scenario one, with the following changes:

  • The absolute path to the workspace folder is generated by the Extract Context Properties node and passed to the Python View node as a variable.
  • The base64 encoded woff2 data is stored in a text file and read into the encoded_font_file variable. This data could be included within the script, however, it would make the code unmanageable.
  • The css is wrapped in a style tag. The encoded font data is added into the string to make it inline within the html
  • The html document is composed using Beautiful Soup.
# Get path to workspace and set path to font file.
workspace = Path(knio.flow_variables['context.workflow.absolute-path'])
font_uri = workspace / "Data" / "Kalam-Light-WOFF2-base64.txt"
with open(font_uri, "r") as font_file:
    encoded_font_file = font_file.read()

# Creat css using inline string.
# Note curly braces need double escaping {{ }}
style.string = f"""
@font-face {{
    font-family: "Kalam";
    src: url(data:application/font-woff2;base64,{encoded_font_file}) format('woff2');  
}}
"""

Final Notes

It is possible to create visualisations using Python View nodes that utilise custom fonts. There are three ways that this can be achieved:

  • Install the font on all client machines - this may be impractical when a component is being distributed to multiple users.
  • Download the font from a web URI when the visualisation is used. The example used is to download the font from Google Fonts, however, any publicly accessible location can be used. This works provided the end user has an internet connection and the web site is accessible.
  • Embed the font into the CSS. This is the most reliable method, but is complicated to setup and increases the size of files and may not scale for complex dashboards.

The final comment is for KNIME to consider implementing a URI for the local (or hosted) server such that components can identify static assets which are shared across multiple components (e.g. JavaScript and fonts). These static assets would be cached at the clients web browser reducing load on the server and increasing the overall performance of the system.

DiaAzul
LinkedIn | Medium | GitHub

6 Likes

Thank you so much for this amazing writeup @DiaAzul!

Right, in the brief discussion we had about this topic I didn’t think of the limitation that JavaScrip needs to be able to access the file. But it obviously cannot access the file system.

Thanks for describing the two possible workarounds and for suggesting which functionality we could provide for making static asset access easier – we’ll look into that!

Just as a heads up, in the upcoming KNIME AP 5.1 (and already in the current KNIME AP nightly) you will be able to retrieve the path to the data area more easily using the knime.scripting.io.get_workflow_data_area_dir() method (see the nightly API docs:
Python Script API — KNIME Python API documentation).

Best,
Carsten

5 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.