File indexing completed on 2024-12-08 09:35:21

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 OUTPUT_DIR: str = sys.argv[1]
0035 INPUT_DIR: str = "./"
0036 
0037 # END globals
0038 
0039 
0040 # BEGIN defs
0041 
0042 def strip_split(s: str):
0043     """
0044     Strip whitespace from the start and end, then split into a list of strings.
0045 
0046     re.split() RegEx: match comma with [0,inf) whitespace characters after it OR [1,inf) whitespace characters.
0047     """
0048     # Ignore warnings about invalid escape sequences, this works fine.
0049     return re.split(",\s*|\s+", s.strip())
0050 
0051 
0052 def get_renderable_elements(root: etree.Element):
0053     """
0054     Get renderable elements that are children of the root of the SVG.
0055 
0056     See the Renderable Elements section of the SVG documentation on MDN web docs:
0057     https://developer.mozilla.org/en-US/docs/Web/SVG/Element#SVG_elements_by_category
0058     """
0059     return root.xpath(
0060         "./svg:a | ./svg:circle | ./svg:ellipse | ./svg:foreignObject | ./svg:g "
0061         + "| ./svg:image | ./svg:line | ./svg:mesh | ./svg:path | ./svg:polygon "
0062         + "| ./svg:polyline | ./svg:rect | ./svg:switch | ./svg:svg | ./svg:symbol "
0063         + "| ./svg:text | ./svg:textPath | ./svg:tspan | ./svg:unknown | ./svg:use",
0064         namespaces=NAMESPACES
0065     )
0066 
0067 
0068 def main():
0069     for dirpath, dirnames, filenames in os.walk(INPUT_DIR):
0070         folder24_destination = os.path.join(dirpath, "24").replace(INPUT_DIR, OUTPUT_DIR, 1)
0071         for d in dirnames:
0072             if d != '22':
0073                 continue
0074 
0075             # Make 24/
0076             Path(folder24_destination).mkdir(parents=True, exist_ok=True)
0077             # print(folder24_destination)
0078 
0079             # Make 24@2x/ and 24@3x/
0080             for scale in (2, 3):
0081                 folder24_scaled_destination = folder24_destination.replace('/24', f'/24@{scale}x')
0082                 if os.path.islink(folder24_scaled_destination):
0083                     os.remove(folder24_scaled_destination)
0084                 os.symlink("24", folder24_scaled_destination, target_is_directory=True)
0085                 # print(folder24_scaled_destination + " -> " + os.readlink(folder24_scaled_destination))
0086 
0087         for f in filenames:
0088             filepath = os.path.join(dirpath, f)
0089 
0090             # Filter out files
0091             if not (f.endswith('.svg') and '/22' in filepath):
0092                 continue
0093 
0094             file_destination = filepath.replace(INPUT_DIR, OUTPUT_DIR, 1).replace('/22', '/24')
0095             # print(file_destination)
0096 
0097             # Regenerate symlinks or edit SVGs
0098             if os.path.islink(filepath):
0099                 symlink_source = os.readlink(filepath).replace('/22', '/24')
0100                 if os.path.exists(file_destination):
0101                     os.remove(file_destination)
0102                 os.symlink(symlink_source, file_destination)
0103                 # print(file_destination + " -> " + os.readlink(file_destination))
0104             else:
0105                 etree.set_default_parser(etree.XMLParser(remove_blank_text=True))
0106                 tree = etree.parse(filepath)
0107                 root = tree.getroot()
0108 
0109                 viewBox_is_none = root.get('viewBox') is None
0110                 width_is_none = root.get('width') is None
0111                 height_is_none = root.get('height') is None
0112 
0113                 """
0114                 NOTE:
0115                 - Using strip and split because the amount of whitespace and usage of commas can vary.
0116                 - Checking against real values because string values can have leading zeros.
0117                 - Replacing "px" with nothing so that values can be converted to real numbers and because px is the default unit type
0118                     - If another unit type is used in the <svg> element, this script will fail, but icons shouldn't use other unit types anyway
0119                 """
0120 
0121                 # This is used to prevent SVGs with non-square or incorrect but valid viewBoxes from being converted to 24x24.
0122                 # If viewBox is None, but the SVG still has width and height, the SVG is still fine.
0123                 viewBox_matched_or_none = viewBox_is_none
0124                 if not viewBox_is_none:
0125                     viewBox_matched_or_none = (
0126                         list(map(float, strip_split(root.get('viewBox').strip('px'))))
0127                         == [0.0, 0.0, 22.0, 22.0]
0128                     )
0129 
0130                 # This is used to prevent SVGs that aren't square or are missing only height or only width from being converted to 24x24.
0131                 # If width and height are None, but the SVG still has a viewBox, the SVG is still fine.
0132                 width_height_matched_or_none = width_is_none and height_is_none
0133                 if not (width_is_none or height_is_none):
0134                     width_height_matched_or_none = (
0135                         float(root.get('width').strip('px').strip()) == 22.0 and
0136                         float(root.get('height').strip('px').strip()) == 22.0
0137                     )
0138 
0139                 if (width_height_matched_or_none and viewBox_matched_or_none
0140                         and not (viewBox_is_none and (width_is_none or height_is_none))):
0141                     # Resize to 24x24
0142                     root.set('viewBox', "0 0 24 24")
0143                     root.set('width', "24")
0144                     root.set('height', "24")
0145                     # Put content in a group that moves content down 1px, right 1px
0146                     group = etree.Element('g', attrib={'transform': "translate(1,1)"})
0147                     group.extend(get_renderable_elements(root))
0148                     root.append(group)
0149 
0150                     # print(file_destination)
0151                     tree.write(file_destination, method="xml", pretty_print=True, exclusive=True)
0152                 else:
0153                     skipped_message = " SKIPPED: "
0154                     if not viewBox_matched_or_none:
0155                         skipped_message += "not square or incorrect viewBox\nviewBox=\"" + root.get('viewBox') + "\""
0156                     elif not width_height_matched_or_none:
0157                         skipped_message += "not square or incorrect width and height\nwidth=\"" + root.get('width') + "height=\"" + root.get('height') + "\""
0158                     elif viewBox_is_none and (width_is_none or height_is_none):
0159                         skipped_message += "viewBox and width/height are missing"
0160                     else:
0161                         skipped_message += "You shouldn't be seeing this. Please fix " + os.path.basename(sys.argv[0])
0162 
0163                     print(filepath.lstrip(INPUT_DIR) + skipped_message)
0164 
0165 # END defs
0166 
0167 
0168 # I've structured the program like this in case I want to do multiprocessing later
0169 if __name__ == '__main__':
0170     sys.exit(main())