1
\$\begingroup\$

I have a small python project, which draws graphics and text onto a graphics context using MacOS's Core Graphics APIs. The idea is to make it easy for the user to write a few short lines, in order to add graphics to a page.

Currently, I'm committing the cardinal sin of a global variable for the graphics context, which each function then draws into. I realise that what I probably ought to do is to create some kind of object, and have the functions as object methods.

While I understand the principle of objects and methods, I'm not very good at conceptualising how they ought to work. Any help with the concept would be very welcome.

#!/usr/bin/python
# coding=utf-8

import os, sys
import Quartz as Quartz
from CoreText import (kCTFontAttributeName, CTFontCreateWithName, CTLineDraw, CTLineCreateWithAttributedString, kCTFontAttributeName, CTLineGetImageBounds)
from CoreFoundation import (CFAttributedStringCreate, CFURLCreateFromFileSystemRepresentation, kCFAllocatorDefault)
from math import pi as PI

pageSize = [[0.,0.], [595.28, 841.88]] # A4
whiteSwatch = [1.,1.,1.]
redSwatch = [1.,0.,0.]
blueSwatch = [0.,0.,1.]
greenSwatch = [0.,1.,0.]
blackSwatch = [0.,0.,0.]

# Use inches instead of points e.g. "inch(1.5)"
def inch(x):
    return 72.0*x

# Use centimetres instead of points e.g. "cm(2.5)"
def cm(x):
    return 28.25*x


def makeRectangle(x, y, xSize, ySize, color, alpha):
    red, green, blue = color[:]
    Quartz.CGContextSetRGBFillColor (writeContext, red, green, blue, alpha)
    Quartz.CGContextFillRect (writeContext, Quartz.CGRectMake(x, y, xSize, ySize))
    return


def centerText(y, text, font, pointSize):
    typeStyle = CTFontCreateWithName(font, pointSize, None)
    astr = CFAttributedStringCreate(kCFAllocatorDefault, text, { kCTFontAttributeName : typeStyle })
    line = CTLineCreateWithAttributedString(astr)
    textWidth = astr.size().width

    if line:
        x = (pageSize[1][0]-textWidth)/2
        # Quartz.CGContextSetAlpha(writeContext, opacity)
        Quartz.CGContextSetTextPosition(writeContext, x, y)
        CTLineDraw(line, writeContext)

    return

def line(x, y, xSize, ySize, stroke, color, alpha):
    red, green, blue = color[:]
    Quartz.CGContextSetLineWidth(writeContext, stroke)
    Quartz.CGContextSetRGBStrokeColor(writeContext, red, green, blue, alpha)
    Quartz.CGContextMoveToPoint(writeContext, x, y)
    Quartz.CGContextAddLineToPoint(writeContext, x+xSize, y+ySize)
    Quartz.CGContextStrokePath(writeContext)
    return

def circle(x, y, radius, color, alpha):
    red, green, blue = color[:]
    Quartz.CGContextSetRGBStrokeColor(writeContext, red, green, blue, alpha)
    Quartz.CGContextSetRGBFillColor(writeContext, red, green, blue, alpha)
    Quartz.CGContextAddArc(writeContext, x, y, radius, 0, 2*PI, 1)
    Quartz.CGContextClosePath(writeContext)
    Quartz.CGContextFillPath(writeContext)
    Quartz.CGContextSetLineWidth(writeContext, 2)
    Quartz.CGContextStrokePath(writeContext)
    return

def addImage(x, y, path):
    # CGContextDrawImage(writeContext, rect, CGImageRef image)
    return

def contextDone(context):
    if context:
        Quartz.CGPDFContextClose(context)
        del context 

def main(argv):
    global writeContext
    writeFilename = os.path.expanduser("~/Desktop/Test.pdf")
    writeContext = Quartz.CGPDFContextCreateWithURL(CFURLCreateFromFileSystemRepresentation(kCFAllocatorDefault, writeFilename, len(writeFilename), False), pageSize, None)
    Quartz.CGContextBeginPage(writeContext, pageSize)


#  HERE IS WHERE YOU WRITE YOUR PAGE!
# ------------------------------------------------------------------

    makeRectangle(100., 100., 400., 50., redSwatch, 0.75)
    makeRectangle(100., 700., 400., 50., greenSwatch, 0.75)
    line(100, 300, 400, 200, 12, blueSwatch, 1)
    circle(300.,400., 150., blueSwatch, 0.5)
    centerText(600, "Sample Text", "Helvetica-Bold", 12.0)

