SVG Import Scaling/Cropping

When importing SVGs into Glyphs programmatically there’s some surprising scaling behavior with notably inconsistent behavior between importing the paths into the master and importing an SVG as a background image.

Base Font

To make this easily repeatable, open Fira Sans Regular TTF in Glyphs.app: https://fonts.gstatic.com/s/firasans/v17/va9E4kDNxMZdWfMOD5VfkILKSTbndQ.ttf

This font is UPEM 1000, and has ascender to descender total height of 1200. (Ascender: 935, Descender: -265)

Test SVG

Create this file as test.svg somewhere locally. You’ll note that it is height 1000, but it has elements that extend all the way to 1200. Elements beyond the height of the SVG do get imported as paths.

<svg xmlns="http://www.w3.org/2000/svg" height="1000" width="1200">
    <rect x="0" y="0" width="100%" height="100%" fill="blue" />
    <rect x="0" y="0" width="100" height="100" fill="red" />
    <rect x="100" y="100" width="100" height="100" fill="red" />
    <rect x="200" y="200" width="100" height="100" fill="red" />
    <rect x="300" y="300" width="100" height="100" fill="red" />
    <rect x="400" y="400" width="100" height="100" fill="red" />
    <rect x="500" y="500" width="100" height="100" fill="red" />
    <rect x="600" y="600" width="100" height="100" fill="red" />
    <rect x="700" y="700" width="100" height="100" fill="red" />
    <rect x="800" y="800" width="100" height="100" fill="red" />
    <rect x="900" y="900" width="100" height="100" fill="red" />
    <rect x="1000" y="1000" width="100" height="100" fill="red" />
    <rect x="1100" y="1100" width="100" height="100" fill="red" />
</svg>

Import Macro

Needs to be edited to specify the directory containing the test SVG.

import os
from Foundation import NSURL
thisFont = Glyphs.font

# EDIT ME
directory = "/PATH/TO/DIR/CONTAINING/TEST/SVG/"

del thisFont.glyphs['test'] # more-easily run tests after each other.

for fileName in os.listdir(directory):
	if fileName.endswith(".svg"):
		svgFilePath = directory+fileName
		svgFileUrl = NSURL.alloc().initFileURLWithPath_(svgFilePath)

		glyphName = fileName.replace(".svg","")
		unicodeValue = None

		newGlyph = GSGlyph(glyphName, autoName=False)
		newGlyph.name = glyphName
		newGlyph.unicode = unicodeValue

		# This fills out a whole bunch of defaults on newGlyph.
		thisFont.glyphs.append(newGlyph)

		# Populate the master layer.
		masterLayer = newGlyph.layers[0]
		masterLayer.importOutlinesFromURL_scale_error_(svgFileUrl, 1, None)

		# Define the SVG layer.
		svgLayer = GSLayer()
		svgLayer.setSVGColorLayer_(True)
		svgBackgroundImage = GSBackgroundImage.alloc().initWithURL_(svgFileUrl)
		svgLayer.setBackgroundImage_(svgBackgroundImage)

		# Add the layers.
		newGlyph.layers.append(svgLayer)

Bug?

This is what the output looks like. To me, this is unexpected.

My expected output (again, setting aside the two “cut off” blocks on the Y axis) is:

This is what I am assuming is happening in the scaling process during import:


  1. The original is 1200x1000. (Setting aside the two boxes which appear “beyond” the height of the SVG.)

  2. This gets cropped to 1000x1000.

  3. Then it for some reason gets scaled back to 1200x1000.

  4. And then it gets recropped back to 1000x1000. This is the value that eventually gets used in the SVG layer. Scaled again to 1200x1000.

(Sorry for having to split this report across multiple posts, but the forum configuration doesn’t let me embed multiple images in a single post as a new user. Once it does, I’ll go back and inline it into a single post.)

Is there a hardcoded scaleHeightToEmUnits for GSBackgroundImage that happens at some point where I can’t do anything about it prior to being handed to me? (e.g. during initWithURL?)

I’ve traced through the entire header chain and I can’t identify any place where I can change this behavior:

GSBackgroundImage : GSTransformableElement : GSElement : GSShape

A script (using the same setup from above) that demonstrates me attempting to adjust scale/crop/transform to prevent this:

import os
from Foundation import CGRect, CGPoint, CGSize, NSURL, NSPoint
thisFont = Glyphs.font

# EDIT ME
directory = "/PATH/TO/DIR/CONTAINING/TEST/SVG/"

