Use text-based 16x16 icons
[MacVim.git] / src / MacVim / icons / docerator.py
blobef20ee2e7e3c89a46b8c23be563a0e7dadbef003
1 from Foundation import *
2 from AppKit import *
4 import itertools
5 import math
6 import operator
7 import os
9 from optparse import OptionParser
12 # Resources
13 DEFAULT_BACKGROUND = '/System/Library/CoreServices/CoreTypes.bundle/' + \
14 'Contents/Resources/GenericDocumentIcon.icns' # might require leopard?
17 # Cache both images and background renderers globally
18 imageCache = {}
19 bgCache = {}
22 # Make us not crash
23 # http://www.cocoabuilder.com/archive/message/cocoa/2008/8/6/214964
24 NSApplicationLoad()
27 class Surface(object):
28 """Represents a simple bitmapped image."""
30 def __init__(self, *p, **kw):
31 if not 'premultiplyAlpha' in kw:
32 kw['premultiplyAlpha'] = True
33 if len(p) == 1 and isinstance(p[0], NSBitmapImageRep):
34 self.bitmapRep = p[0]
35 elif len(p) == 2 and isinstance(p[0], int) and isinstance(p[1], int):
36 format = NSAlphaFirstBitmapFormat
37 if not kw['premultiplyAlpha']:
38 format += NSAlphaNonpremultipliedBitmapFormat
39 self.bitmapRep = NSBitmapImageRep.alloc().initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bitmapFormat_bytesPerRow_bitsPerPixel_(
40 None, p[0], p[1], 8, 4, True, False, NSDeviceRGBColorSpace,
41 format, 0, 0)
43 if not hasattr(self, 'bitmapRep') or not self.bitmapRep:
44 raise Exception('Failed to create surface: ' + str(p))
46 def size(self):
47 return map(int, self.bitmapRep.size()) # cocoa returns floats. cocoa ftw
49 def data(self):
50 """Returns data in ARGB order (on intel, at least)."""
51 r = self.bitmapRep
52 if r.bitmapFormat() != (NSAlphaNonpremultipliedBitmapFormat |
53 NSAlphaFirstBitmapFormat) or \
54 r.bitsPerPixel() != 32 or \
55 r.isPlanar() or \
56 r.samplesPerPixel() != 4:
57 raise Exception("Unsupported image format")
58 return self.bitmapRep.bitmapData()
60 def save(self, filename):
61 """Saves image as png file."""
62 self.bitmapRep.representationUsingType_properties_(NSPNGFileType, None) \
63 .writeToFile_atomically_(filename, True)
65 def draw(self):
66 self.bitmapRep.draw()
68 def context(self):
69 # Note: Cocoa only supports contexts with premultiplied alpha
70 return NSGraphicsContext.graphicsContextWithBitmapImageRep_(self.bitmapRep)
72 def copy(self):
73 return Surface(self.bitmapRep.copy())
76 class Image(object):
77 """Represents an image that can consist of several Surfaces."""
79 def __init__(self, param):
80 if isinstance(param, str):
81 self.image = NSImage.alloc().initWithContentsOfFile_(param)
82 elif isinstance(param, Surface):
83 self.image = NSImage.alloc().initWithSize_( param.size() )
84 self.image.addRepresentation_(param.bitmapRep)
86 if not self.image:
87 raise Exception('Failed to load image: ' + str(param))
89 def surfaceOfSize(self, w, h):
90 """Returns an ARGB, non-premultiplied surface of size w*h or throws."""
91 r = None
92 for rep in self.image.representations():
93 # Cocoa reports fraction widths for pngs (wtf?!), so use round()
94 if map(lambda x: int(round(x)), rep.size()) == [w, h]:
95 r = rep
96 break
98 # XXX: Resample in this case? That'd make the program easier to use, but
99 # can silently create blurry backgrounds. Since this happens with
100 # the app icon anyways, this might not be a huge deal?
101 if not r:
102 raise Exception('Unsupported size %dx%d', w, h)
103 return Surface(r)
105 def blend(self):
106 self.compositeInRect( ((0, 0), self.image.size()) )
108 def compositeInRect(self, r, mode=NSCompositeSourceOver):
109 self.image.drawInRect_fromRect_operation_fraction_(r, NSZeroRect,
110 mode, 1.0)
112 def sizes(self):
113 s = set()
114 for rep in self.image.representations():
115 s.add(tuple(map(lambda x: int(round(x)), rep.size())))
116 return s
119 class Context(object):
120 # Tiger has only Python2.3, so we can't use __enter__ / __exit__ for this :-(
122 def __init__(self, surface):
123 NSGraphicsContext.saveGraphicsState()
124 c = surface.context()
125 c.setShouldAntialias_(True);
126 c.setImageInterpolation_(NSImageInterpolationHigh);
127 NSGraphicsContext.setCurrentContext_(c)
129 def done(self):
130 NSGraphicsContext.restoreGraphicsState()
133 class SplittableBackground(object):
135 def __init__(self, unsplitted, shouldSplit=True):
136 self.unsplitted = unsplitted
137 self.shouldSplit = shouldSplit
138 self.ground = {}
139 self.shadow = {}
141 def rawGroundAtSize(self, s):
142 return self.unsplitted.surfaceOfSize(s, s)
144 def groundAtSize(self, s):
145 if not self.shouldSplit:
146 return self.rawGroundAtSize(s)
147 self._performSplit(s)
148 return self.ground[s]
150 def shadowAtSize(self, s):
151 if not self.shouldSplit:
152 return None
153 self._performSplit(s)
154 return self.shadow[s]
156 def _performSplit(self, s):
157 if s in self.ground:
158 assert s in self.shadow
159 return
160 assert s not in self.shadow
161 ground, shadow = splitGenericDocumentIcon(self.unsplitted, s)
162 self.ground[s] = ground
163 self.shadow[s] = shadow
166 class BackgroundRenderer(object):
168 def __init__(self, bg, icon=None, r={}):
169 self.bgRenderer = bg
170 self.icon = icon
171 self.cache = {}
172 self.rect = r
174 def drawIcon(self, s):
175 if not self.icon:
176 return
178 assert s in [16, 32, 128, 256, 512]
179 a = list(self.rect[s])
181 # convert from `flow` coords to cocoa
182 a[2] = -a[2] # mirror y
184 w, h = s*a[1], s*a[3]
185 self.icon.compositeInRect( (((s-w)/2 + a[0], (s-h)/2 + a[2]), (w, h)) )
187 def drawAtSize(self, s):
188 if not self.icon:
189 # No need to split the background if no icons is interleaved -- take
190 # the faster code path in that case.
191 self.bgRenderer.rawGroundAtSize(s).draw()
192 return
194 self.bgRenderer.groundAtSize(s).draw()
195 self.drawIcon(s)
196 if self.bgRenderer.shouldSplit:
197 # shadow needs to be composited, so it needs to be in an image
198 Image(self.bgRenderer.shadowAtSize(s)).blend()
200 def backgroundAtSize(self, s):
201 if not s in self.cache:
202 result = Surface(s, s)
203 context = Context(result)
204 self.drawAtSize(s)
205 context.done()
206 self.cache[s] = result
207 return self.cache[s]
210 def splitGenericDocumentIcon(img, s):
211 """Takes the generic document icon and splits it into a background and a
212 shadow layer. For the 32x32 and 16x16 variants, the white pixels of the page
213 curl are hardcoded into the otherwise transparent shadow layer."""
215 w, h = s, s
216 r = img.surfaceOfSize(w, h)
217 bps = 4*w
218 data = r.data()
220 ground = Surface(w, h, premultiplyAlpha=False)
221 shadow = Surface(w, h, premultiplyAlpha=False)
223 grounddata = ground.data()
224 shadowdata = shadow.data()
226 for y in xrange(h):
227 for x in xrange(w):
228 idx = y*bps + 4*x
229 ia, ir, ig, ib = data[idx:idx + 4]
230 if ia != chr(255):
231 # buffer objects don't support slice assignment :-(
232 grounddata[idx] = ia
233 grounddata[idx + 1] = ir
234 grounddata[idx + 2] = ig
235 grounddata[idx + 3] = ib
236 shadowdata[idx] = chr(0)
237 shadowdata[idx + 1] = chr(0)
238 shadowdata[idx + 2] = chr(0)
239 shadowdata[idx + 3] = chr(0)
240 continue
242 assert ir == ig == ib
243 grounddata[idx] = chr(255)
244 grounddata[idx + 1] = chr(255)
245 grounddata[idx + 2] = chr(255)
246 grounddata[idx + 3] = chr(255)
247 shadowdata[idx] = chr(255 - ord(ir))
248 shadowdata[idx + 1] = chr(0)
249 shadowdata[idx + 2] = chr(0)
250 shadowdata[idx + 3] = chr(0)
252 # Special-case 16x16 and 32x32 cases: Make some pixels on the fold white.
253 # Ideally, I could make the fold whiteish in all variants, but I can't.
254 whitePix = { 16: [(10, 2), (10, 3), (11, 3), (10, 4), (11, 4), (12, 4)],
255 32: [(21, 4), (21, 5), (22, 5), (21, 6), (22, 6), (23, 6)]}
256 if (w, h) in [(16, 16), (32, 32)]:
257 for x, y in whitePix[w]:
258 idx = y*bps + 4*x
259 shadowdata[idx] = chr(255)
260 shadowdata[idx + 1] = chr(255)
261 shadowdata[idx + 2] = chr(255)
262 shadowdata[idx + 3] = chr(255)
264 return ground, shadow
267 class TextRenderer(object):
269 def __init__(self):
270 self.cache = {}
272 def attribsAtSize(self, s):
273 if s not in self.cache:
274 self.cache[s] = self._attribsAtSize(s)
275 return self.cache[s]
277 def centeredStyle(self):
278 style = NSMutableParagraphStyle.new()
279 style.setParagraphStyle_(NSParagraphStyle.defaultParagraphStyle())
280 style.setAlignment_(NSCenterTextAlignment)
281 return style
283 def _attribsAtSize(self, s):
284 # This looks not exactly like the font on Preview.app's document icons,
285 # but I believe that's because Preview's icons are drawn by Photoshop,
286 # and Adobe's font rendering is different from Apple's.
287 fontname = 'LucidaGrande-Bold'
289 # Prepare text format
290 fontsizes = { 512: 72.0, 256: 36.0, 128: 18.0, 32: 7.0, 16: 3.0 }
291 # http://developer.apple.com/documentation/Cocoa/Conceptual/AttributedStrings/Articles/standardAttributes.html#//apple_ref/doc/uid/TP40004903
292 attribs = {
293 NSParagraphStyleAttributeName: self.centeredStyle(),
294 NSForegroundColorAttributeName: NSColor.colorWithDeviceWhite_alpha_(
295 0.34, 1),
296 NSFontAttributeName: NSFont.fontWithName_size_(fontname, fontsizes[s])
299 # tighten font a bit for some sizes
300 if s in [256, 512]:
301 attribs[NSKernAttributeName] = -1.0
302 elif s == 32:
303 attribs[NSKernAttributeName] = -0.25
305 if not attribs[NSFontAttributeName]:
306 raise Exception('Failed to load font %s' % fontname)
307 return attribs
309 def drawTextAtSize(self, text, s):
310 """Draws text `s` into the current context of size `s`."""
312 textRects = {
313 512: ((0, 7), (512, 119)),
314 128: ((0, 6), (128, 26.5)),
315 256: ((0, 7), (256, 57)),
316 16: ((1, 1), (15, 5)),
317 #32: ((1, 1), (31, 9))
320 attribs = self.attribsAtSize(s)
321 text = NSString.stringWithString_(text)
322 if s in [16, 128, 256, 512]:
323 text.drawInRect_withAttributes_(textRects[s], attribs)
324 elif s == 32:
325 # Try to align text on pixel boundary:
326 attribs = attribs.copy()
327 attribs[NSParagraphStyleAttributeName] = \
328 NSParagraphStyle.defaultParagraphStyle()
329 ts = text.sizeWithAttributes_(attribs)
330 text.drawAtPoint_withAttributes_( (math.floor((32.0-ts[0])/2) + 0.5, 1.5),
331 attribs)
334 class OfficeTextRenderer(TextRenderer):
335 """Uses Office's LucidaSans font for 32x32.
337 This font looks much better for certain strings (e.g. "PDF") but much worse
338 for most others (e.g. "VIM", "JAVA") -- and office fonts are usually not
339 installed. Hence, this class is better not used.
342 def _attribsAtSize(self, s):
343 self.useOfficeFont = False
344 attribs = TextRenderer._attribsAtSize(self, s)
345 if s == 32:
346 font = NSFont.fontWithName_size_('LucidaSans-Demi', 7.0)
347 if font:
348 attribs[NSFontAttributeName] = font
349 attribs[NSKernAttributeName] = 0
350 self.useOfficeFont = True
351 return attribs
353 def drawTextAtSize(self, text, s):
354 attribs = self.attribsAtSize(s)
355 if not self.useOfficeFont or s != 32:
356 TextRenderer.drawTextAtSize(self, text, s)
357 return
358 text = NSString.stringWithString_(text)
359 text.drawInRect_withAttributes_( ((0, 1), (31, 11)), attribs)
362 def createIcon(s, bg, textRenderer, text):
364 # Fill in background
365 output = bg.backgroundAtSize(s).copy()
367 # Draw text on top of shadow
368 context = Context(output)
369 if s in text and text[s]:
370 textRenderer.drawTextAtSize(text[s], s)
371 context.done()
373 return output
376 def textDictFromTextList(l):
377 assert 1 <= len(l) <= 3
378 if len(l) == 1:
379 return dict.fromkeys([16, 32, 128, 256, 512], l[0])
380 elif len(l) == 2:
381 return dict(zip([16, 32], 2*[l[1]]) + zip((128, 256, 512), 3*[l[0]]))
382 elif len(l) == 3:
383 return dict([(16, l[2]), (32, l[1])] + zip((128, 256, 512), 3*[l[0]]))
386 def saveIcns(icons, icnsName, makeIcns='./makeicns'):
387 """Creates an icns file with several variants.
389 Params:
390 icons: A dict that contains icon size as key and Surface as value.
391 Valid keys are 512, 256, 128, 32, 16
392 icnsname: Name of the output file
394 # If IconFamily was less buggy, we could wrap it into a python module and
395 # call it directly, which is about a lot faster. However, IconFamily does not
396 # work with NSAlphaNonpremultipliedBitmapFormat correctly, so this has to
397 # wait.
398 #import IconFamily
399 #typeDict = {
400 #16: IconFamily.kSmall32BitData,
401 #32: IconFamily.kLarge32BitData,
402 #128: IconFamily.kThumbnail32BitData,
403 #256: IconFamily.kIconServices256PixelDataARGB,
404 #512: IconFamily.IconServices512PixelDataARGB,
406 #maskDict = {
407 #16: IconFamily.kSmall8BitMask,
408 #32: IconFamily.kLarge8BitMask,
409 #128: IconFamily.kThumbnail8BitMask,
411 #output = IconFamily.IconFamily.iconFamily()
412 #for s, icon in icons.items():
413 #output.setIconFamilyElement_fromBitmapImageRep_(typeDict[s], icon.bitmapRep)
414 #if s in maskDict:
415 #output.setIconFamilyElement_fromBitmapImageRep_(
416 #maskDict[s], icon.bitmapRep)
417 #output.writeToFile_(icnsName)
418 TMPFILE = 'docerator_tmp_%d.png'
419 try:
420 args = []
421 for s, icon in icons.items():
422 assert s in [512, 256, 128, 32, 16]
423 assert icon.size() == [s, s]
424 icon.save(TMPFILE % s)
425 args.append('-%d %s' % (s, TMPFILE % s))
426 return \
427 os.system('%s %s -out %s' % (makeIcns, ' '.join(args), icnsName)) == 0
428 finally:
429 for s in icons:
430 if os.access(TMPFILE % s, os.F_OK):
431 os.remove(TMPFILE % s)
434 def getOutname(options):
435 def saneBasename(p):
436 """ '/my/path/to/file.txt' -> 'file' """
437 return os.path.splitext(os.path.basename(p))[0]
438 textPart = 'Generic'
439 if options.text:
440 textPart = options.text.split(',')[0]
441 if options.appicon:
442 base = saneBasename(options.appicon)
443 else:
444 base = saneBasename(getBgName(options))
445 return '%s-%s.icns' % (base, textPart)
448 def cachedImage(filename):
449 absPath = os.path.abspath(filename)
450 if not absPath in imageCache:
451 imageCache[absPath] = Image(absPath)
452 return imageCache[absPath]
455 def cachedBackground(img, split):
456 key = (img, split)
457 if not key in bgCache:
458 bgCache[key] = SplittableBackground(img, shouldSplit=split)
459 return bgCache[key]
462 # taken from running flow on preview
463 defaultRects = {
464 16: (-0.30890000000000001, 0.4919, -1.2968, 0.4743),
465 32: (-0.27810000000000001,
466 0.58930000000000005,
467 -2.2292999999999998,
468 0.57140000000000002),
469 128: (1.1774, 0.56820000000000004, -0.8246, 0.56799999999999995),
470 256: (0.5917, 0.56489999999999996, -1.8994, 0.56499999999999995),
471 512: (0.68700000000000006,
472 0.56530000000000002,
473 -4.2813999999999997,
474 0.56540000000000001)
478 def getBgName(options):
479 if not hasattr(options, 'background') \
480 or options.background in ['default-split', 'default-unsplit']:
481 return DEFAULT_BACKGROUND
482 else:
483 return options.background
486 class IconGenerator(object):
487 def __init__(self, options):
488 if hasattr(options, 'textrenderer') and options.textrenderer:
489 self.textRenderer = options.textrenderer()
490 else:
491 self.textRenderer = TextRenderer()
493 # Prepare input images
494 splitBackground = options.background == 'default-split'
495 self.bgIcon = cachedImage(getBgName(options))
497 self.testIcon = None
498 if options.appicon:
499 self.testIcon = cachedImage(options.appicon)
501 rects = defaultRects.copy()
502 rects[16] = [ 0.0000, 0.5000, -1.0000, 0.5000] # manually, better
503 if hasattr(options, 'rects'):
504 rects.update(options.rects)
506 bg = cachedBackground(self.bgIcon, splitBackground)
508 if hasattr(options, 'backgroundrenderer') and options.backgroundrenderer:
509 self.bgRenderer = options.backgroundrenderer(bg, self.testIcon, rects)
510 else:
511 self.bgRenderer = BackgroundRenderer(bg, self.testIcon, rects)
513 self.testtext = textDictFromTextList(options.text.split(','))
515 def createIconAtSize(self, s):
516 return createIcon(s, self.bgRenderer, self.textRenderer, self.testtext)
519 def iconGenerator(**kwargs):
520 return IconGenerator(optsFromDict(**kwargs))
523 def makedocicon_opts(options):
524 renderer = IconGenerator(options)
526 if hasattr(options, 'sizes') and options.sizes:
527 if isinstance(options.sizes, list):
528 sizes = options.sizes
529 else:
530 sizes = map(int, options.sizes.split(','))
531 else:
532 sizes = renderer.bgIcon.sizes()
533 if renderer.testIcon:
534 sizes = sizes.intersection(renderer.testIcon.sizes())
535 sizes = sorted(map(operator.itemgetter(0), sizes))
537 icons = dict([(s, renderer.createIconAtSize(s)) for s in sizes])
539 if options.debug:
540 for s, icon in icons.iteritems():
541 icon.save(options.debug % s)
543 if hasattr(options, 'outname') and options.outname:
544 outname = options.outname
545 else:
546 outname = getOutname(options)
547 if saveIcns(icons, outname, options.makeicns):
548 print 'Wrote', outname
549 else:
550 print 'Failed to write %s. Make sure makeicns is in your path.' % outname
553 def optsFromDict(**kwargs):
554 options, _ = getopts().parse_args([]) # get default options
555 for k in kwargs:
556 setattr(options, k, kwargs[k])
557 return options
560 def makedocicon(**kwargs):
561 makedocicon_opts(optsFromDict(**kwargs))
564 def makedocicons_opts(options):
565 if not hasattr(options, 'text') or not options.text:
566 options.text = ['']
567 texts = options.text
568 for text in texts:
569 options.text = text
570 makedocicon_opts(options)
573 def makedocicons(**kwargs):
574 makedocicons_opts(optsFromDict(**kwargs))
577 def getopts():
578 parser = OptionParser(usage='%prog [options]', version='%prog 1.01')
579 parser.add_option('--background', '--bg', default='default-split',
580 help='Used as background (special values: "default-split" (default), ' \
581 '"default-unsplit").')
582 parser.add_option('--appicon', help='App icon, defaults to no icon.')
584 parser.add_option('--text', help='Text on icon. Defaults to empty. '
585 'More than one text is supported, multiple docicons are generated in '
586 'that case.', action='append')
587 parser.add_option('--sizes', help='Sizes of icons. ' \
588 'Defaults to all sizes available in input appicon. Example: "512,128,16"')
589 # XXX(Nico): This has to go
590 parser.add_option('--debug', help='If set, write out pngs for all variants.' \
591 ' This needs to look like "debug%d.png".')
592 # XXX(Nico): This has to go once IconFamily is less buggy and can be used
593 # directly
594 parser.add_option('--makeicns', help='Path to makeicns binary',
595 default='./makeicns')
596 return parser
599 def main():
600 options, args = getopts().parse_args()
601 makedocicons_opts(options)
604 if __name__ == '__main__':
605 main()