# ------------------------------------------------------------------    

    Quartz.CGContextEndPage(writeContext)
        # Do tidying up
    contextDone(writeContext)   

if __name__ == "__main__":
    main(sys.argv[1:])

The ultimate goal is to turn this into a python library, so that a short script can import the library and then use a few lines to do some drawing.

The project can be found on github here.

\$\endgroup\$
2
\$\begingroup\$

The code in this answer is not tested and is only there as a guide

"A badly constructed object is not better than using globals." – me

def circle_radius_from_circumference(circ):
    return circ / math.tau

def circle_circumference_from_radius(radius):
    return radius * math.tau

How to make an object out of that ?

class Circle:
    @staticmethod
    def radius_from_circumference(circ):
        return circ / math.tau

    @staticmethod
    def circumference_from_radius(radius):
        return radius * math.tau

print(Circle.circumference_from_radius(1))

Sure we did it, but what's the point, we don't code Java here, we do Python. A class is as way to organise data, not functions.

class Circle:
    def __init__(self, *, radius=None, circ=None):
        # feel free to add appropriate checks, this is just an example
        self._r = radius if circ is None else circ / math.tau

    # if you're a great programmer, you can make the following method properties.

    def radius(self):
        return self._r

    def circumference(self):
        return self._r * math.tau

print(Circle(radius=10).circumference(1))

Why is that useful ? Because you can add methods and data and it scales.

Why should you care ? Because it relates to how your code is constructed. Your code is like the first example, and you don't want to be in the second example. And the third example has too little use to be in a class, but consider the following:

class Color(tuple):  # an example of an immutable object

    def __new__(cls, r, g, b, a=1):
        return (r, g, b, a)

    r = property(lambda self: self[0])
    g = property(lambda self: self[1])
    b = property(lambda self: self[2])
    a = property(lambda self: self[3])

    def darker(self, v=10):
        return Color(*[max(i-v, 0) for i in self[:3]], self.a)

    def with_alpha(self, a):
        return Color(*self[:3], a)

    # Imagine all the great methods you could add

Color.WHITE = Color(1, 1, 1)
Color.RED = Color(1, 0, 0)
Color.BLUE = Color(0, 0, 1)
Color.GREEN = Color(0, 1, 0)
Color.BLACK = Color(0, 0, 0)
  1. Here, color is an immutable.
  2. You don't pollute globals with constants.
  3. You can pass the object around as a single object
  4. You can apply methods on color instances
class Canvas:
    def __init__(self, pageSize=[[0., 0.], [595.28, 841.88]]):
        self.writeFilename = os.path.expanduser("~/Desktop/Test.pdf")
        writeContext = Quartz.CGPDFContextCreateWithURL(
            CFURLCreateFromFileSystemRepresentation(
                kCFAllocatorDefault,
                self.writeFilename,
                len(self.writeFilename),
                False
            ),
            pageSize,
            None
        )
        Quartz.CGContextBeginPae(writeContext, pageSize)
        self.shapes = set()

    def redraw(self):
        for shape in self.shapes:
            shape.draw()


class Shape:
    def __init__(self, canvas=Canvas()):
        self.canvas = canvas
        self.canvas.shapes.add(self)


class Rectangle(Shape):
    def __init__(self, x, y, width, height, color):
        super().__init__()
        self.pos = x, y
        self.shape = width, height
        self.color = color
        self.canvas.redraw()

    def draw(self):
        Quartz.CGContextSetRGBFillColor(self.canvas.writeContext, *self.color)
        Quartz.CGContextFillRect(
            self.canvas.writeContext, Quartz.CGRectMake(*self.pos, *self.shape))

class Circle(Shape):
    def __init__(self, ...):
        super().__init__()
        ...
        self.canvas.redraw()

    def draw(self):
        ...

The example above can be used like this.

Rectangle(100, 100, 400, 50, Color.RED.with_alpha(0.75))
Rectangle(100, 700, 400, 50, Color.GREEN.with_alpha(0.75))

But the shape class is kind of dirty in the way it implicitly creates a canvas and redraws.

