Generate base16 color schemes from images
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

342 lines
14 KiB

  1. # AutoBase16Theme.py by Macoy Madson
  2. # MIT License
  3. # https://github.com/makuto/auto-base16-theme
  4. import random
  5. import colorsys
  6. import sys
  7. import argparse
  8. """
  9. This script generates a "base16" color theme intended for code syntax highlighting.
  10. Base16 Style (from https://github.com/chriskempson/base16/blob/master/styling.md):
  11. base00 - Default Background
  12. base01 - Lighter Background (Used for status bars)
  13. base02 - Selection Background
  14. base03 - Comments, Invisibles, Line Highlighting
  15. base04 - Dark Foreground (Used for status bars)
  16. base05 - Default Foreground, Caret, Delimiters, Operators
  17. base06 - Light Foreground (Not often used)
  18. base07 - Light Background (Not often used)
  19. base08 - Variables, XML Tags, Markup Link Text, Markup Lists, Diff Deleted
  20. base09 - Integers, Boolean, Constants, XML Attributes, Markup Link Url
  21. base0A - Classes, Markup Bold, Search Text Background
  22. base0B - Strings, Inherited Class, Markup Code, Diff Inserted
  23. base0C - Support, Regular Expressions, Escape Characters, Markup Quotes
  24. base0D - Functions, Methods, Attribute IDs, Headings
  25. base0E - Keywords, Storage, Selector, Markup Italic, Diff Changed
  26. base0F - Deprecated, Opening/Closing Embedded Language Tags, e.g. <?php ?>
  27. """
  28. argParser = argparse.ArgumentParser(
  29. description='This script generates a base16 color theme intended for code syntax highlighting from a source image.')
  30. argParser.add_argument('--inputColorPaletteFile', type=str, dest='inputColorPaletteFile', default='colors.txt',
  31. help='The colors the script will select from will be read in from this file. The file should'
  32. ' be a list of hexadecimal color values separated by newlines')
  33. argParser.add_argument('template', type=str,
  34. help='The template which the hex colors will be output to. This template should'
  35. ' have 16 curly brace pair (\"{}\") where the 16 colors will be output in order. Due to'
  36. ' python formatting, you\'ll need to add another brace to any non-format braces'
  37. ' ("{{" will become a single "{")')
  38. argParser.add_argument('outputFile', type=str,
  39. help='Colors will be inserted into outputTemplate then written to this outputFile')
  40. argParser.add_argument('--debugColorsVerbose', action='store_const', const=True, default=False, dest='debugColorsVerbose',
  41. help='Print detailed information about color selection')
  42. """
  43. Configuration
  44. """
  45. # TODO: Make these into modes that auto-set these constraints
  46. # TODO: Make contrast ratio mode which meets accessibility guidelines (see https://webaim.org/resources/contrastchecker/)?
  47. ## Background
  48. # These values ensure the backgrounds are nice and dark, even if the color palette values are all bright
  49. # Each background gets progressively lighter. We'll define a different max acceptible value for each level
  50. # For example, the Base00 default background is darkest, so it will be clamped to 0.08 if necessary
  51. maximumBackgroundBrightnessThresholds = [0.08, 0.15, 0.2, 0.25, 0.3, 0.4, 0.45]
  52. ## Foreground contrasts (i.e. text color HSL lightness - background color HSL lightness)
  53. # These are relative values instead of ratios because you can't figure a ratio on a black background
  54. minimumCommentTextContrast = 0.3
  55. minimumTextContrast = 0.43
  56. maximumTextContrast = 0.65
  57. ## Debugging
  58. debugColorsVerbose = False
  59. ## Internal constants
  60. # The index for the darkest background color. This is important to make sure contrast is high enough
  61. # between the darkest background color and the darkest text
  62. BACKGROUND_COLOR_INDEX = 0
  63. class Base16Color:
  64. def __init__(self, name, selectionFunction):
  65. self.name = name
  66. self.color = None
  67. self.selectionFunction = selectionFunction
  68. """
  69. Color utility functions
  70. """
  71. def rgbColorFromStringHex(colorStringHex):
  72. # From https://stackoverflow.com/questions/29643352/converting-hex-to-rgb-value-in-python
  73. return tuple(int(colorStringHex.strip('#')[i:i+2], 16) for i in (0, 2 ,4))
  74. def hlsToRgbStringHex(hlsColor):
  75. rgbColor = colorsys.hls_to_rgb(hlsColor[0], hlsColor[1], hlsColor[2])
  76. return '#{0:02x}{1:02x}{2:02x}'.format(int(rgbColor[0] * 255), int(rgbColor[1] * 255), int(rgbColor[2] * 255))
  77. def rgb256ToHls(color):
  78. rgbColor = color
  79. if type(color) == str:
  80. rgbColor = rgbColorFromStringHex(color)
  81. normalizedColor = []
  82. for component in rgbColor:
  83. normalizedColor.append(component / 256)
  84. return colorsys.rgb_to_hls(normalizedColor[0], normalizedColor[1], normalizedColor[2])
  85. def getColorBrightness(color):
  86. hlsColor = rgb256ToHls(color)
  87. return hlsColor[1]
  88. def colorHasBeenUsed(base16Colors, color):
  89. for base16Color in base16Colors:
  90. if base16Color.color == color:
  91. return True
  92. return False
  93. def isColorWithinContrastRange(color, backgroundColor, minimumContrast, maximumContrast):
  94. colorBrightness = getColorBrightness(color)
  95. backgroundBrightness = getColorBrightness(backgroundColor)
  96. contrast = colorBrightness - backgroundBrightness
  97. if debugColorsVerbose:
  98. print('Color {} brightness {} background {} brightness {} difference {}'
  99. .format(color, colorBrightness, backgroundColor, backgroundBrightness, contrast))
  100. return (contrast >= minimumContrast and contrast <= maximumContrast)
  101. # This is required so backgrounds get progressively lighter
  102. currentMaximumBackgroundBrightnessThresholdIndex = 0
  103. def resetMaximumBackgroundBrightnessThresholdIndex():
  104. global currentMaximumBackgroundBrightnessThresholdIndex
  105. currentMaximumBackgroundBrightnessThresholdIndex = 0
  106. def popMaximumBackgroundBrightnessThreshold():
  107. global currentMaximumBackgroundBrightnessThresholdIndex
  108. threshold = maximumBackgroundBrightnessThresholds[currentMaximumBackgroundBrightnessThresholdIndex]
  109. currentMaximumBackgroundBrightnessThresholdIndex += 1
  110. return threshold
  111. """
  112. Selection heuristics
  113. """
  114. # Used for the background of dark themes. Make sure it is dark, damn it; change the color if you have to :)
  115. def pickDarkestColorForceDarkThreshold(base16Colors, currentBase16Color, colorPool):
  116. viableColors = []
  117. for color in colorPool:
  118. viableColors.append(color)
  119. viableColors = sorted(viableColors,
  120. key=lambda color: getColorBrightness(color), reverse=False)
  121. if currentMaximumBackgroundBrightnessThresholdIndex <= len(viableColors):
  122. bestColor = viableColors[currentMaximumBackgroundBrightnessThresholdIndex]
  123. # Clamp brightness
  124. hlsColor = rgb256ToHls(bestColor)
  125. clampedColor = (hlsColor[0],
  126. min(hlsColor[1], popMaximumBackgroundBrightnessThreshold()),
  127. hlsColor[2])
  128. if debugColorsVerbose and clampedColor != hlsColor:
  129. print('Clamped {} lightness {} to {} (threshold index {})'
  130. .format(bestColor, hlsColor[1], clampedColor[1],
  131. currentMaximumBackgroundBrightnessThresholdIndex))
  132. return hlsToRgbStringHex(clampedColor)
  133. # This is weird and probably an error
  134. return None
  135. # Pick darkest color. If the color is already taken, pick the next unique darkest
  136. def pickDarkestColorUnique(base16Colors, currentBase16Color, colorPool):
  137. bestColor = None
  138. bestColorBrightness = 10000
  139. for color in colorPool:
  140. rgbColorBrightness = getColorBrightness(color)
  141. if rgbColorBrightness < bestColorBrightness and not colorHasBeenUsed(base16Colors, color):
  142. bestColor = color
  143. bestColorBrightness = rgbColorBrightness
  144. return bestColor
  145. # Selects the darkest color which meets the contrast requirements and which hasn't been used yet
  146. def pickDarkestHighContrastColorUnique(base16Colors, currentBase16Color, colorPool):
  147. viableColors = []
  148. for color in colorPool:
  149. if isColorWithinContrastRange(color, base16Colors[BACKGROUND_COLOR_INDEX].color,
  150. minimumCommentTextContrast, maximumTextContrast):
  151. viableColors.append(color)
  152. viableColors = sorted(viableColors,
  153. key=lambda color: getColorBrightness(color), reverse=False)
  154. # We've sorted in order of brightness; pick the darkest one which is unique
  155. bestColor = None
  156. for color in viableColors:
  157. if not colorHasBeenUsed(base16Colors, color):
  158. bestColor = color
  159. break
  160. return bestColor
  161. # Pick high contrast foreground
  162. # High contrast = a minimum brightness difference between this and the brightest background)
  163. def pickHighContrastBrightColorUniqueOrRandom(base16Colors, currentBase16Color, colorPool):
  164. viableColors = []
  165. for color in colorPool:
  166. if isColorWithinContrastRange(color, base16Colors[BACKGROUND_COLOR_INDEX].color,
  167. minimumTextContrast, maximumTextContrast):
  168. viableColors.append(color)
  169. if not viableColors:
  170. return None
  171. # Prefer a color which is unique
  172. bestColor = None
  173. for color in viableColors:
  174. if not colorHasBeenUsed(base16Colors, color):
  175. bestColor = color
  176. break
  177. return bestColor if bestColor else random.choice(viableColors)
  178. def pickRandomColor(base16Colors, currentBase16Color, colorPool):
  179. return random.choice(colorPool)
  180. """
  181. Procedure
  182. """
  183. def main(inputColorsFilename, outputTemplateFilename, outputFilename):
  184. colorsFile = open(inputColorsFilename, 'r')
  185. colorsLines = colorsFile.readlines()
  186. colorsFile.close()
  187. base16Colors = [
  188. # These go from darkest to lightest via implicit unique ordering
  189. # base00 - Default Background
  190. Base16Color('base00', pickDarkestColorForceDarkThreshold),
  191. # base01 - Lighter Background (Used for status bars)
  192. Base16Color('base01', pickDarkestColorForceDarkThreshold),
  193. # base02 - Selection Background
  194. Base16Color('base02', pickDarkestColorForceDarkThreshold),
  195. # base03 - Comments, Invisibles, Line Highlighting
  196. Base16Color('base03', pickDarkestHighContrastColorUnique),
  197. # base04 - Dark Foreground (Used for status bars)
  198. Base16Color('base04', pickDarkestHighContrastColorUnique),
  199. # base05 - Default Foreground, Caret, Delimiters, Operators
  200. Base16Color('base05', pickDarkestHighContrastColorUnique),
  201. # base06 - Light Foreground (Not often used)
  202. Base16Color('base06', pickDarkestColorForceDarkThreshold),
  203. # base07 - Light Background (Not often used)
  204. Base16Color('base07', pickDarkestColorForceDarkThreshold),
  205. # base08 - Variables, XML Tags, Markup Link Text, Markup Lists, Diff Deleted
  206. Base16Color('base08', pickHighContrastBrightColorUniqueOrRandom),
  207. # base09 - Integers, Boolean, Constants, XML Attributes, Markup Link Url
  208. Base16Color('base09', pickHighContrastBrightColorUniqueOrRandom),
  209. # base0A - Classes, Markup Bold, Search Text Background
  210. Base16Color('base0A', pickHighContrastBrightColorUniqueOrRandom),
  211. # base0B - Strings, Inherited Class, Markup Code, Diff Inserted
  212. Base16Color('base0B', pickHighContrastBrightColorUniqueOrRandom),
  213. # base0C - Support, Regular Expressions, Escape Characters, Markup Quotes
  214. Base16Color('base0C', pickHighContrastBrightColorUniqueOrRandom),
  215. # base0D - Functions, Methods, Attribute IDs, Headings
  216. Base16Color('base0D', pickHighContrastBrightColorUniqueOrRandom),
  217. # base0E - Keywords, Storage, Selector, Markup Italic, Diff Changed
  218. Base16Color('base0E', pickHighContrastBrightColorUniqueOrRandom),
  219. # base0F - Deprecated, Opening/Closing Embedded Language Tags, e.g. <?php ?>
  220. Base16Color('base0F', pickHighContrastBrightColorUniqueOrRandom)]
  221. # The colors we are able to choose from
  222. colorPool = []
  223. if colorsLines:
  224. colorPool = colorsLines
  225. else:
  226. print('Error: Could not parse colors from input colors file {}'.format(inputColorsFilename))
  227. return
  228. # Remove duplicate colors; these throw off the algorithm
  229. colorPool = list(set(colorPool))
  230. # Process color pool
  231. for i, color in enumerate(colorPool):
  232. # Remove newlines
  233. colorPool[i] = color.strip('\n')
  234. color = colorPool[i]
  235. if debugColorsVerbose:
  236. print(color)
  237. rgbColor = rgbColorFromStringHex(color)
  238. print('RGB =', rgbColor)
  239. # Make sure we start at the darkest threshold
  240. resetMaximumBackgroundBrightnessThresholdIndex()
  241. # Select a color from the color pool for each base16 color
  242. for i, base16Color in enumerate(base16Colors):
  243. color = base16Color.selectionFunction(base16Colors, base16Color, colorPool)
  244. if not color:
  245. print('WARNING: {} could not select a color! Picking one at random'.format(base16Color.name))
  246. color = random.choice(colorPool)
  247. base16Colors[i].color = color
  248. print('Selected {} for {}'.format(base16Colors[i].color, base16Color.name))
  249. # Output selected colors
  250. outputTemplateFile = open(outputTemplateFilename, 'r')
  251. outputTemplate = ''.join(outputTemplateFile.readlines())
  252. outputTemplateFile.close()
  253. outputText = outputTemplate.format((base16Colors[0].color)[1:], (base16Colors[1].color)[1:],
  254. (base16Colors[2].color)[1:], (base16Colors[3].color)[1:],
  255. (base16Colors[4].color)[1:], (base16Colors[5].color)[1:],
  256. (base16Colors[6].color)[1:], (base16Colors[7].color)[1:],
  257. (base16Colors[8].color)[1:], (base16Colors[9].color)[1:],
  258. (base16Colors[10].color)[1:], (base16Colors[11].color)[1:],
  259. (base16Colors[12].color)[1:], (base16Colors[13].color)[1:],
  260. (base16Colors[14].color)[1:], (base16Colors[15].color)[1:])
  261. outputFile = open(outputFilename, 'w')
  262. outputFile.write(outputText)
  263. outputFile.close()
  264. print('Wrote {} using template {}'.format(outputFilename, outputTemplateFilename))
  265. if __name__ == '__main__':
  266. if len(sys.argv) == 1:
  267. argParser.print_help()
  268. exit()
  269. args = argParser.parse_args()
  270. debugColorsVerbose = args.debugColorsVerbose
  271. main(args.inputColorPaletteFile, args.template, args.outputFile)