213 lines
7.9 KiB
Python
Executable File
213 lines
7.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
|
|
SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
|
|
SPDX-FileCopyrightText: 2020 Niccolò Venerandi <niccolo@venerandi.com>
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from lxml import etree
|
|
"""
|
|
This script generates 24px icons based on 22px icons
|
|
"""
|
|
|
|
# The BEGIN/END stuff is a Kate/KDevelop feature. Please don't remove it unless you have a good reason.
|
|
|
|
# BEGIN globals
|
|
|
|
# These are needed to prevent nonsense namespaces like ns0 from being
|
|
# added to otherwise perfectly fine svg elements and attributes
|
|
NAMESPACES = {
|
|
"svg": "http://www.w3.org/2000/svg",
|
|
"xlink": "http://www.w3.org/1999/xlink",
|
|
"inkscape": "http://www.inkscape.org/namespaces/inkscape",
|
|
"dc": "http://purl.org/dc/elements/1.1/",
|
|
"cc": "http://creativecommons.org/ns#",
|
|
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
"sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
|
|
}
|
|
for prefix, uri in NAMESPACES.items():
|
|
etree.register_namespace(prefix, uri)
|
|
|
|
# END globals
|
|
|
|
|
|
# BEGIN defs
|
|
|
|
|
|
def strip_split(s: str):
|
|
"""
|
|
Strip whitespace from the start and end, then split into a list of strings.
|
|
|
|
re.split() RegEx: match comma with [0,inf) whitespace characters after it OR [1,inf) whitespace characters.
|
|
"""
|
|
# Ignore warnings about invalid escape sequences, this works fine.
|
|
return re.split(r",\s*|\s+", s.strip())
|
|
|
|
|
|
def get_renderable_elements(root: etree.Element):
|
|
"""
|
|
Get renderable elements that are children of the root of the SVG.
|
|
|
|
See the Renderable Elements section of the SVG documentation on MDN web docs:
|
|
https://developer.mozilla.org/en-US/docs/Web/SVG/Element#SVG_elements_by_category
|
|
"""
|
|
return root.xpath(
|
|
"./svg:a | ./svg:circle | ./svg:ellipse | ./svg:foreignObject | ./svg:g "
|
|
+ "| ./svg:image | ./svg:line | ./svg:mesh | ./svg:path | ./svg:polygon "
|
|
+ "| ./svg:polyline | ./svg:rect | ./svg:switch | ./svg:svg | ./svg:symbol "
|
|
+ "| ./svg:text | ./svg:textPath | ./svg:tspan | ./svg:unknown | ./svg:use",
|
|
namespaces=NAMESPACES
|
|
)
|
|
|
|
|
|
def make_dir(input_dir, output_dir, path):
|
|
if not path.endswith('/22'):
|
|
return
|
|
|
|
folder24_destination = path.replace(input_dir, output_dir, 1).replace('/22', '/24')
|
|
|
|
# Make 24/
|
|
Path(folder24_destination).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Make 24@2x/ and 24@3x/
|
|
for scale in (2, 3):
|
|
folder24_scaled_destination = folder24_destination.replace('/24', f'/24@{scale}x')
|
|
if os.path.islink(folder24_scaled_destination):
|
|
os.remove(folder24_scaled_destination)
|
|
os.symlink("24", folder24_scaled_destination, target_is_directory=True)
|
|
|
|
|
|
def make_file(input_dir, output_dir, path):
|
|
# only handle files
|
|
if os.path.islink(path):
|
|
return
|
|
|
|
# Filter out files
|
|
if not (path.endswith('.svg') and '/22/' in path):
|
|
return
|
|
|
|
file_destination = path.replace(input_dir, output_dir, 1).replace('/22/', '/24/')
|
|
|
|
# edit SVGs
|
|
etree.set_default_parser(etree.XMLParser(remove_blank_text=True))
|
|
tree = etree.parse(path)
|
|
root = tree.getroot()
|
|
|
|
viewBox_is_none = root.get('viewBox') is None
|
|
width_is_none = root.get('width') is None
|
|
height_is_none = root.get('height') is None
|
|
|
|
"""
|
|
NOTE:
|
|
- Using strip and split because the amount of whitespace and usage of commas can vary.
|
|
- Checking against real values because string values can have leading zeros.
|
|
- Replacing "px" with nothing so that values can be converted to real numbers and because px is the default unit type
|
|
- If another unit type is used in the <svg> element, this script will fail, but icons shouldn't use other unit types anyway
|
|
"""
|
|
|
|
# This is used to prevent SVGs with non-square or incorrect but valid viewBoxes from being converted to 24x24.
|
|
# If viewBox is None, but the SVG still has width and height, the SVG is still fine.
|
|
viewBox_matched_or_none = viewBox_is_none
|
|
if not viewBox_is_none:
|
|
viewBox_matched_or_none = (
|
|
list(map(float, strip_split(root.get('viewBox').strip('px'))))
|
|
== [0.0, 0.0, 22.0, 22.0]
|
|
)
|
|
|
|
# This is used to prevent SVGs that aren't square or are missing only height or only width from being converted to 24x24.
|
|
# If width and height are None, but the SVG still has a viewBox, the SVG is still fine.
|
|
width_height_matched_or_none = width_is_none and height_is_none
|
|
if not (width_is_none or height_is_none):
|
|
width_height_matched_or_none = (
|
|
float(root.get('width').strip('px').strip()) == 22.0 and
|
|
float(root.get('height').strip('px').strip()) == 22.0
|
|
)
|
|
|
|
if (width_height_matched_or_none and viewBox_matched_or_none
|
|
and not (viewBox_is_none and (width_is_none or height_is_none))):
|
|
# Resize to 24x24
|
|
root.set('viewBox', "0 0 24 24")
|
|
root.set('width', "24")
|
|
root.set('height', "24")
|
|
# Put content in a group that moves content down 1px, right 1px
|
|
group = etree.Element('g', attrib={'transform': "translate(1,1)"})
|
|
group.extend(get_renderable_elements(root))
|
|
root.append(group)
|
|
|
|
# print(file_destination)
|
|
tree.write(file_destination, method="xml", pretty_print=True, exclusive=True)
|
|
else:
|
|
skipped_message = " SKIPPED: "
|
|
if not viewBox_matched_or_none:
|
|
skipped_message += "not square or incorrect viewBox\nviewBox=\"" + root.get('viewBox') + "\""
|
|
elif not width_height_matched_or_none:
|
|
skipped_message += "not square or incorrect width and height\nwidth=\"" + root.get('width') + "height=\"" + root.get('height') + "\""
|
|
elif viewBox_is_none and (width_is_none or height_is_none):
|
|
skipped_message += "viewBox and width/height are missing"
|
|
else:
|
|
skipped_message += "You shouldn't be seeing this. Please fix " + os.path.basename(sys.argv[0])
|
|
|
|
print(path.lstrip(input_dir) + skipped_message)
|
|
|
|
|
|
def make_file_link(input_dir, output_dir, path):
|
|
# only handle links
|
|
if not os.path.islink(path):
|
|
return
|
|
|
|
# Filter out files
|
|
if not (path.endswith('.svg') and '/22/' in path):
|
|
return
|
|
|
|
file_destination = path.replace(input_dir, output_dir, 1).replace('/22/', '/24/')
|
|
|
|
# Regenerate symlinks or edit SVGs, don't create dead links for icons we failed to generate
|
|
symlink_source = os.readlink(path).replace('/22/', '/24/')
|
|
if os.path.islink(file_destination):
|
|
os.remove(file_destination)
|
|
if not os.path.exists(file_destination):
|
|
os.symlink(symlink_source, file_destination)
|
|
|
|
|
|
def main(input_dirs, output_dir):
|
|
# first pass: create dirs and files
|
|
for input_dir in input_dirs:
|
|
for dirpath, dirnames, filenames in os.walk(input_dir):
|
|
for d in dirnames:
|
|
make_dir(input_dir, output_dir, os.path.join(dirpath, d))
|
|
for f in filenames:
|
|
make_file(input_dir, output_dir, os.path.join(dirpath, f))
|
|
|
|
# second pass: create links, that avoids dead links
|
|
for input_dir in input_dirs:
|
|
for dirpath, dirnames, filenames in os.walk(input_dir):
|
|
for f in filenames:
|
|
make_file_link(input_dir, output_dir, os.path.join(dirpath, f))
|
|
|
|
# END defs
|
|
|
|
|
|
# I've structured the program like this in case I want to do multiprocessing later
|
|
if __name__ == '__main__':
|
|
argv_len = len(sys.argv)
|
|
if argv_len < 3:
|
|
print("missing arguments")
|
|
sys.exit(1)
|
|
input_dirs: list = []
|
|
for i in range(1, argv_len-1):
|
|
if Path(sys.argv[i]).is_dir():
|
|
input_dirs.append(sys.argv[i])
|
|
if len(input_dirs) < 1:
|
|
print("No valid input folders")
|
|
sys.exit(1)
|
|
output_dir: str = sys.argv[argv_len-1]
|
|
output_path = Path(output_dir)
|
|
if output_path.exists() and not output_path.is_dir():
|
|
print("Output is not a folder")
|
|
sys.exit(1)
|
|
|
|
sys.exit(main(input_dirs, output_dir))
|