Functions for Wrapping Text Around a Cylinder or Curving Text Around an Ellipse
Before we can perform either of these functions, we will need to be able to calculate the circumference of the ellipse, and that's not so easy to do. In fact, there isn't a precise equation for calculating the perimeter of any oval other than a circle. However, there are quite a few ways to accurately estimate it. Here is a helper function for quickly getting an ellipse's perimeter distance:
from java.lang import Math
def getOvalCircumference(width, height):
horizontalRadius = width / 2.0 # Major Axis
verticleRadius = height / 2.0 # Minor Axis
# Calculate a close approximation of the ellipse's circumference
# https://www.mathsisfun.com/geometry/ellipse-perimeter.html ~ "Approximation 2"
return Math.PI * (3 * (horizontalRadius + verticleRadius) - Math.sqrt((3 * horizontalRadius + verticleRadius) * (horizontalRadius + 3 * verticleRadius)))
Armed with that, it is now possible to curve text around an oval using this function:
from java.lang import Math
def drawStringAroundOval(graphics, text, x, y, width, height, degreeOffset=0, isInverted=False):
centerX = x + width / 2.0
centerY = y + height / 2.0
horizontalRadius = width / 2.0 # Major Axis
verticalRadius = height / 2.0 # Minor Axis
circumference = getOvalCircumference(width, height)
fontMetrics = graphics.fontMetrics
# Set the unit circle starting angle around the ellips for the string
angle = Math.toRadians(degreeOffset)
# If inverted, the top of the characters will face the origin instead of the bottom
direction = -1 if isInverted else 1
for character in text:
# Get the current transform for the graphics object,
# ...so it can be reset for the next character position calculation
originalTransform = graphics.getTransform()
# Measure how much horizontal space this character takes in the current font,
# ...and convert that character width into an angular amount around the cylinder.
characterWidth = fontMetrics.charWidth(character)
widthOffset = (characterWidth / circumference) * 2 * Math.PI * direction
angle -= widthOffset / 2.0 # Move halfway into this character's angular space before drawing it to center the character on its respective angle
# Calculate the x and y coordinates for the character on the ellipse, and translate origin to that position,
# ...so all that has be calculated to draw in the right place are the centering offsets
characterX = centerX + Math.cos(angle) * horizontalRadius
characterY = centerY - Math.sin(angle) * verticalRadius
graphics.translate(characterX, characterY)
# Calculate the tangent direction of the ellipse at this angle, and rotate the character accordingly
tangentX = -horizontalRadius * Math.sin(angle)
tangentY = -verticalRadius * Math.cos(angle)
rotationAngle = Math.atan2(tangentY, tangentX) if isInverted else Math.atan2(tangentY, tangentX) + Math.PI
graphics.rotate(rotationAngle)
# Adjust the string position to center it vertically on its ellipse and horizontally on its angle
centeringOffsetX = -characterWidth / 2
centeringOffsetY = fontMetrics.ascent / 2
graphics.drawString(character, centeringOffsetX, centeringOffsetY)
# Restore the graphics transform for the next character.
graphics.setTransform(originalTransform)
# Add in the second half of the character's width,
# ...which places its value at the start of the next character's space
angle -= widthOffset / 2.0
In this script, x, y, width and height are obviously the position and dimension of the oval.
Supplying a degreeOffset will move the string around the ellipse in a counter clockwise direction if a positive value is supplied or a clockwise direction if a negative value is supplied.
I added an optional isInverted boolean flag because I could imagine times when it would be desirable for the top of the text to face inward, but if set to false or ignored, the bottom of the text will face inward.
I imagine that usually, the length of the string will not be long enough to circumvent the given oval, and in those instances, it would be desirable to center the text either on the top or the bottom of whatever is being drawn around. To accomplish this, use fontMetrics to determine the width of the string and use it to calculate what percentage of the oval's circumference is being consumed by the text. The angular offset will be that percentage of 360 degrees. My function always starts at 0 degrees as it appears on the unit circle, and intuitively, a positive offset will always push the text counter clockwise, so whether or not the text is inverted matters, since inverted text will be typed in the opposite direction.
Here is an example of how how to handle either scenario to position the text, so it is centered at the top of the ellipse:
testString = 'Coffee is that savory elixir that transforms complex logic into functional code and abstract ideas into deployed reality'
####
# Calculate position and dimensions here
####
isInverted = False # or True
circumference = event.source.getOvalCircumference(stringOvalWidth, stringOvalHeight)
stringWidth = fontMetrics.stringWidth(testString)
ovalPercentage = float(stringWidth) / circumference
angleOffset = (360 * ovalPercentage) / 2
stringOvalAngleOffset = 90 - angleOffset if isInverted else 90 + angleOffset # Always centered at the top
event.source.drawStringAroundOval(graphics, testString, x, y, width, height, stringOvalAngleOffset, isInverted)
Of course, from here the next natural progression is to wrap text around a cylinder. Here is a library script I developed that does this:
from java.lang import Math
from java.awt import AlphaComposite # Used to fade the letters slightly at the edges
def drawStringAroundCylinder(graphics, text, x, y, width, height):
fontMetrics = graphics.fontMetrics
# Make it impossible for any characters to overlap the edges of the cup
originalClip = graphics.getClip()
graphics.setClip(x, y, width, (2 * height))
# This is used to slightly widen the major axis of the ellipse,
# ...so the text won't curve up as much at the edges
# ...making it easier to squish them around the edges of the cylinder
flatteningFactor = 1.15
flattenedWidth = flatteningFactor * width
# The center of the ellipse
centerX = x + width / 2.0
centerY = y + height / 2.0
# The radii of the magor and minor axes
horizontalRadius = flattenedWidth / 2.0 # Modified to be wider than the actual cup
verticalRadius = height / 2.0
# Get the estinated circumference of the slightly widened oval
circumference = getOvalCircumference(flattenedWidth, height)
# Starting at the middle of the given string and working outward,
# ...get the letters that will actually fit or partially fit within the bounds of the cup
index = 0
midIndex = len(text) / 2
finalString = text[midIndex]
while fontMetrics.stringWidth(finalString) <= width and index <= midIndex:
index += 1
startIndex = max(0, midIndex - index)
endIndex = min(len(text), midIndex + index)
finalString = text[startIndex:endIndex] # Do this last to ensure it adds two overhanging letters before the loop breaks
# Get the width of the clipped string,
# ...and caculate the necessary angular offset to center the text on the cylinder
stringWidth = fontMetrics.stringWidth(finalString) / flatteningFactor
ovalPercentage = float(stringWidth) / circumference
angleOffset = 360.0 * ovalPercentage / 2.0
startAngle = 270.0 - angleOffset
# Start the text on the offset starting angle, and individually draw each character in the circle
angle = Math.toRadians(startAngle)
for character in finalString:
# Measure how much horizontal space this character takes in the current font,
# ...and convert that character width into an angular amount around the cylinder.
characterWidth = fontMetrics.charWidth(character) / flatteningFactor
widthOffset = characterWidth / circumference * 2.0 * Math.PI
angle += widthOffset / 2.0
# Calculate the x and y coordinates for the character on the ellipse
characterX = centerX + Math.cos(angle) * horizontalRadius
characterY = centerY - Math.sin(angle) * verticalRadius
# Take the absolute value of the distance from origin
# ...to determine how close the character is to the left or right edge of the ellipse.
edgeFactor = abs(characterX - centerX) / horizontalRadius# The value of edge is 0 at the center and approaches 1 at either side.
# Use the proxity from the edge calculate an amount squish the letters horizontally as they approach the edge of the cylinder
# ...to simulate the letters angling away around the curvature of the cup
squishFactor = Math.sqrt(max(0.0, 1.0 - edgeFactor * edgeFactor))
# Save the current graphics state so this character's transparency,
# ...translation, and scaling do not affect the next character.
originalTransform = graphics.getTransform()
originalComposite = graphics.composite
# Make the characters fade from view as they get closer to the cylinder edge.
graphics.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, squishFactor)
# Move the drawing origin to the character's ellipse position,
# ...and apply the squish factor to horizontally squeeze characters that are near the edges.
graphics.translate(characterX, characterY)
graphics.scale(squishFactor, 1.0)
# Draw the character centered on the transformed origin.
centeringOffsetX = -characterWidth / 2
centeringOffsetY = fontMetrics.ascent / 2
graphics.drawString(character, centeringOffsetX, centeringOffsetY)
# Restore original composite and transform,
# ...and increment the angle in preparation for the next cycle
graphics.composite = originalComposite
graphics.setTransform(originalTransform)
angle += widthOffset / 2.0
# Restore the drawing and painting ability outside the bounds of the cup
graphics.setClip(originalClip)
This is similar to curving text around an oval except I've found that not angling the characters produces a more believable effect, and furthermore, some hocus pocus is needed near edges of the cylinder to make the text appear to curve away. Lastly, an ellipse tends to start curving up sharply near its edge, and that looks unnatural when the goal is to curve around to the back instead of up, so I've found that it's better to make the ellipse of the cylinder slightly wider to flatten out the curve a bit.
To demonstrate the effectiveness of this technique, I'll add a fontSize property to the paintable canvas and bind it to the floatValue of a spinner component that I've set up to increment at a value of 0.5

