Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ tags
# This project
Lib/blackrenderer/_version.py
Tests/tmpOutput/
Tests/expectedOutput/*_chrome.png
Tests/expectedOutput/*_otcanvas.png
Tests/expectedOutput/*_otsvg.png
Vault/
3 changes: 3 additions & 0 deletions Lib/blackrenderer/backends/cairo.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ def drawPathSweepGradient(
useGouraudShading=False,
extendMode=extendMode,
)
if not patches:
self.context.restore()
return
for (P0, color0), C0, C1, (P1, color1) in patches:
# draw patch
pat.begin_patch()
Expand Down
82 changes: 51 additions & 31 deletions Lib/blackrenderer/backends/coregraphics.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
from contextlib import contextmanager
from math import ceil, sqrt
from math import ceil, radians, sqrt
import os
from fontTools.pens.basePen import BasePen
from fontTools.ttLib.tables.otTables import CompositeMode, ExtendMode
from CoreFoundation import CFDataCreateMutable
import Quartz as CG
from .base import Canvas, Surface
from .sweepGradient import buildSweepGradientPatches
from .sweepGradient import buildSweepGradientPatches, normalizeSweepColorLineAndAngles

_compositeModeMap = {
CompositeMode.CLEAR: CG.kCGBlendModeClear,
CompositeMode.SRC: CG.kCGBlendModeCopy,
CompositeMode.DEST: CG.kCGBlendModeNormal, # This is wrong, but is worked around in canvas.compositeMode()
# This is wrong, but is worked around in canvas.compositeMode().
CompositeMode.DEST: CG.kCGBlendModeNormal,
CompositeMode.SRC_OVER: CG.kCGBlendModeNormal,
CompositeMode.DEST_OVER: CG.kCGBlendModeDestinationOver,
CompositeMode.SRC_IN: CG.kCGBlendModeSourceIn,
Expand Down Expand Up @@ -216,36 +217,55 @@ def drawPathSweepGradient(
CG.CGContextClip(self.context)
# else: unbounded source, paint the existing clip area
self.transform(gradientTransform)
# find current path' extent
(x1, y1), (w, h) = CG.CGContextGetClipBoundingBox(self.context)
x2 = x1 + w
y2 = y1 + h
maxX = max(d * d for d in (x1 - center[0], x2 - center[0]))
maxY = max(d * d for d in (y1 - center[1], y2 - center[1]))
R = sqrt(maxX + maxY)
# compute the triangle fan approximating the sweep gradient
patches = buildSweepGradientPatches(
colorLine,
center,
R,
startAngle,
endAngle,
useGouraudShading=True,
extendMode=extendMode,
colorLine, startAngle, endAngle = normalizeSweepColorLineAndAngles(
colorLine, startAngle, endAngle, extendMode
)
CG.CGContextBeginTransparencyLayer(self.context, None)
CG.CGContextSetAllowsAntialiasing(self.context, False)
for (P0, color0), (P1, color1) in patches:
color = 0.5 * (color0 + color1)
CG.CGContextMoveToPoint(self.context, center[0], center[1])
CG.CGContextAddLineToPoint(self.context, P0[0], P0[1])
CG.CGContextAddLineToPoint(self.context, P1[0], P1[1])
CG.CGContextSetFillColorWithColor(
self.context, CG.CGColorCreate(_sRGBColorSpace, color)
if not colorLine:
return
if hasattr(CG, "CGContextDrawConicGradient"):
colors, stops = _unpackColorLine(colorLine)
gradient = CG.CGGradientCreateWithColors(
_sRGBColorSpace, colors, stops
)
CG.CGContextFillPath(self.context)
CG.CGContextSetAllowsAntialiasing(self.context, True)
CG.CGContextEndTransparencyLayer(self.context)
CG.CGContextDrawConicGradient(
self.context, gradient, center, radians(startAngle)
)
else:
self._drawPathSweepGradientWithPatches(
colorLine, center, startAngle, endAngle
)

def _drawPathSweepGradientWithPatches(self, colorLine, center, startAngle, endAngle):
(x1, y1), (w, h) = CG.CGContextGetClipBoundingBox(self.context)
x2 = x1 + w
y2 = y1 + h
maxX = max(d * d for d in (x1 - center[0], x2 - center[0]))
maxY = max(d * d for d in (y1 - center[1], y2 - center[1]))
R = sqrt(maxX + maxY)
patches = buildSweepGradientPatches(
colorLine,
center,
R,
startAngle,
endAngle,
useGouraudShading=True,
)
if not patches:
return
CG.CGContextBeginTransparencyLayer(self.context, None)
CG.CGContextSetBlendMode(self.context, CG.kCGBlendModeCopy)
CG.CGContextSetAllowsAntialiasing(self.context, False)
for (P0, color0), (P1, color1) in patches:
color = 0.5 * (color0 + color1)
CG.CGContextMoveToPoint(self.context, center[0], center[1])
CG.CGContextAddLineToPoint(self.context, P0[0], P0[1])
CG.CGContextAddLineToPoint(self.context, P1[0], P1[1])
CG.CGContextSetFillColorWithColor(
self.context, CG.CGColorCreate(_sRGBColorSpace, color)
)
CG.CGContextFillPath(self.context)
CG.CGContextSetAllowsAntialiasing(self.context, True)
CG.CGContextEndTransparencyLayer(self.context)

def _shouldNotDrawPath(self, path):
return self.clipIsEmpty or (
Expand Down
2 changes: 1 addition & 1 deletion Lib/blackrenderer/backends/pathCollector.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def transform(self, transform):
self.currentTransform = self.currentTransform.transform(transform)

def clipPath(self, path):
self._addPath(path)
pass

def drawPathSolid(self, path, color):
self._addPath(path)
Expand Down
6 changes: 4 additions & 2 deletions Lib/blackrenderer/backends/skia.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,10 @@ def drawPathSweepGradient(
gradientTransform,
):
colorLine, startAngle, endAngle = normalizeSweepColorLineAndAngles(
colorLine, startAngle, endAngle
colorLine, startAngle, endAngle, extendMode
)
if not colorLine:
return
matrix = skia.Matrix()
matrix.setAffine(gradientTransform)
colors, stops = _unpackColorLine(colorLine)
Expand All @@ -169,7 +171,7 @@ def drawPathSweepGradient(
cy=center[1],
colors=colors,
positions=stops,
mode=_extendModeMap[extendMode],
mode=skia.TileMode.kClamp,
startAngle=startAngle,
endAngle=endAngle,
localMatrix=matrix,
Expand Down
2 changes: 1 addition & 1 deletion Lib/blackrenderer/backends/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def _addElement(self, fillPath, fillTransform, paint, gradientTransform):
if len(self.clipStack) > 1:
# FIXME: intersect clip paths with pathops
self._warn(
"nested_clip"
"nested_clip",
"SVG canvas does not support more than two nested clip paths"
)
if clipTransform is not None:
Expand Down
141 changes: 71 additions & 70 deletions Lib/blackrenderer/backends/sweepGradient.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from math import pi, ceil, floor, sin, cos, radians
from math import pi, ceil, floor, sin, cos, radians, isclose
from fontTools.misc.vector import Vector
from fontTools.ttLib.tables.otTables import ExtendMode

Expand Down Expand Up @@ -34,16 +34,10 @@ def buildSweepGradientPatches(
360° circle."""

colorLine, startAngle, endAngle = normalizeSweepColorLineAndAngles(
colorLine, startAngle, endAngle
colorLine, startAngle, endAngle, extendMode
)

angleRange = endAngle - startAngle

# Extend the color line to cover the full 360° circle if needed
if extendMode is not None and angleRange < 360:
colorLine, startAngle, endAngle = _extendColorLineForFullCircle(
colorLine, startAngle, endAngle, angleRange, extendMode
)
if not colorLine:
return []

return _buildPatches(
colorLine,
Expand All @@ -56,77 +50,84 @@ def buildSweepGradientPatches(
)


def normalizeSweepColorLineAndAngles(colorLine, startAngle, endAngle):
def normalizeSweepColorLineAndAngles(colorLine, startAngle, endAngle, extendMode=None):
colorLine = _normalizeCoincidentStops(colorLine, extendMode)
if not colorLine:
return [], 0, 0

if _anglesCoincident(startAngle, endAngle):
if extendMode in (ExtendMode.REPEAT, ExtendMode.REFLECT):
return [], 0, 0
if extendMode == ExtendMode.PAD:
return _padColorLineForCoincidentAngles(colorLine, startAngle), 0, 360

# When endAngle < startAngle, the sweep covers the arc going clockwise.
# Our backends consume increasing angles, so swap angles and reverse the
# color line to draw the same color rays.
if endAngle < startAngle:
startAngle, endAngle = endAngle, startAngle
colorLine = [(1.0 - stop, color) for stop, color in reversed(colorLine)]

# Normalize angles to [0, 360) range and ensure startAngle < endAngle.
startAngle %= 360
endAngle %= 360
if startAngle >= endAngle:
endAngle += 360
if extendMode is not None:
colorLine = _colorLineForDrawingTurn(
colorLine, startAngle, endAngle, extendMode
)
return colorLine, 0, 360

return colorLine, startAngle, endAngle


def _extendColorLineForFullCircle(
colorLine, startAngle, endAngle, angleRange, extendMode
):
"""Extend the color line to cover a full 360° circle based on the extend mode.

Returns (newColorLine, newStartAngle, newEndAngle).
"""
newColorLine = []
for angle0, angle1, wrapOffset in _splitSweepSegments(
startAngle, endAngle, extendMode
):
if angle1 <= angle0:
continue

t0 = (angle0 + wrapOffset - startAngle) / angleRange
t1 = (angle1 + wrapOffset - startAngle) / angleRange
segmentSamples = _extendedSegmentSamples(colorLine, t0, t1, extendMode)

for t, color in segmentSamples:
angle = startAngle + t * angleRange - wrapOffset
if angle0 <= angle <= angle1:
newColorLine.append((angle / 360.0, color))

newColorLine.sort(key=lambda item: item[0])
return newColorLine, 0, 360


def _splitSweepSegments(startAngle, endAngle, extendMode):
"""Return one-turn angle segments with the correct shader t mapping.

Sweep shader angles live in the 0°..360° turn. If the sweep crosses the
angle wrap, angles from 0° to endAngle % 360 are part of the in-range
sector and must be evaluated with a +360° offset. Angles between the
wrapped end and the start are before the start, not after the end.
"""
if endAngle <= 360:
return (
(0, startAngle, 0),
(startAngle, endAngle, 0),
(endAngle, 360, 0),
)

if extendMode == ExtendMode.PAD:
return (
(0, startAngle, 0),
(startAngle, 360, 0),
)

wrappedEnd = endAngle - 360
return (
(0, wrappedEnd, 360),
(wrappedEnd, startAngle, 0),
(startAngle, 360, 0),
)
def _normalizeCoincidentStops(colorLine, extendMode):
if not colorLine:
return []
if not all(isclose(stop, colorLine[0][0], abs_tol=1e-9) for stop, _ in colorLine):
return colorLine
if extendMode in (ExtendMode.REPEAT, ExtendMode.REFLECT):
return []
if extendMode != ExtendMode.PAD:
return colorLine

offset = max(0, min(1, colorLine[0][0]))
firstColor = colorLine[0][1]
lastColor = colorLine[-1][1]
return [
(0, firstColor),
(offset, firstColor),
(offset, lastColor),
(1, lastColor),
]


def _anglesCoincident(startAngle, endAngle):
return isclose(startAngle, endAngle, abs_tol=1e-9)


def _padColorLineForCoincidentAngles(colorLine, startAngle):
angle = (startAngle % 360) / 360.0
firstColor = colorLine[0][1]
lastColor = colorLine[-1][1]
return [
(0, firstColor),
(angle, firstColor),
(angle, lastColor),
(1, lastColor),
]


def _colorLineForDrawingTurn(colorLine, startAngle, endAngle, extendMode):
angleRange = endAngle - startAngle
if angleRange <= 0:
return []

t0 = (0 - startAngle) / angleRange
t1 = (360 - startAngle) / angleRange
samples = _extendedSegmentSamples(colorLine, t0, t1, extendMode)
colorLine = [
((startAngle + t * angleRange) / 360.0, color)
for t, color in samples
]
colorLine.sort(key=lambda item: item[0])
return colorLine


def _extendedSegmentSamples(colorLine, t0, t1, extendMode):
Expand Down
44 changes: 34 additions & 10 deletions Lib/blackrenderer/font.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,9 @@ def colrV1GlyphNames(self):
def getGlyphBounds(self, glyphName):
if glyphName in self.colrV1Glyphs:
bounds = self._getGlyphBounds(glyphName)
if self.clipBoxes is not None:
box = self.clipBoxes.get(glyphName)
if box is not None:
if (
box.Format == ClipBoxFormat.Variable
and self.instancer is not None
):
box = VarTableWrapper(box, self.instancer, self.varIndexMap)
bounds = box.xMin, box.yMin, box.xMax, box.yMax
box = self._getClipBox(glyphName)
if box is not None:
bounds = box.xMin, box.yMin, box.xMax, box.yMax
elif glyphName in self.colrV0Glyphs:
# For COLRv0, we take the union of all layer bounds
bounds = None
Expand Down Expand Up @@ -187,7 +181,8 @@ def _drawGlyphCOLRv1(self, glyph, canvas):
raise RecursionError(f"Glyph '{glyph.BaseGlyph}' references itself")
self._recursionCheck.add(glyph.BaseGlyph)
try:
self._drawPaint(glyph.Paint, canvas)
with self._ensureClipBox(glyph.BaseGlyph, canvas):
self._drawPaint(glyph.Paint, canvas)
finally:
self._recursionCheck.remove(glyph.BaseGlyph)

Expand Down Expand Up @@ -409,6 +404,35 @@ def _ensureClipAndPushPath(self, canvas, path):
self.currentPath = currentPath
self.currentTransform = currentTransform

@contextmanager
def _ensureClipBox(self, glyphName, canvas):
box = self._getClipBox(glyphName)
if box is None:
yield
return

path = canvas.newPath()
path.moveTo((box.xMin, box.yMin))
path.lineTo((box.xMin, box.yMax))
path.lineTo((box.xMax, box.yMax))
path.lineTo((box.xMax, box.yMin))
path.closePath()
with canvas.savedState():
canvas.clipPath(path)
yield

def _getClipBox(self, glyphName):
if self.clipBoxes is None:
return None
box = self.clipBoxes.get(glyphName)
if (
box is not None
and box.Format == ClipBoxFormat.Variable
and self.instancer is not None
):
box = VarTableWrapper(box, self.instancer, self.varIndexMap)
return box

@contextmanager
def _savedTransform(self):
savedTransform = self.currentTransform
Expand Down
Loading
Loading