Skip to content

Commit

Permalink
improving test for imageObjects and all their filters (#579)
Browse files Browse the repository at this point in the history
* improving test for imageObjects and all their filters

* wrong path to test files

* ignore  macos15+ filters

* ignore macos15+ filters

* fixing docs

* update min python version
  • Loading branch information
typemytype authored Feb 24, 2025
1 parent 15781e6 commit 53893d6
Show file tree
Hide file tree
Showing 7 changed files with 1,638 additions and 1,824 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@

DrawBot is a powerful, free application for macOS that invites you to write Python scripts to generate two-dimensional graphics. The built-in graphics primitives support rectangles, ovals, (bezier) paths, polygons, text objects, colors, transparency and much more. You can program multi-page documents and stop-motion animations. Export formats include PDF, SVG, PNG, JPEG, TIFF, animated GIF and MP4 video.

To download the latest version of the app, go to
To download the latest version of the app, go to
http://www.drawbot.com/content/download.html

---

## Using DrawBot as a Python module

DrawBot can also be installed as a Python module, the app is not required. It works on Python3.10+.
DrawBot can also be installed as a Python module, the app is not required. It works on Python3.11+.

#### Install
#### Install

The easiest way is to use pip:

Expand Down
3 changes: 2 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,4 +475,5 @@ def format_args(self):
def setup(app):
app.add_directive('showcode', ShowCode)
app.add_directive('downloadcode', DownloadCode)
app.add_autodocumenter(DrawBotDocumenter)
# 'self' seems to be removed upstream while formatting the arguments
# app.add_autodocumenter(DrawBotDocumenter)
2 changes: 1 addition & 1 deletion docs/content/shapes/bezierPath.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ Bezier Paths
:undoc-members:
:inherited-members:
:show-inheritance:
:exclude-members: copyContextProperties
:exclude-members: copyContextProperties, add_note, args, with_traceback, log, MissingComponentError, svgClass, svgID, svgLink
2 changes: 1 addition & 1 deletion docs/content/text/formattedString.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ Formatted Strings
:undoc-members:
:inherited-members:
:show-inheritance:
:exclude-members: copyContextProperties
:exclude-members: copyContextProperties, svgClass, svgID, svgLink
2,592 changes: 1,099 additions & 1,493 deletions drawBot/context/tools/imageObject.py

Large diffs are not rendered by default.

149 changes: 105 additions & 44 deletions scripting/imageObjectCodeExtractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def addDict(self, attribute, data, space=" ", trailing=""):
if value:
trailing = "" if index == len(data) - 1 else ","
self.addDict(key, value, space="", trailing=trailing)
trailing = ""
else:
comma = ","
if index == len(data) - 1:
Expand All @@ -49,6 +50,7 @@ def appendCode(self, otherCode):
def get(self, indentLevel=0):
return self.INDENT*indentLevel + f"\n{self.INDENT*indentLevel}".join(self.code)


class UnitTestWriter(CodeWriter):

def header(self):
Expand All @@ -57,8 +59,10 @@ def header(self):
self.add("import drawBot")
self.add("from testSupport import DrawBotBaseTest")
self.newline()
self.add('sourceImagePath = "tests/data/drawBot144.png"')
self.add('sampleImage = drawBot.ImageObject("tests/data/drawBot.png")')
self.add('fs = drawBot.FormattedString("Hello World")')
self.add('sampleFormattedString = drawBot.FormattedString("Hello World")')
self.add('sampleText = drawBot.FormattedString("Hello World")')
self.newline()
self.newline()
self.add("class ImageObjectTest(DrawBotBaseTest):")
Expand Down Expand Up @@ -96,13 +100,17 @@ def camelCase(txt):
"rectangle": "AppKit.CIVector.vectorWithValues_count_({inputKey}, 4)",
"lightPosition": "AppKit.CIVector.vectorWithValues_count_({inputKey}, 3)",
"angle": "radians({inputKey})",
"rotation": "radians({inputKey})",
"message": "AppKit.NSData.dataWithBytes_length_({inputKey}, len({inputKey}))",
"text": "text.getNSObject()",
"image": "{inputKey}._ciImage()",
("size", "CIStretchCrop"): "AppKit.CIVector.vectorWithValues_count_({inputKey}, 2)",
}