del thisFont.glyphs['test'] # more-easily run tests after each other.

for fileName in os.listdir(directory):
	if fileName.endswith(".svg"):
		svgFilePath = directory+fileName
		svgFileUrl = NSURL.alloc().initFileURLWithPath_(svgFilePath)

		glyphName = fileName.replace(".svg","")
		unicodeValue = None

		newGlyph = GSGlyph(glyphName, autoName=False)
		newGlyph.name = glyphName
		newGlyph.unicode = unicodeValue

		# This fills out a whole bunch of defaults on newGlyph.
		thisFont.glyphs.append(newGlyph)

		# Populate the master layer.
		masterLayer = newGlyph.layers[0]
		masterLayer.importOutlinesFromURL_scale_error_(svgFileUrl, 1, None)

		# Define the SVG layer.
		svgLayer = GSLayer()
		svgLayer.setSVGColorLayer_(True)
		svgBackgroundImage = GSBackgroundImage.alloc().initWithURL_(svgFileUrl)

		print("before")
		print(svgBackgroundImage.crop)
		print(svgBackgroundImage.position)
		print(svgBackgroundImage.scale)
		print(svgBackgroundImage.transform)

		# STRANGE: This allows me to crop, but it appears to be happening after scaling has occurred.
		# If the source image is width="500" height="2000" it first becomes 250x1000, and then applies this crop.
		manualCrop = CGRect(CGPoint(0,0), CGSize(100,1000))

		manualPosition = NSPoint(0, thisFont.masters[0].descender)
		manualScale = (1,1)
		manualTransform = (1.0, 0.0, 0.0, 1.0, 0.0, thisFont.masters[0].descender)

		svgBackgroundImage.setCrop_(manualCrop)
		svgBackgroundImage.setPosition_(manualPosition)
		svgBackgroundImage.setScale_(manualScale)
		svgBackgroundImage.transform = manualTransform

		print("mutated")
		print(svgBackgroundImage.crop)
		print(svgBackgroundImage.position)
		print(svgBackgroundImage.scale)
		print(svgBackgroundImage.transform)

		print("assigning background image")
		svgLayer.setBackgroundImage_(svgBackgroundImage)

		print("after")
		print(svgLayer.backgroundImage.crop)
		print(svgLayer.backgroundImage.position)
		print(svgLayer.backgroundImage.scale)
		print(svgLayer.backgroundImage.transform)

		print("attempting mutation after assigning background image")
		svgLayer.backgroundImage.setCrop_(manualCrop)
		svgLayer.backgroundImage.setPosition_(manualPosition)
		svgLayer.backgroundImage.setScale_(manualScale)
		svgLayer.backgroundImage.transform = manualTransform

		print("remutated")
		print(svgLayer.backgroundImage.crop)
		print(svgLayer.backgroundImage.position)
		print(svgLayer.backgroundImage.scale)
		print(svgLayer.backgroundImage.transform)

		# Add the layers.
		newGlyph.layers.append(svgLayer)

Note in particular that, if I do this to a width="500" height="2000" SVG (same source SVG, just modify the dimensions) it will crop after the initial scaling of it, which means that it makes each square fifty pixels wide, and then cuts it down to two-wide. (100px)

svgBackgroundImage.setCrop_(CGRect(CGPoint(0,0), CGSize(100,1000)))

This does not appear to match the description in the documentation for the crop API, but I could be reading it wrong.

If scaling is indeed occurring in initWithURL as implied by “It will be scaled so that 1 em unit equals 1 of the image’s pixels.” from the GSLayer.backgroundImage docs, I’d propose one of two options:

  1. Update initWithURL to have that transformation be attached to the GSBackgroundImage’s scale. This isn’t 100% backwards compatible, but prevents needing to do double transformations. (The user can inspect and adjust, or alternatively clobber the existing transform.)
  2. initWithURLUnscaled, which just drops the scaling pass.

The nonscaled variant would also have downstream impacts on the consistency with other things like importOutlinesFromURL_scale_error_ which is non-ideal, but for SVG can be sidestepped by just lying about width and height using <svg width="UPEM" height="UPEM"> and just overflowing the SVG bounds. Keeps it unscaled, gets all the paths in, and a position offset is easy to calculate.

I also hypothesize that the scaling pass (which I assume is in initWithURL) does not maintain aspect ratio in all scenarios (namely, when width is greater than height), which is causing my originally-reported issue.