Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b872fe3
replace monolithic setup.py with pyproject.toml + scripts
samizdatco Jul 9, 2026
19809d0
bump minimum python version to 3.9
samizdatco Jul 9, 2026
3256a87
publish wheels containing the native extension
samizdatco Jul 9, 2026
18f985c
reflect test results in exit code
samizdatco Jul 9, 2026
d486a29
test for presence of pyobjc+extension, not just paths
samizdatco Jul 9, 2026
cafc691
fix regex escape
samizdatco Jul 10, 2026
62e9ca2
fix objc.super use in ConsoleScript
samizdatco Jul 10, 2026
665bc57
fix `ordered()` crash on grobs and silent dotted-path failures
samizdatco Jul 10, 2026
3f69b4c
add python 3.14 support by bumping pyobjc to 11.1
samizdatco Jul 10, 2026
3639d49
ignore rsrc files resulting from in-place build
samizdatco Jul 10, 2026
dc2ac7f
generate MANIFEST.in only when needed for sdist
samizdatco Jul 10, 2026
cb696af
rename build.py to make.py (to prevent `-m build` shadowing)
samizdatco Jul 10, 2026
46052ee
opt into secure restorable state
samizdatco Jul 10, 2026
ddab30c
update embedded python to 3.14.6
samizdatco Jul 10, 2026
f8ca3c8
update python version compat
samizdatco Jul 10, 2026
7df6d3b
update 1.0.1 docs
samizdatco Jul 11, 2026
ef7093a
bump copyright year
samizdatco Jul 11, 2026
2f58879
fix typos
samizdatco Jul 11, 2026
e0e6d59
add missing 3.14 classifier
samizdatco Jul 11, 2026
00d7e22
use system fonts in typography tests
samizdatco Jul 11, 2026
6363a2a
fixed Text.flow() when invoked w/ callback
samizdatco Jul 11, 2026
30f574f
fix the test for blend-only effects
samizdatco Jul 11, 2026
e4b666a
fix MagicNumber's numeric-protocol implementation
samizdatco Jul 11, 2026
41556e2
fix rasterization of clipping masks based on vector images
samizdatco Jul 13, 2026
318b8d9
add clip() test cases that use canvas transforms
samizdatco Jul 13, 2026
e5b8d1b
replace deprecated WebView with WKWebView
samizdatco Jul 13, 2026
79b7051
update document contents after each edit
samizdatco Jul 13, 2026
bbff6e9
fix license format
samizdatco Jul 13, 2026
74fc923
rename `sync_edits` and `flash_menu` methods
samizdatco Jul 13, 2026
293f85a
add new default editor theme
samizdatco Jul 13, 2026
5280134
add `Assets.car` for macOS 26+ icon
samizdatco Jul 14, 2026
43e2599
Replace setup.py with direct call to tests
samizdatco Jul 14, 2026
cf15d6e
handle quadratic curves in `path_to_polygon`
samizdatco Jul 14, 2026
8315113
Add logo banner to readme
samizdatco Jul 14, 2026
0226c2b
Fix deprecation warning from pyobjc
samizdatco Jul 14, 2026
2e5635e
update module providing UTType*
samizdatco Jul 14, 2026
a119caf
set exit code on script errors when running `plotdevice` headless
samizdatco Jul 14, 2026
8338229
replace `xrange` with `range` in code samples
samizdatco Jul 14, 2026
1773ce0
update markdown docs
samizdatco Jul 14, 2026
ac940c6
fix crash when canvas is zoomed before script has rendered
samizdatco Jul 15, 2026
73471c5
upgrade vendored ace.js to 1.44.0
samizdatco Jul 13, 2026
40164de
local ace modification: filter autocompletions
samizdatco Jul 14, 2026
3846f3a
local ace modification: fix snippet formatting
samizdatco Jul 15, 2026
8ca98b3
rough draft: add command-click documentation lookups
samizdatco Jul 14, 2026
f68126d
Add links to the documentation for all known symbols
samizdatco Jul 14, 2026
81318c4
add usage samples
samizdatco Jul 14, 2026
41ca645
reformat
samizdatco Jul 14, 2026
4daf9a3
ensure only actual function tokens have clickable docs
samizdatco Jul 14, 2026
87f9178
Add tooltips with syntax reference for plotdevice symbols
samizdatco Jul 15, 2026
b22781b
remove command-click docs (but keep tooltip links)
samizdatco Jul 15, 2026
a94fec7
add context menu for opening docs
samizdatco Jul 15, 2026
97474d7
add () to token name in View Docs context menu
samizdatco Jul 15, 2026
e53ec91
clean up usage strings
samizdatco Jul 15, 2026
86cedbf
fix refreshdoc command mishandling an empty source string
samizdatco Jul 15, 2026
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
Prev Previous commit
Next Next commit
fix rasterization of clipping masks based on vector images
- previously the vector would be rasterized at the width/height of the PDF or SVG, then that bitmap mask would be scaled
- now the vector image is scaled to the size it will occupy on the canvas *before* being rasterized, so the masking is still pixel-based, but will no longer be super-low res
- partially addresses #83 (though it doesn't provide the vector-based mask the user exptected)
  • Loading branch information