variableValues = {
"image": "an Image object",
"size": "a tuple (w, h)",
("size", "CIStretchCrop"): "a float",
"center": "a tuple (x, y)",
"angle": "a float in degrees",
"minComponents": "RGBA tuple values for the lower end of the range.",
Expand Down Expand Up @@ -222,6 +230,14 @@ def getVariableValue(key, fallback=None):
key = key[0]
return variableValues.get(key, fallback)


def getConverterValue(key, fallback=None):
if key in converters:
return converters[key]
key = key[0]
return converters.get(key, fallback)


argumentToHint = {"text": ": FormattedString", "message": ": str"}

toCopy = {
Expand All @@ -240,6 +256,9 @@ def getVariableValue(key, fallback=None):
"glassesImage",
"hairImage",
"matteImage",
"paletteImage",
"guideImage",
"smallImage",
),
"message": ("cube0Data", "cube1Data"),
"rectangle": (
Expand All @@ -257,6 +276,7 @@ def getVariableValue(key, fallback=None):
"blueCoefficients",
"alphaCoefficients",
"biasVector",
"focusRect"
),
"color": (
"replacementColor3",
Expand All @@ -283,6 +303,13 @@ def getVariableValue(key, fallback=None):
"topRight",
"bottomLeft",
"bottomRight",
"breakpoint0",
"breakpoint1",
"growAmount",
"nosePositions",
"leftEyePositions",
"rightEyePositions",
"chinPositions",
),
"lightPosition": ("lightPointsAt"),
"angle": ("acuteAngle", "crossAngle"),
Expand All @@ -298,7 +325,7 @@ def getVariableValue(key, fallback=None):

ignoreInputKeys = ["inputImage"]

generators = list(AppKit.CIFilter.filterNamesInCategory_("CICategoryGenerator"))
generators = list(AppKit.CIFilter.filterNamesInCategory_("CICategoryGenerator"))
generators.extend(
[
"CIPDF417BarcodeGenerator",
Expand All @@ -309,7 +336,7 @@ def getVariableValue(key, fallback=None):
]
)

allFilterNames = AppKit.CIFilter.filterNamesInCategory_(None)
allFilterNames = AppKit.CIFilter.filterNamesInCategory_(None)

excludeFilterNames = [
"CIBarcodeGenerator",
Expand All @@ -318,11 +345,44 @@ def getVariableValue(key, fallback=None):
"CIMedianFilter",
"CIColorCube",
"CIColorCubeWithColorSpace",
"CIHueSaturationValueGradient",
# this one requires a colorspace which is difficult to express for regular drawBot users
# little use for a filter like this, it does not make sense to abstract this for now
# no default value for the colorspace makes it difficult to use it
"CIColorCubesMixedWithMask",
"CIColorCurves",
"CIAffineTransform",

# use drawBot to draw text/formattedStrings into an image
"CITextImageGenerator",
"CIAttributedTextImageGenerator",

# no idea what inputCalibrationData or inputAuxDataMetadata is
"CIDepthBlurEffect",

# no idea what inputModel should be
"CICoreMLModelFilter",

# make an issue for a very good reason why DrawBot needs these filters!
"CIConvolution3X3",
"CIConvolution5X5",
"CIConvolution7X7",
"CIConvolution9Horizontal",
"CIConvolution9Vertical",
"CIConvolutionRGB7X7",
"CIConvolutionRGB9Vertical",
"CIConvolutionRGB9Horizontal",
"CIConvolutionRGB5X5",
"CIConvolutionRGB3X3",
"CIAreaAlphaWeightedHistogram",
"CIAreaBoundsRed",

"CIDistanceGradientFromRedMask", # macos15+
"CIMaximumScaleTransform", # macos15+
"CIToneCurve", # macos15+
"CIToneMapHeadroom" # macos15+


]

degreesAngleFilterNames = ["CIVortexDistortion"]
Expand All @@ -346,19 +406,19 @@ def generateImageObjectCode() -> tuple[str, str]:
code = CodeWriter()
unitTests = UnitTestWriter()
unitTests.header()

for filterName in allFilterNames:
if filterName in excludeFilterNames:
continue
ciFilter = AppKit.CIFilter.filterWithName_(filterName)
ciFilter = AppKit.CIFilter.filterWithName_(filterName)
ciFilterAttributes = ciFilter.attributes()
doc = CodeWriter()
doc.add(AppKit.CIFilter.localizedDescriptionForFilterName_(filterName))
doc.add(AppKit.CIFilter.localizedDescriptionForFilterName_(filterName))

args = []
unitTestsArgs = []
inputCode = CodeWriter()

inputKeys = [
inputKey
for inputKey in ciFilter.inputKeys()
Expand All @@ -371,38 +431,39 @@ def generateImageObjectCode() -> tuple[str, str]:
ciFilterAttributes.get(x, dict()).get("CIAttributeDefault") is not None
)
)

attributes = dict()

