File indexing completed on 2024-12-01 03:38:19

0001 #!/usr/bin/env python3
0002 """
0003 SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0004 SPDX-FileCopyrightText: 2020 Noah Davis <noahadvs@gmail.com>
0005 SPDX-FileCopyrightText: 2020 Niccolò Venerandi <niccolo@venerandi.com>
0006 """
0007 import os
0008 import re
0009 import sys
0010 from pathlib import Path
0011 from lxml import etree
0012 """
0013 This script generates 24px icons based on 22px icons
0014 """
0015 
0016 # The BEGIN/END stuff is a Kate/KDevelop feature. Please don't remove it unless you have a good reason.
0017 
0018 # BEGIN globals
0019 
0020 # These are needed to prevent nonsense namespaces like ns0 from being
0021 # added to otherwise perfectly fine svg elements and attributes
0022 NAMESPACES = {
0023     "svg": "http://www.w3.org/2000/svg",
0024     "xlink": "http://www.w3.org/1999/xlink",
0025     "inkscape": "http://www.inkscape.org/namespaces/inkscape",
0026     "dc": "http://purl.org/dc/elements/1.1/",
0027     "cc": "http://creativecommons.org/ns#",
0028     "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
0029     "sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
0030 }
0031 for prefix, uri in NAMESPACES.items():
0032     etree.register_namespace(prefix, uri)
0033 
0034 # END globals
0035 
0036 
0037 # BEGIN defs
0038 
0039 
0040 def strip_split(s: str):
0041     """
0042     Strip whitespace from the start and end, then split into a list of strings.
0043 
0044     re.split() RegEx: match comma with [0,inf) whitespace characters after it OR [1,inf) whitespace characters.
0045     """
0046     # Ignore warnings about invalid escape sequences, this works fine.
0047     return re.split(",\s*|\s+", s.strip())
0048 
0049 
0050 def get_renderable_elements(root: etree.Element):
0051     """
0052     Get renderable elements that are children of the root of the SVG.
0053 
0054     See the Renderable Elements section of the SVG documentation on MDN web docs:
0055     https://developer.mozilla.org/en-US/docs/Web/SVG/Element#SVG_elements_by_category
0056     """
0057     return root.xpath(
0058         "./svg:a | ./svg:circle | ./svg:ellipse | ./svg:foreignObject | ./svg:g "
0059         + "| ./svg:image | ./svg:line | ./svg:mesh | ./svg:path | ./svg:polygon "
0060         + "| ./svg:polyline | ./svg:rect | ./svg:switch | ./svg:svg | ./svg:symbol "
0061         + "| ./svg:text | ./svg:textPath | ./svg:tspan | ./svg:unknown | ./svg:use",
0062         namespaces=NAMESPACES
0063     )
0064 
0065 
0066 def make_dir(input_dir, output_dir, path):
0067     if not path.endswith('/22'):
0068         return
0069 
0070     folder24_destination = path.replace(input_dir, output_dir, 1).replace('/22', '/24')
0071 
0072     # Make 24/
0073     Path(folder24_destination).mkdir(parents=True, exist_ok=True)
0074 
0075     # Make 24@2x/ and 24@3x/
0076     for scale in (2, 3):
0077         folder24_scaled_destination = folder24_destination.replace('/24', f'/24@{scale}x')
0078         if os.path.islink(folder24_scaled_destination):
0079             os.remove(folder24_scaled_destination)
0080         os.symlink("24", folder24_scaled_destination, target_is_directory=True)
0081 
0082 
0083 def make_file(input_dir, output_dir, path):
0084     # Filter out files
0085     if not (path.endswith('.svg') and '/22/' in path):
0086         return
0087 
0088     file_destination = path.replace(input_dir, output_dir, 1).replace('/22/', '/24/')
0089 
0090     # Regenerate symlinks or edit SVGs
0091     if os.path.islink(path):
0092         symlink_source = os.readlink(path).replace('/22/', '/24/')
0093         if os.path.islink(file_destination):
0094             os.remove(file_destination)
0095         if not os.path.exists(file_destination):
0096             os.symlink(symlink_source, file_destination)
0097     else:
0098         etree.set_default_parser(etree.XMLParser(remove_blank_text=True))
0099         tree = etree.parse(path)
0100         root = tree.getroot()
0101 
0102         viewBox_is_none = root.get('viewBox') is None
0103         width_is_none = root.get('width') is None
0104         height_is_none = root.get('height') is None
0105 
0106         """
0107         NOTE:
0108         - Using strip and split because the amount of whitespace and usage of commas can vary.
0109         - Checking against real values because string values can have leading zeros.
0110         - Replacing "px" with nothing so that values can be converted to real numbers and because px is the default unit type
0111             - If another unit type is used in the <svg> element, this script will fail, but icons shouldn't use other unit types anyway
0112         """
0113 
0114         # This is used to prevent SVGs with non-square or incorrect but valid viewBoxes from being converted to 24x24.
0115         # If viewBox is None, but the SVG still has width and height, the SVG is still fine.
0116         viewBox_matched_or_none = viewBox_is_none
0117         if not viewBox_is_none:
0118             viewBox_matched_or_none = (
0119                 list(map(float, strip_split(root.get('viewBox').strip('px'))))
0120                 == [0.0, 0.0, 22.0, 22.0]
0121             )
0122 
0123         # This is used to prevent SVGs that aren't square or are missing only height or only width from being converted to 24x24.
0124         # If width and height are None, but the SVG still has a viewBox, the SVG is still fine.
0125         width_height_matched_or_none = width_is_none and height_is_none
0126         if not (width_is_none or height_is_none):
0127             width_height_matched_or_none = (
0128                 float(root.get('width').strip('px').strip()) == 22.0 and
0129                 float(root.get('height').strip('px').strip()) == 22.0
0130             )
0131 
0132         if (width_height_matched_or_none and viewBox_matched_or_none
0133                 and not (viewBox_is_none and (width_is_none or height_is_none))):
0134             # Resize to 24x24
0135             root.set('viewBox', "0 0 24 24")
0136             root.set('width', "24")
0137             root.set('height', "24")
0138             # Put content in a group that moves content down 1px, right 1px
0139             group = etree.Element('g', attrib={'transform': "translate(1,1)"})
0140             group.extend(get_renderable_elements(root))
0141             root.append(group)
0142 
0143             # print(file_destination)
0144             tree.write(file_destination, method="xml", pretty_print=True, exclusive=True)
0145         else:
0146             skipped_message = " SKIPPED: "
0147             if not viewBox_matched_or_none:
0148                 skipped_message += "not square or incorrect viewBox\nviewBox=\"" + root.get('viewBox') + "\""
0149             elif not width_height_matched_or_none:
0150                 skipped_message += "not square or incorrect width and height\nwidth=\"" + root.get('width') + "height=\"" + root.get('height') + "\""
0151             elif viewBox_is_none and (width_is_none or height_is_none):
0152                 skipped_message += "viewBox and width/height are missing"
0153             else:
0154                 skipped_message += "You shouldn't be seeing this. Please fix " + os.path.basename(sys.argv[0])
0155 
0156             print(path.lstrip(input_dir) + skipped_message)
0157 
0158 
0159 def main(input_dirs, output_dir):
0160     for input_dir in input_dirs:
0161         for dirpath, dirnames, filenames in os.walk(input_dir):
0162             for d in dirnames:
0163                 make_dir(input_dir, output_dir, os.path.join(dirpath, d))
0164             for f in filenames:
0165                 make_file(input_dir, output_dir, os.path.join(dirpath, f))
0166 
0167 # END defs
0168 
0169 
0170 # I've structured the program like this in case I want to do multiprocessing later
0171 if __name__ == '__main__':
0172     argv_len = len(sys.argv)
0173     if argv_len < 3:
0174         print("missing arguments")
0175         sys.exit(1)
0176     input_dirs: list = []
0177     for i in range(1, argv_len-1):
0178         if Path(sys.argv[i]).is_dir():
0179             input_dirs.append(sys.argv[i])
0180     if len(input_dirs) < 1:
0181         print("No valid input folders")
0182         sys.exit(1)
0183     output_dir: str = sys.argv[argv_len-1]
0184     output_path = Path(output_dir)
0185     if output_path.exists() and not output_path.is_dir():
0186         print("Output is not a folder")
0187         sys.exit(1)
0188 
0189     sys.exit(main(input_dirs, output_dir))