samizdatco committed Jul 13, 2026
commit 41556e2bd82509ee3470ebffefa400ac23e1e55a
2 changes: 1 addition & 1 deletion plotdevice/gfx/effects.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ def set(self):
singlechannel = ciFilter(self.channel, self.bmp._ciImage)
greyscale = ciFilter(self.invert, singlechannel)
ci_ctx = CIContext.contextWithOptions_(None)
maskRef = ci_ctx.createCGImage_fromRect_(greyscale, ((0,0), self.bmp.size))
maskRef = ci_ctx.createCGImage_fromRect_(greyscale, ((0,0), self.bmp._screen_size))

# turn the image into an ‘imagemask’ cg-image
cg_mask = CGImageMaskCreate(CGImageGetWidth(maskRef),
Expand Down
61 changes: 55 additions & 6 deletions plotdevice/gfx/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from .geometry import Region, Size, Point, Transform, CENTER
from .atoms import TransformMixin, EffectsMixin, FrameMixin, Grob
from .colors import CMYK
from . import _ns_context
from . import _ns_context, _cg_port

_ctx = None
__all__ = ("Image", 'ImageWriter')
Expand Down Expand Up @@ -174,21 +174,48 @@ def _nsBitmap(self):
if isinstance(bitmap, NSBitmapImageRep):
break
else:
# ...otherwise convert the vector image to a bitmap
# (note that this should use _screen_transform somehow but currently doesn't)
# ...otherwise convert the vector image to a bitmap at its native size.
# it will only be used for a hasAlpha() check in effects.py, so the actual
# pixel data needn't match its _screen_size
tiffdata = self._nsImage.TIFFRepresentation()
image = NSImage.alloc().initWithData_(tiffdata)
bitmap = image.representations()[0]
return bitmap

@property
def _ciImage(self):
"""A CIImage suitable for use as a clip()/mask() stencil.

Raster sources are used as-is. Vector sources are scaled to the size
they'll actually occupy on the canvas before being rasterized.
"""

# if the image is raster-based, use its bitmap as-is
for bitmap in self._nsImage.representations():
if isinstance(bitmap, NSBitmapImageRep):
break
else:
# if vector-based, rasterize it at its 'actual' on-screen size
w, h = self._screen_size

# create an offscreen bitmap context and rasterize the scaled image into it
bitmap_opts = kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host
bitmap_ctx = CGBitmapContextCreate(None, w, h, 8, w*4, CGColorSpaceCreateDeviceRGB(), bitmap_opts)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.setCurrentContext_(
NSGraphicsContext.graphicsContextWithCGContext_flipped_(bitmap_ctx, True)
)
self._nsImage.drawInRect_fromRect_operation_fraction_(((0,0),(w,h)), NSZeroRect, NSCompositeSourceOver, 1.0)
NSGraphicsContext.restoreGraphicsState() # NB: class-level restore also resets currentContext

bitmap = NSBitmapImageRep.alloc().initWithCGImage_(CGBitmapContextCreateImage(bitmap_ctx))

# core-image needs to be told to compensate for our flipped coords
flip = NSAffineTransform.transform()
flip.translateXBy_yBy_(0, self.size.height)
flip.translateXBy_yBy_(0, bitmap.pixelsHigh())
flip.scaleXBy_yBy_(1,-1)

ciImage = CIImage.alloc().initWithBitmapImageRep_(self._nsBitmap)
ciImage = CIImage.alloc().initWithBitmapImageRep_(bitmap)
transform = CIFilter.filterWithName_("CIAffineTransform")
transform.setValue_forKey_(ciImage, "inputImage")
transform.setValue_forKey_(flip, "inputTransform")
Expand Down Expand Up @@ -249,6 +276,29 @@ def _screen_transform(self):
xf.scale(factor) # scale to fit size constraints (if any)
return xf

@property
def _screen_size(self):
"""Returns the image's real pixel dimensions.

Raster-backed images return their native size, but vector-based images incorporate
the canvas's current CTM and the image's scaling based on its _screen_transform."""

# if the image is raster-based, use its native size
for bitmap in self._nsImage.representations():
if isinstance(bitmap, NSBitmapImageRep):
return bitmap.pixelsWide(), bitmap.pixelsHigh()

# if vector-based, use the canvas's CTM and the image's _screen_transform to
# determine its 'actual' size (rather than just using its mediabox 1:1)
ctm = CGContextGetCTM(_cg_port())
ctm_scale = max(math.hypot(ctm.a, ctm.b), math.hypot(ctm.c, ctm.d))
xf_a, xf_b, xf_c, xf_d, _, _ = self._screen_transform.matrix
xf_scale = max(math.hypot(xf_a, xf_b), math.hypot(xf_c, xf_d))
scale = max(ctm_scale * xf_scale, 1.0)
src_w, src_h = self._nsImage.size()

return max(1, round(src_w * scale)), max(1, round(src_h * scale))

def _draw(self):
"""Draw an image on the given coordinates."""

Expand Down Expand Up @@ -390,4 +440,3 @@ def finish(self):
if self.session.writer.doneWriting():
break
time.sleep(0.1)

5 changes: 3 additions & 2 deletions plotdevice/lib/cocoa.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
CGColorSpaceCreateDeviceCMYK, CGColorSpaceCreateDeviceRGB, CGContextAddPath, CGContextAddRect, \
CGContextBeginPath, CGContextBeginTransparencyLayer, CGContextBeginTransparencyLayerWithRect, \
CGContextClearRect, CGContextClearRect, CGContextClip, CGContextClipToMask, CGContextDrawPath, \
CGContextEndTransparencyLayer, CGContextEOClip, CGContextRestoreGState, CGContextSaveGState, \
CGContextEndTransparencyLayer, CGContextEOClip, CGContextGetCTM, CGContextRestoreGState, CGContextSaveGState, \
CGContextSetAlpha, CGContextSetBlendMode, CGContextSetFillColorWithColor, CGContextSetLineCap, \
CGContextSetLineDash, CGContextSetLineJoin, CGContextSetLineWidth, \
CGContextSetStrokeColorWithColor, CGDataConsumerCreateWithCFData, CGImageDestinationAddImage, \
Expand Down Expand Up @@ -56,7 +56,8 @@
NSIntersectionRect, NSLocale, NSLog, NSMacOSRomanStringEncoding, NSMakeRange, NSMidX, NSMidY, \
NSMutableAttributedString, NSMutableData, NSNotificationCenter, NSObject, NSOffsetRect, \
NSOperationQueue, NSPoint, NSRect, NSRectFromString, NSSelectorFromString, NSSize, NSString, \
NSStringFromRect, NSTimer, NSTimeZone, NSURL, NSUserDefaults, NSUTF8StringEncoding, NSWidth
NSStringFromRect, NSTimer, NSTimeZone, NSURL, NSUserDefaults, NSUTF8StringEncoding, NSWidth, \
NSZeroRect
from LaunchServices import kUTTypePNG, kUTTypeJPEG, kUTTypeGIF, kUTTypeTIFF
from WebKit import WebView
from objc import IBOutlet, IBAction
Binary file added tests/_in/tiny-triangle.pdf
Binary file not shown.
Binary file added tests/_ref/compositing/clip-vector-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/_ref/compositing/mask-vector-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions tests/compositing.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ def test_clip_text(self):
with clip(text('Hi', 5, 100)):
image('tests/_in/plaid.png')

@reference('compositing/clip-vector-image.png')
def test_clip_image_vector(self):
size(125, 125)
# ensure tiny PDF is scaled up before rasterizing into a larger mask
with clip(image('tests/_in/tiny-triangle.pdf', width=125, height=125)):
image('tests/_in/plaid.png')

@reference('compositing/mask.png')
def test_mask(self):
# ref/Compositing/commands/mask()
Expand All @@ -64,6 +71,13 @@ def test_mask_text(self):
with mask(text('Hi', 5, 100)):
image('tests/_in/plaid.png')

@reference('compositing/mask-vector-image.png')
def test_mask_image_vector(self):
size(125, 125)
# ensure tiny PDF is scaled up before rasterizing into a larger mask
with mask(image('tests/_in/tiny-triangle.pdf', width=125, height=125)):
image('tests/_in/plaid.png')

@reference('compositing/shadow-multi.png')
def test_shadow_multi(self):
# ref/Compositing/commands/shadow()
Expand Down