if inputKeys or filterName == "CIRandomGenerator":
doc.newline()
doc.add("**Arguments:**")
doc.newline()
if filterName in generators:
args.append("size: Size")
unitTestsArgs.append("size=(100, 100)")
doc.add(f"`size` {variableValues['size']}")
doc.add(f"* `size` {variableValues['size']}")
for inputKey in inputKeys:
info = ciFilterAttributes.get(inputKey)
default = info.get("CIAttributeDefault")
defaultClass = info.get("CIAttributeClass")

description = info.get("CIAttributeDescription", "")
filterInputKey = inputKey
inputKey = camelCase(inputKey[5:])
arg = inputKey

if inputKey in toCopy["image"]:
arg += ": Self"

if inputKey in argumentToHint:
arg += argumentToHint[inputKey]

# if filterName == "CIAztecCodeGenerator":
# print(inputKeys)
# print(ciFilterAttributes)

if default is not None:
if isinstance(default, AppKit.CIVector):
if isinstance(default, AppKit.CIVector):
if default.count() == 2:
default = default.X(), default.Y()
arg += ": Point"
Expand All @@ -414,22 +475,22 @@ def generateImageObjectCode() -> tuple[str, str]:
default.valueAtIndex_(i) for i in range(default.count())
)
arg += ": tuple"

elif isinstance(default, bool):
arg += ": bool"

elif isinstance(default, (AppKit.NSString, str)):
default = f"'{default}'"
arg += ": str"

elif isinstance(default, AppKit.NSNumber):
default = float(default)
arg += ": float"
elif isinstance(default, AppKit.NSAffineTransform):

elif isinstance(default, AppKit.NSAffineTransform):
default = tuple(default.transformStruct())
arg += ": TransformTuple"

elif isinstance(default, AppKit.CIColor):
default = (
default.red(),
Expand All @@ -438,46 +499,45 @@ def generateImageObjectCode() -> tuple[str, str]:
default.alpha(),
)
arg += ": RGBAColorTuple"

elif isinstance(default, AppKit.NSData):
default = None
arg += ": bytes | None"

elif isinstance(default, type(Quartz.CGColorSpaceCreateDeviceCMYK())): # type: ignore
default = None

else:
print(filterName, ciFilterAttributes)
raise ValueError(f"We can't parse this default class of `{inputKey}`: {defaultClass}, {default}, {type(default)}")

arg += f" = {default}"
if filterName in degreesAngleFilterNames:

if filterName in degreesAngleFilterNames and inputKey == "angle":
value = inputKey
else:
value = converters.get(inputKey, inputKey).format(inputKey=inputKey)
value = getConverterValue((inputKey, filterName), inputKey).format(inputKey=inputKey)
docValue = getVariableValue((inputKey, filterName), "a float")
attributes[inputKey] = value
doc.add(f"`{inputKey}` {docValue}. {pythonifyDescription(description)}")
attributes[filterInputKey] = value

doc.add(f"* `{inputKey}` {docValue}. {pythonifyDescription(description)}")
args.append(arg)



match inputKey:
case inputKey if inputKey.endswith("Image"):
value = "sampleImage"
case "gainMap" | "texture" | "mask":
value = "sampleImage"
case "text":
value = "fs"
value = "sampleFormattedString"
case "message":
value = "b'Hello World'"
case "topLeft" | "topRight" | "bottomRight" | "bottomLeft":
value = "(2, 2)"
case _:
value = default
unitTestsArgs.append(f"{inputKey}={value}")

drawBotFilterName = camelCase(filterName[2:])
code.add(
f"def {drawBotFilterName}"
Expand All @@ -496,28 +556,29 @@ def generateImageObjectCode() -> tuple[str, str]:
filterDict["isGenerator"] = "True"
if filterName.endswith("CodeGenerator"):
filterDict["fitImage"] = "True"

code.addDict("filterDict", filterDict)

code.add("self._addFilter(filterDict)")
code.dedent()
code.newline()

unitTests.add(f"def test_{drawBotFilterName}(self):")
unitTests.indent()
unitTests.add("img = drawBot.ImageObject()")
unitTests.add("img = drawBot.ImageObject(sourceImagePath)")
unitTests.add(f"img.{drawBotFilterName}({', '.join(unitTestsArgs)})")
unitTests.add("img._applyFilters()")
unitTests.newline()
unitTests.dedent()

imageObjectText = IMAGE_OBJECT_PATH.read_text()

beforeFilters = []
for eachLine in imageObjectText.splitlines():
beforeFilters.append(eachLine)
if eachLine == " # --- filters ---":
break

imageObjectCode = "\n".join(beforeFilters) + "\n" + code.get(indentLevel=1).replace("“", '"').replace("”", '"')
unitTests.footer()
unitTestsCode = unitTests.get()
Expand Down
Loading

0 comments on commit 53893d6

Please sign in to comment.