I'll package the preceding functions into custom methods that I'll call from the repaint event:
Note: The last two custom methods are used to generate the coffee cup that I will be wrapping with text. I'll provide all of the scripts at the bottom of the post in case anybody has a use for them.
Watch how the text appears to wrap around the edges as the font size increases and the text begins to overflow:

Here is the repaint event along with each custom method I developed for this tutorial. Simply click on the arrows to reveal and copy the code:
Paintable canvas repaint event handler:
repaint
graphics = event.graphics
color = system.vision.color # Version 8.3 or newer
#color = system.gui.color # Version 8.1 or older
# The coffee cup
cupX = cupY = 100
cupWidth = 140
cupHeight = 180
cupTopOvalHeight = 40
event.source.paintCoffeeCup(graphics, cupX, cupY, cupWidth, cupHeight)
# The large string to be drawn in an oval well outside the bounds of the cup
originalFont = graphics.font
graphics.font = originalFont.deriveFont(16.0).deriveFont(originalFont.BOLD + originalFont.ITALIC)
fontMetrics = graphics.fontMetrics
testString = 'Coffee is that savory elixir that transforms complex logic into functional code and abstract ideas into deployed reality'
stringOvalMargin = 80 # How far away from the outside of the cup, should this oval of text be
stringOvalX = cupX - stringOvalMargin
stringOvalY = cupY - stringOvalMargin
stringOvalWidth = cupWidth + (2 * stringOvalMargin)
stringOvalHeight = cupHeight + (2 * stringOvalMargin)
isInverted = True
circumference = event.source.getOvalCircumference(stringOvalWidth, stringOvalHeight)
stringWidth = fontMetrics.stringWidth(testString)
ovalPercentage = float(stringWidth) / circumference
angleOffset = (360 * ovalPercentage) / 2
stringOvalAngleOffset = 90 - angleOffset if isInverted else 90 + angleOffset
graphics.color = color('black')
event.source.drawStringAroundOval(graphics, testString, stringOvalX, stringOvalY, stringOvalWidth, stringOvalHeight, stringOvalAngleOffset, isInverted)
# The text that is wrapped around the outside of the coffee cup
graphics.font = originalFont.deriveFont(event.source.fontSize).deriveFont(originalFont.BOLD)
nextTestStrings = [unicode('I LOVE ♥'), 'Good Coffee'] # Two lines to print
graphics.color = color(0, 0, 0, 155)
lineSpacing = int(event.source.fontSize * 1.5)
for index, word in enumerate(nextTestStrings):
event.source.drawStringAroundCylinder(graphics, word, cupX, cupY + cupTopOvalHeight + (index * lineSpacing), cupWidth, cupTopOvalHeight)
Custom Methods on the paintable canvas:
drawStringAroundCylinder
#def drawStringAroundCylinder(self, graphics, text, x, y, width, height):
from java.lang import Math
from java.awt import AlphaComposite # Used to fade the letters slightly at the edges
fontMetrics = graphics.fontMetrics
# Make it impossible for any characters to overlap the edges of the cup
originalClip = graphics.getClip()
graphics.setClip(x, y, width, (2 * height))
# This is used to slightly widen the major axis of the ellipse,
# ...so the text won't curve up as much at the edges
# ...making it easier to squish them around the edges of the cylinder
flatteningFactor = 1.15
flattenedWidth = flatteningFactor * width
# The center of the ellipse
centerX = x + width / 2.0
centerY = y + height / 2.0
# The radii of the magor and minor axes
horizontalRadius = flattenedWidth / 2.0 # Modified to be wider than the actual cup
verticalRadius = height / 2.0
# Get the estinated circumference of the slightly widened oval
circumference = self.getOvalCircumference(flattenedWidth, height)
# Starting at the middle of the given string and working outward,
# ...get the letters that will actually fit or partially fit within the bounds of the cup
index = 0
midIndex = len(text) / 2
finalString = text[midIndex]
while fontMetrics.stringWidth(finalString) <= width and index <= midIndex:
index += 1
startIndex = max(0, midIndex - index)
endIndex = min(len(text), midIndex + index)
finalString = text[startIndex:endIndex] # Do this last to ensure it adds two overhanging letters before the loop breaks
# Get the width of the clipped string,
# ...and caculate the necessary angular offset to center the text on the cylinder
stringWidth = fontMetrics.stringWidth(finalString) / flatteningFactor
ovalPercentage = float(stringWidth) / circumference
angleOffset = 360.0 * ovalPercentage / 2.0
startAngle = 270.0 - angleOffset
# Start the text on the offset starting angle, and individually draw each character in the circle
angle = Math.toRadians(startAngle)
for character in finalString:
# Measure how much horizontal space this character takes in the current font,
# ...and convert that character width into an angular amount around the cylinder.
characterWidth = fontMetrics.charWidth(character) / flatteningFactor
widthOffset = characterWidth / circumference * 2.0 * Math.PI
angle += widthOffset / 2.0
# Calculate the x and y coordinates for the character on the ellipse
characterX = centerX + Math.cos(angle) * horizontalRadius
characterY = centerY - Math.sin(angle) * verticalRadius
# Take the absolute value of the distance from origin
# ...to determine how close the character is to the left or right edge of the ellipse.
edgeFactor = abs(characterX - centerX) / horizontalRadius# The value of edge is 0 at the center and approaches 1 at either side.
# Use the proxity from the edge calculate an amount squish the letters horizontally as they approach the edge of the cylinder
# ...to simulate the letters angling away around the curvature of the cup
squishFactor = Math.sqrt(max(0.0, 1.0 - edgeFactor * edgeFactor))
# Save the current graphics state so this character's transparency,
# ...translation, and scaling do not affect the next character.
originalTransform = graphics.getTransform()
originalComposite = graphics.composite
# Make the characters fade from view as they get closer to the cylinder edge.
graphics.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, squishFactor)
# Move the drawing origin to the character's ellipse position,
# ...and apply the squish factor to horizontally squeeze characters that are near the edges.
graphics.translate(characterX, characterY)
graphics.scale(squishFactor, 1.0)
# Draw the character centered on the transformed origin.
centeringOffsetX = -characterWidth / 2
centeringOffsetY = fontMetrics.ascent / 2
graphics.drawString(character, centeringOffsetX, centeringOffsetY)
# Restore original composite and transform,
# ...and increment the angle in preparation for the next cycle
graphics.composite = originalComposite
graphics.setTransform(originalTransform)
angle += widthOffset / 2.0
# Restore the drawing and painting ability outside the bounds of the cup
graphics.setClip(originalClip)
drawStringAroundOval
#def drawStringAroundOval(self, graphics, text, x, y, width, height, degreeOffset=0, isInverted=False):
from java.lang import Math
centerX = x + width / 2.0
centerY = y + height / 2.0
horizontalRadius = width / 2.0 # Major Axis
verticalRadius = height / 2.0 # Minor Axis
circumference = self.getOvalCircumference(width, height)
fontMetrics = graphics.fontMetrics
# Set the unit circle starting angle around the ellips for the string
angle = Math.toRadians(degreeOffset)
# If inverted, the top of the characters will face the origin instead of the bottom
direction = -1 if isInverted else 1
for character in text:
# Get the current transform for the graphics object,
# ...so it can be reset for the next character position calculation
originalTransform = graphics.getTransform()
# Measure how much horizontal space this character takes in the current font,
# ...and convert that character width into an angular amount around the cylinder.
characterWidth = fontMetrics.charWidth(character)
widthOffset = (characterWidth / circumference) * 2 * Math.PI * direction
angle -= widthOffset / 2.0 # Move halfway into this character's angular space before drawing it to center the character on its respective angle
# Calculate the x and y coordinates for the character on the ellipse, and translate origin to that position,
# ...so all that has be calculated to draw in the right place are the centering offsets
characterX = centerX + Math.cos(angle) * horizontalRadius
characterY = centerY - Math.sin(angle) * verticalRadius
graphics.translate(characterX, characterY)
# Calculate the tangent direction of the ellipse at this angle, and rotate the character accordingly
tangentX = -horizontalRadius * Math.sin(angle)
tangentY = -verticalRadius * Math.cos(angle)
rotationAngle = Math.atan2(tangentY, tangentX) if isInverted else Math.atan2(tangentY, tangentX) + Math.PI
graphics.rotate(rotationAngle)
# Adjust the string position to center it vertically on its ellipse and horizontally on its angle
centeringOffsetX = -characterWidth / 2
centeringOffsetY = fontMetrics.ascent / 2
graphics.drawString(character, centeringOffsetX, centeringOffsetY)
# Restore the graphics transform for the next character.
graphics.setTransform(originalTransform)
# Add in the second half of the character's width,
# ...which places its value at the start of the next character's space
angle -= widthOffset / 2.0
getOvalCircumference
#def getOvalCircumference(self, width, height):
from java.lang import Math
horizontalRadius = width / 2.0 # Major Axis
verticleRadius = height / 2.0 # Minor Axis
# Calculate a close approximation of the ellipse's circumference
# https://www.mathsisfun.com/geometry/ellipse-perimeter.html ~ "Approximation 2"
return Math.PI * (3 * (horizontalRadius + verticleRadius) - Math.sqrt((3 * horizontalRadius + verticleRadius) * (horizontalRadius + 3 * verticleRadius)))
getSpacedOvalPoints
#def getSpacedOvalPoints(self, pointCount, centerX, centerY, width, height, degreeOffset=0):
from java.lang import Math
radiusWidth = width / 2
radiusHeight = height / 2
# java.lang.Math trigonometric functions requires radians
startAngle = Math.toRadians(degreeOffset) # ...or (3.14 * degreeOffset) / 180 to eliminate the import
# Set the end angle to 360 degrees more than the starting angle
endAngle = startAngle + (2 * Math.PI) # This could also be written startAngle + Math.toRadians(360)
# Abort the operation if there aren't at least two points to avoid a potential division error and other wierdness
if pointCount <= 1:
yield centerX, centerY
return
# Iterate through each point, and yield a coordinate set for the current angle and distance from origin
angleIncrement = (endAngle - startAngle) / float(pointCount - 1)
for point in xrange(pointCount):
angle = startAngle + (point * angleIncrement)
yield ( # Polar-to-Cartesian conversions
int(centerX + Math.cos(angle) * radiusWidth),
int(centerY - Math.sin(angle) * radiusHeight))
paintCoffeeCup
#def paintCoffeeCup(self, graphics, x, y, width, height):
from java.awt import BasicStroke
color = system.gui.color
rimWidth = 4
cupTopHeight = 40
cupX = cupY = 0
cupWidth = 140
cupHeight = 180
cupID = cupWidth - (2 * rimWidth)
cirumference = int(3.14 * cupWidth)
# Allow the cup to be resized, but for simplicity,
# ...just scale the original development size to the given size
originalTransform = graphics.getTransform() # ...so the scaling and origin can be restored after the coffee cup is painted
graphics.translate(x, y) # Translate the disired x and y to origin, so the cup doesn't move when scaled
graphics.scale(float(width) / cupWidth, float(height) / cupHeight)
lightRGB = 240
shadowRGB = 180
grey = color('lightgrey')
darkGrey = color(120,120,120)
coffee = color(75,45,25)
shadow = color(shadowRGB, shadowRGB, shadowRGB)
highlight = color(lightRGB, lightRGB ,lightRGB)
# Handle
handleThickness = 18
handleX = cupX + cupWidth - 10
handleY = cupY + 45
handleWidth = 55
handleHeight = 85
for strokeWidth in xrange(handleThickness, 0, -1):
shadowPercentage = float(strokeWidth) / handleThickness
adjustedRGB = lightRGB - int((lightRGB - shadowRGB) * shadowPercentage)
alpha = 255 if strokeWidth == handleThickness else 10
graphics.color = color(adjustedRGB, adjustedRGB, adjustedRGB, alpha)
graphics.stroke = BasicStroke(strokeWidth)
graphics.drawOval(handleX - 20, handleY, handleWidth, handleHeight)
# Added to eliminate aliasing at large size,
# ...and to smooth out the bottom after cup highlighting
graphics.color = grey
graphics.fillRect(cupX, cupY + (cupTopHeight / 2), cupWidth, cupHeight - cupTopHeight)
graphics.fillOval(cupX, cupY + cupHeight - cupTopHeight + 1, cupWidth, cupTopHeight)
# Create the body of the cup with vertical lines in a light to shadow arc
centerX = cupX + (cupWidth / 2)
centerY = cupY + (cupTopHeight / 2)
for index, (x, y) in enumerate(self.getSpacedOvalPoints(cirumference, centerX, centerY, cupWidth, cupTopHeight)):
if index > cirumference / 2:
shadowPercentage = float(index - cirumference / 2) / (cirumference / 2)
adjustedRGB = lightRGB - int((lightRGB - shadowRGB) * shadowPercentage)
graphics.color = color(adjustedRGB, adjustedRGB, adjustedRGB)
graphics.drawLine(x, y, x, y + cupHeight - cupTopHeight)
# Inside visible part of the cup
graphics.fillOval(cupX + (rimWidth / 2), cupY, cupWidth - rimWidth, cupTopHeight)
# Cup Rim
graphics.stroke = BasicStroke(rimWidth)
graphics.color = grey
graphics.drawOval(cupX + (rimWidth / 2), cupY, cupWidth - rimWidth, cupTopHeight)
# Coffee Surface
graphics.color = coffee
liquidInset = 6
levelDrop = -8
graphics.fillOval(cupX + liquidInset, cupY - levelDrop, cupWidth - (2 * liquidInset), cupTopHeight - (liquidInset + rimWidth))
# Coffee spot
spotX = 30
spotY = 12
spotWidth = 30
spotHeight = 8
graphics.color = color(130,90,60)
graphics.fillOval(cupX + spotX, cupY + spotY, spotWidth, spotHeight)
# Steam - S-shaped curls
steamTopY = cupY - 18
curlSpacing = curlOneX = cupID / 4
curlTwoX = curlOneX + curlSpacing
curlThreeX = curlTwoX + curlSpacing
steamHeight = 28
steamWidth = 22
steamThickness = 6
graphics.color = color(130,90,60, 50).brighter() #highlight
graphics.stroke = BasicStroke(steamThickness)
for steamX in [cupX + curlOneX, cupX + curlTwoX, cupX + curlThreeX]:
# top curve
graphics.drawArc(steamX, steamTopY - 22, steamWidth, steamHeight, 270, 140)
# bottom curve
graphics.drawArc(steamX, steamTopY + steamThickness, steamWidth, steamHeight, 114, 140)
# Restore original graphics positioning and painting parameters
graphics.setTransform(originalTransform)