diff --git a/color_manager/utils.py b/color_manager/utils.py
index 6e8fa2f7..88bbcfd2 100644
--- a/color_manager/utils.py
+++ b/color_manager/utils.py
@@ -9,7 +9,8 @@ from colormath.color_diff import delta_e_cie2000
from PIL import Image, ImageDraw
import os, re, shutil, json, subprocess
-# Utility ----------------------------------------------------------------------
+# Basic utility ----------------------------------------------------------------
+
def expand_path(path:str) -> str:
""" Returns the absolute version of the given path, and expands unix notation like tilde for home folder. """
return os.path.abspath(os.path.expanduser(path))
@@ -24,6 +25,31 @@ def load_json_file(path:str) -> Dict:
obj = json.load(file)
return obj
+def check_path(path:str) -> None:
+ if not os.path.exists(expand_path(path)):
+ raise Exception("Invalid path: " + path)
+
+def svg_to_png(src_path:str, dest_path:str, width:int = 300) -> None:
+ """ Generate pngs at given destination path from a given source folder with a given width. """
+
+ src_path = expand_path(src_path)
+ dest_path = expand_path(dest_path)
+
+ try:
+ subprocess.run(['inkscape', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ except FileNotFoundError:
+ raise RuntimeError("Inkscape is not installed.")
+
+ os.makedirs(dest_path, exist_ok=True)
+ svgs = [file for file in os.listdir(src_path) if file.endswith('.svg')]
+
+ for svg in svgs:
+ svg_path = os.path.join(src_path, svg)
+ png = os.path.splitext(svg)[0] + '.png'
+ png_path = os.path.join(dest_path, png)
+ command = ['inkscape', svg_path, '-o', png_path, '-w', str(width)]
+ subprocess.run(command)
+
# Color conversion -------------------------------------------------------------
def hex_to_rgb(hex:str) -> Tuple[int,int,int]:
@@ -110,29 +136,6 @@ def norm_hsl(h:int, s:int, l:int) -> Tuple[float,float,float]:
""" Normalize hsl color values. """
return h/360, s/100, l/100
-# File conversion --------------------------------------------------------------
-
-def svg_to_png(src_path:str, dest_path:str, width:int = 300) -> None:
- """ Generate pngs at given destination path from a given source folder with a given width. """
-
- src_path = expand_path(src_path)
- dest_path = expand_path(dest_path)
-
- try:
- subprocess.run(['inkscape', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- except FileNotFoundError:
- raise RuntimeError("Inkscape is not installed.")
-
- os.makedirs(dest_path, exist_ok=True)
- svgs = [file for file in os.listdir(src_path) if file.endswith('.svg')]
-
- for svg in svgs:
- svg_path = os.path.join(src_path, svg)
- png = os.path.splitext(svg)[0] + '.png'
- png_path = os.path.join(dest_path, png)
- command = ['inkscape', svg_path, '-o', png_path, '-w', str(width)]
- subprocess.run(command)
-
# Preprocessing ----------------------------------------------------------------
def expand_css_rgba(match) -> str:
@@ -177,15 +180,15 @@ def generate_palette_dict(colors:List[str]) -> Dict[str,LabColor]:
def get_input_colors(resource) -> Tuple[List[str],bool,bool]:
""" Returns an HSL tuple, or a list of colors, depending on the input, as well as a boolean indicating which one, as well as if the palettes specifies smoothing pngs/jpgs. """
+ # If resource is an hsl color.
if isinstance(resource, tuple) and len(resource) == 3:
return resource, False, True
- elif type(resource) is str:
- palette_file = load_json_file(resource)
- return generate_palette_dict(palette_file["colors"]), palette_file["smooth"], False
-
else:
- return generate_palette_dict(resource["colors"]), palette_file["smooth"], False
+ # If resource is a path to a palette.
+ if type(resource) is str: resource = load_json_file(resource)
+
+ return generate_palette_dict(resource["colors"]), resource["smooth"], False
def get_file_colors(text:str) -> Set[str]:
""" Return a set of all unique colors within a given string representing an svg-file. """
@@ -302,9 +305,9 @@ def copy_pack(src_path:str, dest_path:str, name:str) -> str:
return dest_path
-# SVG/CSS recoloring -----------------------------------------------------------
+# Vector-based recoloring ------------------------------------------------------
-def monochrome_vec(svg:str, colors:Set[str], hsl:Tuple[float,float,float]) -> str:
+def apply_monotones_to_vec(svg:str, colors:Set[str], hsl:Tuple[float,float,float]) -> str:
""" Replace every instance of color within the given list with their monochrome equivalent in the given string representing an svg-file, determined by the given hue, saturation and lightness offset. """
h, s, l_offset = hsl
@@ -326,7 +329,7 @@ def monochrome_vec(svg:str, colors:Set[str], hsl:Tuple[float,float,float]) -> st
return svg
-def multichrome_vec(svg:str, colors:Set[str], new_colors:Dict[str,LabColor]) -> str:
+def apply_palette_to_vec(svg:str, colors:Set[str], new_colors:Dict[str,LabColor]) -> str:
""" Replace colors in a given svg/css with the closest match within a given color palette. """
for color in colors:
@@ -335,9 +338,9 @@ def multichrome_vec(svg:str, colors:Set[str], new_colors:Dict[str,LabColor]) ->
return svg
-# PNG/JPG recoloring -----------------------------------------------------------
+# Pixel-based recoloring -------------------------------------------------------
-def monochrome_img(img:Image, hsl:Tuple[float,float,float]) -> Image:
+def apply_monotones_to_img(img:Image, hsl:Tuple[float,float,float]) -> Image:
""" Replace every instance of color within the given list with their monochrome equivalent in the given image, determined by the given hue, saturation and lightness offset. """
mode = img.mode
@@ -368,7 +371,7 @@ def monochrome_img(img:Image, hsl:Tuple[float,float,float]) -> Image:
return img
-def multichrome_img(img:Image, new_colors:Dict[str,LabColor], smooth:bool) -> Image:
+def apply_palette_to_img(img:Image, new_colors:Dict[str,LabColor], smooth:bool) -> Image:
""" Replace colors in a given image with the closest match within a given color palette. """
if smooth: img = img.convert("P", palette=Image.ADAPTIVE, colors=256)
@@ -391,76 +394,83 @@ def multichrome_img(img:Image, new_colors:Dict[str,LabColor], smooth:bool) -> Im
# User interface functions -----------------------------------------------------
def recolor(src_path:str, dest_path:str, name:str, replacement) -> None:
- """ Recursively copies and converts a source folder into a destination, given a either a color or a palette. """
+ """ Recursively copies and converts a source folder into a destination, given either an hsl color, a palette, or a color mapping. """
+
+ check_path(src_path)
+ check_path(dest_path)
new_colors, smooth, is_mono = get_input_colors(replacement)
dest_path = copy_pack(src_path, dest_path, name)
- svg_paths = get_paths(dest_path, [".svg", ".xml"])
- png_paths = get_paths(dest_path, [".png"])
- jpg_paths = get_paths(dest_path, [".jpg", ".jpeg"])
- css_paths = get_paths(dest_path, [".css", "rc"])
- for path in tqdm(svg_paths, desc="svg", disable=is_empty(svg_paths)):
+ # Recolor vector graphics.
+ paths = get_paths(dest_path, [".svg", ".xml"])
+ for path in tqdm(paths, desc="svg", disable=is_empty(paths)):
with open(path, 'r') as file: x = file.read()
x = expand_all_hex(x)
colors = get_file_colors(x)
- if is_mono: x = monochrome_vec(x, colors, new_colors)
- else: x = multichrome_vec(x, colors, new_colors)
+ if is_mono: x = apply_monotones_to_vec(x, colors, new_colors)
+ else: x = apply_palette_to_vec(x, colors, new_colors)
with open(path, 'w') as file: file.write(x)
- for path in tqdm(png_paths, desc="png", disable=is_empty(png_paths)):
+ # Recolor stylesheets.
+ paths = get_paths(dest_path, [".css", "rc"])
+ for path in tqdm(paths, desc="css", disable=is_empty(paths)):
+ with open(path, 'r') as file: x = file.read()
+
+ x = css_to_hex(x)
+ x = expand_all_hex(x)
+ colors = get_file_colors(x)
+
+ if is_mono: x = apply_monotones_to_vec(x, colors, new_colors)
+ else: x = apply_palette_to_vec(x, colors, new_colors)
+
+ with open(path, 'w') as file: file.write(x)
+
+ # Recolor pngs.
+ paths = get_paths(dest_path, [".png"])
+ for path in tqdm(paths, desc="png", disable=is_empty(paths)):
x = Image.open(path)
x = x.convert("RGBA")
a = x.split()[3] # Save original alpha channel.
- if is_mono: x = monochrome_img(x, new_colors)
- else: x = multichrome_img(x, new_colors, smooth)
+ if is_mono: x = apply_monotones_to_img(x, new_colors)
+ else: x = apply_palette_to_img(x, new_colors, smooth)
x = x.convert("RGBA")
r,g,b,_ = x.split()
x = Image.merge("RGBA",(r,g,b,a)) # Restore original alpha channel.
x.save(path)
- for path in tqdm(jpg_paths, desc="jpg", disable=is_empty(jpg_paths)):
+ # Recolor jpgs.
+ paths = get_paths(dest_path, [".jpg", ".jpeg"])
+ for path in tqdm(paths, desc="jpg", disable=is_empty(paths)):
x = Image.open(path)
x = x.convert("RGB")
- if is_mono: x = monochrome_img(x, new_colors)
- else: x = multichrome_img(x, new_colors, smooth)
+ if is_mono: x = apply_monotones_to_img(x, new_colors)
+ else: x = apply_palette_to_img(x, new_colors, smooth)
x = x.convert("RGB")
x.save(path)
- for path in tqdm(css_paths, desc="css", disable=is_empty(css_paths)):
- with open(path, 'r') as file: x = file.read()
+def extract_colors(src_path:str, num_colors:int=8, save_path:str=None, pixels:int=50, cols:int=10) -> List[str]:
+ """ Returns and optionally saves the color palette of the given image, as its own image. Optionally specify the number of unique colors you want to be found. """
- x = css_to_hex(x)
- x = expand_all_hex(x)
-
- colors = get_file_colors(x)
-
- if is_mono: x = monochrome_vec(x, colors, new_colors)
- else: x = multichrome_vec(x, colors, new_colors)
-
- with open(path, 'w') as file: file.write(x)
-
-def extract_colors(img_path:str, num_colors:int=8, save_path:str=None, pixels:int=50, cols:int=10) -> List[str]:
- """ Returns and optionally saves the color palette of the given image, finding the specified number of colors. """
-
- _, ext = os.path.splitext(img_path)
+ check_path(src_path)
+ _, ext = os.path.splitext(src_path)
if ext == ".svg":
- with open(img_path, 'r') as file:
+ with open(src_path, 'r') as file:
svg = file.read()
colors = list(get_file_colors(svg))
num_colors = len(colors)
else:
- img = Image.open(img_path)
+ img = Image.open(src_path)
colors = img.convert('P', palette=Image.ADAPTIVE, colors=num_colors)
colors = colors.getpalette()[0:num_colors*3]
@@ -468,6 +478,8 @@ def extract_colors(img_path:str, num_colors:int=8, save_path:str=None, pixels:in
colors = ['#{:02X}{:02X}{:02X}'.format(colors[i], colors[i+1], colors[i+2]) for i in range(0, len(colors), 3)]
if save_path != None:
+ check_path(save_path)
+
if num_colors < cols: cols = num_colors
rows = -(-len(colors) // cols)
@@ -486,10 +498,11 @@ def extract_colors(img_path:str, num_colors:int=8, save_path:str=None, pixels:in
return colors
-def clean_svg(input_path:str, output_path:str=None) -> str:
+def clean_svg(src_path:str, dest_path:str=None) -> str:
""" Removes needless metadata from svgs and optionally saves as copy, if output path is specified. """
- with open(input_path, 'r') as f:
+ check_path(src_path)
+ with open(src_path, 'r') as f:
svg = f.read()
patterns = [
@@ -501,15 +514,17 @@ def clean_svg(input_path:str, output_path:str=None) -> str:
for pattern in patterns:
svg = re.sub(pattern, '', svg)
- if output_path is None:
- output_path = input_path
+ if dest_path is None: dest_path = src_path
+ else: check_path(dest_path)
- with open(output_path, 'w') as f:
+ with open(dest_path, 'w') as f:
f.write(svg)
def add_backdrop(src_path:str, dest_path:str, name:str, color:str="#000000", padding=0, rounding=0):
- """ Add a customizable backdrop to all svg-based icons. """
+ """ Add a customizable backdrop to all svg-based icons. Optionally specify the backdrop color, the padding to the edge of the graphic, and the corner rounding factor. """
+ check_path(src_path)
+ check_path(dest_path)
dest_path = copy_pack(src_path, dest_path, name)
svg_paths = get_paths(dest_path, [".svg"])
diff --git a/readme.md b/readme.md
index dfe93908..00b74f1e 100644
--- a/readme.md
+++ b/readme.md
@@ -45,19 +45,20 @@ Color Manager is a program for recoloring and manipulating existing icon packs,
- [ ] Optional automatic palette extending.
- [ ] Basic framework for manipulating GTK, Cinnamon and Metacity themes.
- [ ] Intelligent color inversion.
+- [ ] Update GUI to support new features.
- [ ] GNU/Linux binary (deb, flatpak, appimage).
## Features
Currently, three operations are supported:
-| Type | Result | Speed | Supports |
-| ---- | ------ | ----- | -------- |
-| Monochrome recoloring | A monochromatic variant, colored by appropriate shades of the provided base color. | ~5000 svgs/sec | svg, png, jpg, css |
-| Multichrome recoloring | A multichromatic variant, where all colors are replaced by their nearest perceived equivalent that adheres to the provided color palette. | ~100 svgs/sec | svg, png, jpg, css |
-| Extract colors | Returns and optionally saves the color palette of an image, in specified detail. | ~100 colors/sec | svg, png, jpg, css |
-| Add backdrop | Add a rectangular or elliptical background (and anything in between) to all svg icons. | ~5000 svgs/sec | svg |
+| Type | Result | Speed | Support |
+| :--: | ------ | ----- | ------- |
+| **Monochrome recoloring** | A monochromatic variant, colored by appropriate shades of the provided base color. | ~5000svg/s
~500png/s | svg, xml, css, png, jpg |
+| **Palette recoloring** | A multichromatic variant, where all colors are replaced by their nearest perceived equivalent that adheres to the provided color palette. | ~100svg/s
~3png/s | svg, xml, css, png, jpg |
+| **Extract colors** | Returns and optionally saves the color palette of an image, in specified detail. | ~10colors/s | svg, xml, css, png, jpg |
+| **Add backdrop** | Add a rectangular or elliptical background (and anything in between) to all svg icons. | ~5000svg/s | svg |
-Speeds were recorded with an Intel i7-4770K CPU. Any asset can serve as the base for any color palette or base color. Svg recolorings will always be perfect, but png/jpgs may require experimentation.
+Speeds were recorded with an Intel i7-4770K CPU and is strongly dependent on file sizes. Any asset can serve as the base for any color palette or base color. Svg/css recolorings will always be perfect, but png/jpgs may require experimentation.
**Tip**: To increase the quality, i.e. decrease the perceived noise of multichromatic recolorings of pngs/jpgs, either...
- Increase the number of colors in the palette you provide to the program, e.g. populate it with slight variations of the existing colors
diff --git a/test.ipynb b/test.ipynb
index bbf1d738..d99db35f 100644
--- a/test.ipynb
+++ b/test.ipynb
@@ -2,9 +2,18 @@
"cells": [
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 8,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The autoreload extension is already loaded. To reload it, use:\n",
+ " %reload_ext autoreload\n"
+ ]
+ }
+ ],
"source": [
"%load_ext autoreload\n",
"%autoreload 2\n",
@@ -14,42 +23,32 @@
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": 10,
"metadata": {},
"outputs": [
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "/home/nv/Downloads/my_pack/index.theme\n"
- ]
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "svg: 100%|██████████| 43/43 [00:00<00:00, 250.05it/s]\n",
- "png: 100%|██████████| 439/439 [00:01<00:00, 360.71it/s]\n",
- "css: 100%|██████████| 12/12 [00:00<00:00, 84.68it/s]\n"
+ "ename": "Exception",
+ "evalue": "Invalid path: ~/Downloads",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+ "\u001b[0;31mException\u001b[0m Traceback (most recent call last)",
+ "Cell \u001b[0;32mIn[10], line 7\u001b[0m\n\u001b[1;32m 4\u001b[0m hsl \u001b[39m=\u001b[39m (\u001b[39m0.6\u001b[39m, \u001b[39m0.54\u001b[39m, \u001b[39m0.5\u001b[39m) \u001b[39m# = rc.normalize_hsl(180, 50, 50)\u001b[39;00m\n\u001b[1;32m 5\u001b[0m palette \u001b[39m=\u001b[39m \u001b[39m\"\u001b[39m\u001b[39mpalettes/nord.json\u001b[39m\u001b[39m\"\u001b[39m\n\u001b[0;32m----> 7\u001b[0m utils\u001b[39m.\u001b[39;49mrecolor(src, dest, name, hsl) \u001b[39m# Either hsl or palette\u001b[39;00m\n",
+ "File \u001b[0;32m~/Projects/Color_manager/color_manager/utils.py:402\u001b[0m, in \u001b[0;36mrecolor\u001b[0;34m(src_path, dest_path, name, replacement)\u001b[0m\n\u001b[1;32m 399\u001b[0m \u001b[39m\u001b[39m\u001b[39m\"\"\" Recursively copies and converts a source folder into a destination, given either an hsl color, a palette, or a color mapping. \"\"\"\u001b[39;00m\n\u001b[1;32m 401\u001b[0m check_path(src_path)\n\u001b[0;32m--> 402\u001b[0m check_path(dest_path)\n\u001b[1;32m 404\u001b[0m new_colors, smooth, is_mono \u001b[39m=\u001b[39m get_input_colors(replacement)\n\u001b[1;32m 405\u001b[0m dest_path \u001b[39m=\u001b[39m copy_pack(src_path, dest_path, name)\n",
+ "File \u001b[0;32m~/Projects/Color_manager/color_manager/utils.py:30\u001b[0m, in \u001b[0;36mcheck_path\u001b[0;34m(path)\u001b[0m\n\u001b[1;32m 28\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39mcheck_path\u001b[39m(path:\u001b[39mstr\u001b[39m) \u001b[39m-\u001b[39m\u001b[39m>\u001b[39m \u001b[39mNone\u001b[39;00m:\n\u001b[1;32m 29\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39mnot\u001b[39;00m os\u001b[39m.\u001b[39mpath\u001b[39m.\u001b[39mexists(path):\n\u001b[0;32m---> 30\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mException\u001b[39;00m(\u001b[39m\"\u001b[39m\u001b[39mInvalid path: \u001b[39m\u001b[39m\"\u001b[39m \u001b[39m+\u001b[39m path)\n",
+ "\u001b[0;31mException\u001b[0m: Invalid path: ~/Downloads"
]
}
],
"source": [
- "src = \"test_theme\" # = \"test_theme\"\n",
- "name = \"my_pack\" # = \"my_theme\"\n",
+ "src = \"test_pack\" # = \"test_theme\"\n",
+ "name = \"my_pack3\" # = \"my_theme\"\n",
"dest = \"~/Downloads\"\n",
"hsl = (0.6, 0.54, 0.5) # = rc.normalize_hsl(180, 50, 50)\n",
"palette = \"palettes/nord.json\"\n",
"\n",
"utils.recolor(src, dest, name, hsl) # Either hsl or palette"
]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Priority: Preserve transparency in PNGs after multichrome recoloring."
- ]
}
],
"metadata": {