class Canvas:
    def __init__(self, pageSize=[[0., 0.], [595.28, 841.88]]):
        self.writeFilename = os.path.expanduser("~/Desktop/Test.pdf")
        writeContext = Quartz.CGPDFContextCreateWithURL(
            CFURLCreateFromFileSystemRepresentation(
                kCFAllocatorDefault,
                self.writeFilename,
                len(self.writeFilename),
                False
            ),
            pageSize,
            None
        )
        Quartz.CGContextBeginPae(writeContext, pageSize)
        self.shapes = set()

    def redraw(self):
        for shape in self.shapes:
            shape.draw()

    def add_children(self, *shapes):
        for shape in shapes:
            self.shapes.add(shape)

class Shape: pass

class Rectangle(Shape):  # an example of a mutable object
    def __init__(self, x, y, width, height, color):
        super().__init__()
        self.pos = x, y
        self.shape = width, height
        self.color = color

    def draw(self):
        Quartz.CGContextSetRGBFillColor(self.canvas.writeContext, *self.color)
        Quartz.CGContextFillRect(
            self.canvas.writeContext, Quartz.CGRectMake(*self.pos, *self.shape))

can be used like this:

canvas = Canvas()
canvas.add_children(
    Rectangle(100, 100, 400, 50, Color.RED.with_alpha(0.75)),
    Rectangle(100, 700, 400, 50, Color.GREEN.with_alpha(0.75))
)
canvas.redraw()
\$\endgroup\$
  • \$\begingroup\$ Thanks. This is incredibly helpful. And a lot to think through! I'm sure I'll be back here with my attempts to make this work. \$\endgroup\$ – benwiggy Feb 25 at 7:21
  • \$\begingroup\$ One more thing: does every variable inside the Class definition need to be self.variable? And do I pass parameters to object methods in the same way as __init__? \$\endgroup\$ – benwiggy Feb 26 at 9:26
1
\$\begingroup\$

This is an interesting project. Is your goal to simplify the use of CoreGraphics from Python? What's the advantage of using your python module over just importing Quartz and using it directly? That's not intended as a criticism, but a serious question. The answer will inform how you design the interface for your library.

Naming

One thing I'd like to point out is that your naming is confusing. For example, you have:

def inch(x):

From just looking at the function prototype, it's not clear at all what the function returns or what the argument is supposed to be. The comment makes this even less clear. (If I'm using inches, why does the function return points?) A better way to name these would be something like:

 def inchesToPoints(inches):
 ...
 def cmToPoints(cm):

Also, why are you changing the constant name pi to PI?

Your naming is also inconsistent. You have makeRectangle() (which draws a rectangle rather than making one) and then just line() and circle(). These should all be something like fillRectangle(), strokeLine() and fillAndStrokeCircle(). Also, it would make sense to have the same forms for all shapes - fillRectangle(), strokeRectangle(), fillCircle(), strokeCircle(), etc.

API

Looking at the Quartz.framework headers, there is an object model immediately suggested to me. The main object in CoreGraphics is the CGContext. The header treats it as an opaque pointer, but we can assume for our purposes that it points to some sort of object behind-the-scenes. You could mimic that, offering a Context or DrawingContext class.

CoreGraphics also has several data types that it passes around, such as CGPoint, CGRect, and CGColor. These would make good classes, as well. And it turns out that python supports some operator overloading so you can make some useful methods like overloading + and - for easy use of points as vectors.

Now all of this might not be the right abstraction for your library. (Though it might be useful internally for it.) The right abstraction will depend on your goals. But most modern graphics APIs have a concept of some sort of context to draw into and a set of methods to do the drawing of various types of primitives (shapes, images, text, etc.). So it's a decent model to follow.

\$\endgroup\$
  • \$\begingroup\$ Yes, I'm planning to simplify the CoreGraphics APIs somewhat, so that I (or anyone) can just write a sequence of functions to add graphics and text, without re-writing the same lines over and over. It's "not quite PostScript"™. The inches thing was just to make it easy for someone to specify different units, rather than the points required. It's still early stages, so I'd probably have functions for setting stroke and fill independently of the shape or line. But my prime concern is getting the object concept so that I can then build all the methods. Thanks. \$\endgroup\$ – benwiggy Feb 24 at 21:51

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.