diff --git a/StaticGalleryBuilder.code-workspace b/StaticGalleryBuilder.code-workspace index 731cfb0..abe224b 100644 --- a/StaticGalleryBuilder.code-workspace +++ b/StaticGalleryBuilder.code-workspace @@ -35,6 +35,8 @@ "themes/default.css", "--use-fancy-folders", "--web-manifest", + "-l", + "cc-by-nc-sa", "-n", "-m", "-r" diff --git a/builder.py b/builder.py index 25fe58a..2a3dcda 100755 --- a/builder.py +++ b/builder.py @@ -1,113 +1,51 @@ #!/usr/bin/env python3 import os -import argparse -import urllib.parse import shutil import fnmatch -import json -import re from multiprocessing import Pool from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple -import numpy as np -from jinja2 import Environment, FileSystemLoader +from typing import Dict, List, Tuple + from tqdm.auto import tqdm -from PIL import Image, ImageOps, ExifTags -from rich_argparse import RichHelpFormatter, HelpPreviewAction +from PIL import Image, ImageOps -try: - import cairosvg - from io import BytesIO - - SVGSUPPORT = True -except ImportError: - SVGSUPPORT = False - -import cclicense +from modules.argumentparser import parse_arguments, Args +from modules.svg_handling import icons, webmanifest +from modules.generate_html import list_folder, EXCLUDES # fmt: off # Constants STATIC_FILES_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "files") -FAVICON_PATH = ".static/favicon.ico" -GLOBAL_CSS_PATH = ".static/global.css" -DEFAULT_THEME_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), "themes", "default.css") -DEFAULT_AUTHOR = "Author" -VERSION = "1.10.0" -RAW_EXTENSIONS = [".3fr", ".ari", ".arw", ".bay", ".braw", ".crw", ".cr2", ".cr3", ".cap", ".data", ".dcs", ".dcr", ".dng", ".drf", ".eip", ".erf", ".fff", ".gpr", ".iiq", ".k25", ".kdc", ".mdc", ".mef", ".mos", ".mrw", ".nef", ".nrw", ".obm", ".orf", ".pef", ".ptx", ".pxn", ".r3d", ".raf", ".raw", ".rwl", ".rw2", ".rwz", ".sr2", ".srf", ".srw", ".tif", ".tiff", ".x3f"] -IMG_EXTENSIONS = [".jpg", ".jpeg"] -EXCLUDES = [".lock", "index.html", "manifest.json", ".sizelist.json", ".thumbnails", ".static"] +VERSION = "2.0.0" +RAW_EXTENSIONS = [ + ".3fr", ".ari", ".arw", ".bay", ".braw", ".crw", ".cr2", ".cr3", ".cap", ".data", ".dcs", ".dcr", + ".dng", ".drf", ".eip", ".erf", ".fff", ".gpr", ".iiq", ".k25", ".kdc", ".mdc", ".mef", ".mos", + ".mrw", ".nef", ".nrw", ".obm", ".orf", ".pef", ".ptx", ".pxn", ".r3d", ".raf", ".raw", ".rwl", + ".rw2", ".rwz", ".sr2", ".srf", ".srw", ".tif", ".tiff", ".x3f" +] +IMG_EXTENSIONS = [".jpg", ".jpeg", ".png"] NOT_LIST = ["*/Galleries/*", "Archives"] -ICON_SIZES = ["36x36", "48x48", "72x72", "96x96", "144x144", "192x192", "512x512"] # fmt: on -# Initialize Jinja2 environment -env = Environment(loader=FileSystemLoader(os.path.join(os.path.abspath(os.path.dirname(__file__)), "templates"))) -thumbnails: List[Tuple[str, str]] = [] -info: Dict[str, str] = {} pbardict: Dict[str, tqdm] = {} -class Icon: - src: str - type: str - sizes: str - purpose: str +def init_globals(_args: Args, raw: List[str]) -> Tuple[Args, List[str]]: + """ + Initialize global variables and set default values for arguments. + Parameters: + ----------- + _args : Args + Parsed command-line arguments. + raw : List[str] + List of raw file extensions. -class Args: - author_name: str - exclude_folders: List[str] - file_extensions: List[str] - generate_webmanifest: bool - ignore_other_files: bool - license_type: Optional[str] - non_interactive_mode: bool - regenerate_thumbnails: bool - root_directory: str - site_title: str - theme_path: str - use_fancy_folders: bool - web_root_url: str - - -# fmt: off -def parse_arguments() -> Args: - parser = argparse.ArgumentParser(description="Generate HTML files for a static image hosting website.", formatter_class=RichHelpFormatter) - parser.add_argument("--exclude-folder", help="Folders to exclude from processing, globs supported (can be specified multiple times).", action="append", dest="exclude_folders", metavar="FOLDER") - parser.add_argument("--generate-help-preview", action=HelpPreviewAction, path="help.svg") - parser.add_argument("--ignore-other-files", help="Ignore files that do not match the specified extensions.", action="store_true", default=False, dest="ignore_other_files") - parser.add_argument("--theme-path", help="Path to the CSS theme file.", default=DEFAULT_THEME_PATH, type=str, dest="theme_path", metavar="PATH") - parser.add_argument("--use-fancy-folders", help="Enable fancy folder view instead of the default Apache directory listing.", action="store_true", default=False, dest="use_fancy_folders") - parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") - parser.add_argument("-a", "--author-name", help="Name of the author of the images.", default=DEFAULT_AUTHOR, type=str, dest="author_name", metavar="AUTHOR") - parser.add_argument("-e", "--file-extensions", help="File extensions to include (can be specified multiple times).", action="append", dest="file_extensions", metavar="EXTENSION") - parser.add_argument("-l", "--license-type", help="Specify the license type for the images.", choices=["cc-zero", "cc-by", "cc-by-sa", "cc-by-nd", "cc-by-nc", "cc-by-nc-sa", "cc-by-nc-nd"], default=None, dest="license_type", metavar="LICENSE") - parser.add_argument("-m", "--web-manifest", help="Generate a web manifest file.", action="store_true", default=False, dest="generate_webmanifest") - parser.add_argument("-n", "--non-interactive-mode", help="Run in non-interactive mode, disabling progress bars.", action="store_true", default=False, dest="non_interactive_mode") - parser.add_argument("-p", "--root-directory", help="Root directory containing the images.", required=True, type=str, dest="root_directory", metavar="ROOT") - parser.add_argument("-r", "--regenerate-thumbnails", help="Regenerate thumbnails even if they already exist.", action="store_true", default=False, dest="regenerate_thumbnails") - parser.add_argument("-t", "--site-title", help="Title of the image hosting site.", required=True, type=str, dest="site_title", metavar="TITLE") - parser.add_argument("-w", "--web-root-url", help="Base URL of the web root for the image hosting site.", required=True, type=str, dest="web_root_url", metavar="URL") - parsed_args = parser.parse_args() - _args = Args() - _args.author_name = parsed_args.author_name - _args.exclude_folders = parsed_args.exclude_folders - _args.file_extensions = parsed_args.file_extensions - _args.generate_webmanifest = parsed_args.generate_webmanifest - _args.ignore_other_files = parsed_args.ignore_other_files - _args.license_type = parsed_args.license_type - _args.non_interactive_mode = parsed_args.non_interactive_mode - _args.regenerate_thumbnails = parsed_args.regenerate_thumbnails - _args.root_directory = parsed_args.root_directory - _args.site_title = parsed_args.site_title - _args.theme_path = parsed_args.theme_path - _args.use_fancy_folders = parsed_args.use_fancy_folders - _args.web_root_url = parsed_args.web_root_url - return _args -# fmt: on - - -def init_globals(_args: Args, raw: list[str]) -> Args: + Returns: + -------- + Tuple[Args, List[str]] + Updated arguments and raw file extensions. + """ if not _args.file_extensions: _args.file_extensions = IMG_EXTENSIONS if not _args.exclude_folders: @@ -121,315 +59,105 @@ def init_globals(_args: Args, raw: list[str]) -> Args: def copy_static_files(_args: Args) -> None: - if os.path.exists(os.path.join(_args.root_directory, ".static")): + """ + Copy static files to the root directory. + + Parameters: + ----------- + _args : Args + Parsed command-line arguments. + """ + static_dir = os.path.join(_args.root_directory, ".static") + if os.path.exists(static_dir): print("Removing existing .static folder...") - shutil.rmtree(os.path.join(_args.root_directory, ".static")) - if not os.path.exists(os.path.join(_args.root_directory, ".static")): - print("Copying static files...") - shutil.copytree(STATIC_FILES_DIR, os.path.join(_args.root_directory, ".static"), dirs_exist_ok=True) - shutil.copyfile(_args.theme_path, os.path.join(_args.root_directory, ".static", "theme.css")) + shutil.rmtree(static_dir) - -def icons(_args: Args) -> None: - print("Generating icons...") - pattern = r"--color([1-4]):\s*(#[0-9a-f]+);" - colorscheme = {} - iconspath = os.path.join(_args.root_directory, ".static", "icons") - with open(_args.theme_path, "r") as f: - filecontent = f.read() - matches = re.findall(pattern, filecontent) - for match in matches: - colorscheme["color" + match[0]] = match[1] - svg = env.get_template("icon.svg.j2") - content = svg.render(colorscheme=colorscheme) - with open(os.path.join(iconspath, "icon.svg"), "w+") as f: - f.write(content) - if not SVGSUPPORT: - print("Please install cairosvg to generate favicon from svg icon.") - return - tmpimg = BytesIO() - cairosvg.svg2png(bytestring=content, write_to=tmpimg) - with Image.open(tmpimg) as iconfile: - iconfile.save(os.path.join(iconspath, "icon.png")) - if shutil.which("magick"): - os.system( - f'magick {os.path.join(iconspath, "icon.png")} -define icon:auto-resize=16,32,48,64,72,96,144,192 {os.path.join(_args.root_directory, ".static", "favicon.ico")}' - ) - else: - os.system( - f'convert {os.path.join(iconspath, "icon.png")} -define icon:auto-resize=16,32,48,64,72,96,144,192 {os.path.join(_args.root_directory, ".static", "favicon.ico")}' - ) - - -def webmanifest(_args: Args) -> None: - icons: List[Icon] = [] - files = os.listdir(os.path.join(_args.root_directory, ".static", "icons")) - if SVGSUPPORT and any(file.endswith(".svg") for file in files): - svg = [file for file in files if file.endswith(".svg")][0] - icons.append( - {"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "maskable"} - ) - icons.append({"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "any"}) - for size in ICON_SIZES: - tmpimg = BytesIO() - sizes = size.split("x") - iconpath = os.path.join(_args.root_directory, ".static", "icons", os.path.splitext(svg)[0] + "-" + size + ".png") - cairosvg.svg2png( - url=os.path.join(_args.root_directory, ".static", "icons", svg), - write_to=tmpimg, - output_width=int(sizes[0]), - output_height=int(sizes[1]), - scale=1, - ) - with Image.open(tmpimg) as iconfile: - iconfile.save(iconpath, format="PNG") - icons.append( - { - "src": f"{_args.web_root_url}.static/icons/{os.path.splitext(svg)[0]}-{size}.png", - "sizes": size, - "type": "image/png", - "purpose": "maskable", - } - ) - icons.append( - { - "src": f"{_args.web_root_url}.static/icons/{os.path.splitext(svg)[0]}-{size}.png", - "sizes": size, - "type": "image/png", - "purpose": "any", - } - ) - else: - for icon in os.listdir(os.path.join(STATIC_FILES_DIR, "icons")): - if not icon.endswith(".png"): - continue - with Image.open(os.path.join(STATIC_FILES_DIR, "icons", icon)) as iconfile: - iconsize = f"{iconfile.size[0]}x{iconfile.size[1]}" - icons.append( - {"src": f"{_args.web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "maskable"} - ) - icons.append({"src": f"{_args.web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "any"}) - if len(icons) == 0: - print("No icons found in the static/icons folder!") - return - - with open(os.path.join(_args.root_directory, ".static", "theme.css"), "r", encoding="utf-8") as f: - content = f.read() - background_color = ( - content.replace("body{", "body {").split("body {")[1].split("}")[0].split("background-color:")[1].split(";")[0].strip() - ) - theme_color = ( - content.replace(".navbar{", "navbar {").split(".navbar {")[1].split("}")[0].split("background-color:")[1].split(";")[0].strip() - ) - with open(os.path.join(_args.root_directory, ".static", "manifest.json"), "w", encoding="utf-8") as f: - manifest = env.get_template("manifest.json.j2") - content = manifest.render( - name=_args.web_root_url.replace("https://", "").replace("http://", "").replace("/", ""), - short_name=_args.site_title, - icons=icons, - background_color=background_color, - theme_color=theme_color, - ) - f.write(content) + print("Copying static files...") + shutil.copytree(STATIC_FILES_DIR, static_dir, dirs_exist_ok=True) + shutil.copyfile(_args.theme_path, os.path.join(static_dir, "theme.css")) def generate_thumbnail(arguments: Tuple[str, str, str, bool]) -> None: + """ + Generate a thumbnail for a given image. + + Parameters: + ----------- + arguments : Tuple[str, str, str, bool] + A tuple containing the folder, item, root directory, and regenerate thumbnails flag. + """ folder, item, root_directory, regenerate_thumbnails = arguments - path = os.path.join(root_directory, ".thumbnails", folder.removeprefix(root_directory), os.path.splitext(item)[0]) + ".jpg" + path = os.path.join(root_directory, ".thumbnails", folder.removeprefix(root_directory), item) + ".jpg" + oldpath = os.path.join(root_directory, ".thumbnails", folder.removeprefix(root_directory), os.path.splitext(item)[0]) + ".jpg" + if os.path.exists(oldpath): + try: + os.rename(oldpath, path) + except FileNotFoundError: + pass if not os.path.exists(path) or regenerate_thumbnails: if os.path.exists(path): os.remove(path) try: with Image.open(os.path.join(folder, item)) as imgfile: - img = ImageOps.exif_transpose(imgfile) + imgrgb = imgfile.convert("RGB") + img = ImageOps.exif_transpose(imgrgb) img.thumbnail((512, 512)) - img.save(path, "JPEG", quality=75, optimize=True) + img.save(path, "JPEG", quality=75, optimize=True, mode="RGB") except OSError: print(f"Failed to generate thumbnail for {os.path.join(folder, item)}") def get_total_folders(folder: str, _args: Args, _total: int = 0) -> int: + """ + Recursively count the total number of folders to be processed. + Parameters: + ----------- + folder : str + The current folder being processed. + _args : Args + Parsed command-line arguments. + _total : int, optional + The running total of folders, default is 0. + + Returns: + -------- + int + The total number of folders. + """ _total += 1 - pbardict["traversingbar"].desc = f"Traversing filesystem - {folder}" pbardict["traversingbar"].update(1) - items = os.listdir(folder) - items.sort() + items = sorted(os.listdir(folder)) for item in items: - if item not in EXCLUDES: - if os.path.isdir(os.path.join(folder, item)): - if item not in _args.exclude_folders: - skip = False - for exclude in _args.exclude_folders: - if fnmatch.fnmatchcase(os.path.join(folder, item), exclude): - skip = True - if not skip: - _total = get_total_folders(os.path.join(folder, item), _args, _total) + if item not in EXCLUDES and os.path.isdir(os.path.join(folder, item)): + if item not in _args.exclude_folders and not any( + fnmatch.fnmatchcase(os.path.join(folder, item), exclude) for exclude in _args.exclude_folders + ): + _total = get_total_folders(os.path.join(folder, item), _args, _total) return _total -def list_folder(folder: str, title: str, _args: Args, raw: list[str]) -> None: - sizelist: Dict[Dict[str, int], Dict[str, int]] = {} - if not os.path.exists(os.path.join(folder, ".sizelist.json")): - sizelistfile = open(os.path.join(folder, ".sizelist.json"), "x", encoding="utf-8") - sizelistfile.write("{}") - sizelistfile.close() - with open(os.path.join(folder, ".sizelist.json"), "r+", encoding="utf-8") as sizelistfile: - sizelist = json.loads(sizelistfile.read()) - items = os.listdir(folder) - items.sort() - images: List[Dict[str, Any]] = [] - subfolders: List[Dict[str, str]] = [] - foldername = folder.removeprefix(_args.root_directory) - foldername = f"{foldername}/" if foldername else "" - baseurl = urllib.parse.quote(foldername) - if not os.path.exists(os.path.join(_args.root_directory, ".thumbnails", foldername)): - os.mkdir(os.path.join(_args.root_directory, ".thumbnails", foldername)) - contains_files = False - if not _args.non_interactive_mode: - pbardict[folder] = tqdm(total=len(items), desc=f"Getting image infos - {folder}", unit="files", ascii=True, dynamic_ncols=True) - for item in items: - if item not in EXCLUDES: - if os.path.isdir(os.path.join(folder, item)): - if _args.web_root_url.startswith("file://"): - subfolder = {"url": f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}/index.html", "name": item} - else: - subfolder = {"url": f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}", "name": item} - subfolders.append(subfolder) - if item not in _args.exclude_folders: - skip = False - for exclude in _args.exclude_folders: - if fnmatch.fnmatchcase(os.path.join(folder, item), exclude): - skip = True - if not skip: - list_folder( - os.path.join(folder, item), os.path.join(folder, item).removeprefix(_args.root_directory), _args, raw - ) - else: - extsplit = os.path.splitext(item) - contains_files = True - if extsplit[1].lower() in _args.file_extensions: - if not sizelist.get(item) or _args.regenerate_thumbnails: - exifdata = {} - with Image.open(os.path.join(folder, item)) as img: - exif = img.getexif() - width, height = img.size - - for key, val in exif.items(): - if key in ExifTags.TAGS: - exifdata[ExifTags.TAGS[key]] = val - else: - exifdata[key] = val - if "Orientation" in exifdata and (exifdata["Orientation"] == 6 or exifdata["Orientation"] == 8): - sizelist[item] = {"width": height, "height": width} - else: - sizelist[item] = {"width": width, "height": height} - - image = { - "url": f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}", - "thumbnail": f"{_args.web_root_url}.thumbnails/{baseurl}{urllib.parse.quote(extsplit[0])}.jpg", - "name": item, - "width": sizelist[item]["width"], - "height": sizelist[item]["height"], - } - if not os.path.exists(os.path.join(_args.root_directory, ".thumbnails", foldername, item)): - thumbnails.append((folder, item, _args.root_directory, _args.regenerate_thumbnails)) - for _raw in raw: - if os.path.exists(os.path.join(folder, extsplit[0] + _raw)): - url = urllib.parse.quote(extsplit[0]) + _raw - if _raw in (".tif", ".tiff"): - image["tiff"] = f"{_args.web_root_url}{baseurl}{url}" - else: - image["raw"] = f"{_args.web_root_url}{baseurl}{url}" - images.append(image) - if item == "info": - with open(os.path.join(folder, item), encoding="utf-8") as f: - _info = f.read() - info[urllib.parse.quote(folder)] = _info - if not _args.non_interactive_mode: - pbardict[folder].update(1) - pbardict["htmlbar"].update(0) - if not _args.non_interactive_mode: - pbardict[folder].close() - sizelistfile.seek(0) - sizelistfile.write(json.dumps(sizelist, indent=4)) - sizelistfile.truncate() - if os.path.exists(os.path.join(folder, ".sizelist.json")) and sizelist == {}: - os.remove(os.path.join(folder, ".sizelist.json")) - if not contains_files and not _args.use_fancy_folders: - return - if images or (_args.use_fancy_folders and not contains_files) or (_args.use_fancy_folders and _args.ignore_other_files): - image_chunks = np.array_split(images, 8) if images else [] - with open(os.path.join(folder, "index.html"), "w", encoding="utf-8") as f: - _info: List[str] = None - header = os.path.basename(folder) or title - parent = ( - None - if not foldername - else f"{_args.web_root_url}{urllib.parse.quote(foldername.removesuffix(folder.split('/')[-1] + '/'))}" - ) - if parent and _args.web_root_url.startswith("file://"): - parent += "index.html" - license_info: cclicense.License = ( - { - "project": _args.site_title, - "author": _args.author_name, - "type": cclicense.licensenameswitch(_args.license_type), - "url": cclicense.licenseurlswitch(_args.license_type), - "pics": cclicense.licensepicswitch(_args.license_type), - } - if _args.license_type - else None - ) - if urllib.parse.quote(folder) in info: - _info = [] - _infolst = info[urllib.parse.quote(folder)].split("\n") - for i in _infolst: - if len(i) > 1: - _info.append(i) - html = env.get_template("index.html.j2") - content = html.render( - title=title, - favicon=f"{_args.web_root_url}{FAVICON_PATH}", - stylesheet=f"{_args.web_root_url}{GLOBAL_CSS_PATH}", - theme=f"{_args.web_root_url}.static/theme.css", - root=_args.web_root_url, - parent=parent, - header=header, - license=license_info, - subdirectories=subfolders, - images=image_chunks, - info=_info, - allimages=images, - webmanifest=_args.generate_webmanifest, - ) - f.write(content) - else: - if os.path.exists(os.path.join(folder, "index.html")): - os.remove(os.path.join(folder, "index.html")) - if os.path.exists(os.path.join(folder, ".sizelist.json")): - os.remove(os.path.join(folder, ".sizelist.json")) - if not _args.non_interactive_mode: - pbardict["htmlbar"].update(1) - - def main() -> None: - args = parse_arguments() + """ + Main function to process images and generate a static image hosting website. + """ + thumbnails: List[Tuple[str, str, str, bool]] = [] + + args = parse_arguments(VERSION) args, raw = init_globals(args, RAW_EXTENSIONS) - if os.path.exists(os.path.join(args.root_directory, ".lock")): + lock_file = os.path.join(args.root_directory, ".lock") + if os.path.exists(lock_file): print("Another instance of this program is running.") exit() try: - Path(os.path.join(args.root_directory, ".lock")).touch() - if not os.path.exists(os.path.join(args.root_directory, ".thumbnails")): - os.mkdir(os.path.join(args.root_directory, ".thumbnails")) + Path(lock_file).touch() + os.makedirs(os.path.join(args.root_directory, ".thumbnails"), exist_ok=True) copy_static_files(args) - icons(args) if args.generate_webmanifest: @@ -438,7 +166,7 @@ def main() -> None: if args.non_interactive_mode: print("Generating HTML files...") - list_folder(args.root_directory, args.site_title, args, raw) + thumbnails = list_folder(0, args.root_directory, args.site_title, args, raw) with Pool(os.cpu_count()) as pool: print("Generating thumbnails...") pool.map(generate_thumbnail, thumbnails) @@ -449,9 +177,7 @@ def main() -> None: pbardict["traversingbar"].update(0) pbardict["traversingbar"].close() - pbardict["htmlbar"] = tqdm(total=total, desc="Generating HTML files", unit="folders", ascii=True, dynamic_ncols=True) - list_folder(args.root_directory, args.site_title, args, raw) - pbardict["htmlbar"].close() + thumbnails = list_folder(total, args.root_directory, args.site_title, args, raw) with Pool(os.cpu_count()) as pool: for _ in tqdm( @@ -464,7 +190,8 @@ def main() -> None: ): pass finally: - os.remove(os.path.join(args.root_directory, ".lock")) + os.remove(lock_file) + return if __name__ == "__main__": diff --git a/modules/argumentparser.py b/modules/argumentparser.py new file mode 100644 index 0000000..1b30371 --- /dev/null +++ b/modules/argumentparser.py @@ -0,0 +1,104 @@ +from typing import List, Optional +import os +import argparse +from rich_argparse import RichHelpFormatter, HelpPreviewAction + + +DEFAULT_THEME_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "themes", "default.css") +DEFAULT_AUTHOR = "Author" + + +class Args: + """ + A class to store command-line arguments for the script. + + Attributes: + ----------- + author_name : str + The name of the author of the images. + exclude_folders : List[str] + A list of folders to exclude from processing. + file_extensions : List[str] + A list of file extensions to include. + generate_webmanifest : bool + Whether to generate a web manifest file. + ignore_other_files : bool + Whether to ignore files that do not match the specified extensions. + license_type : Optional[str] + The type of license for the images. + non_interactive_mode : bool + Whether to run in non-interactive mode. + regenerate_thumbnails : bool + Whether to regenerate thumbnails even if they already exist. + root_directory : str + The root directory containing the images. + site_title : str + The title of the image hosting site. + theme_path : str + The path to the CSS theme file. + use_fancy_folders : bool + Whether to enable fancy folder view. + web_root_url : str + The base URL of the web root for the image hosting site. + """ + author_name: str + exclude_folders: List[str] + file_extensions: List[str] + generate_webmanifest: bool + ignore_other_files: bool + license_type: Optional[str] + non_interactive_mode: bool + regenerate_thumbnails: bool + root_directory: str + site_title: str + theme_path: str + use_fancy_folders: bool + web_root_url: str + + +def parse_arguments(version: str) -> Args: + """ + Parse command-line arguments. + + Parameters: + ----------- + version : str + The version of the program. + + Returns: + -------- + Args + An instance of the Args class containing the parsed arguments. + """ + parser = argparse.ArgumentParser(description="Generate HTML files for a static image hosting website.", formatter_class=RichHelpFormatter) + parser.add_argument("--exclude-folder", help="Folders to exclude from processing, globs supported (can be specified multiple times).", action="append", dest="exclude_folders", metavar="FOLDER") + parser.add_argument("--generate-help-preview", action=HelpPreviewAction, path="help.svg") + parser.add_argument("--ignore-other-files", help="Ignore files that do not match the specified extensions.", action="store_true", default=False, dest="ignore_other_files") + parser.add_argument("--theme-path", help="Path to the CSS theme file.", default=DEFAULT_THEME_PATH, type=str, dest="theme_path", metavar="PATH") + parser.add_argument("--use-fancy-folders", help="Enable fancy folder view instead of the default Apache directory listing.", action="store_true", default=False, dest="use_fancy_folders") + parser.add_argument("--version", action="version", version=f"%(prog)s {version}") + parser.add_argument("-a", "--author-name", help="Name of the author of the images.", default=DEFAULT_AUTHOR, type=str, dest="author_name", metavar="AUTHOR") + parser.add_argument("-e", "--file-extensions", help="File extensions to include (can be specified multiple times).", action="append", dest="file_extensions", metavar="EXTENSION") + parser.add_argument("-l", "--license-type", help="Specify the license type for the images.", choices=["cc-zero", "cc-by", "cc-by-sa", "cc-by-nd", "cc-by-nc", "cc-by-nc-sa", "cc-by-nc-nd"], default=None, dest="license_type", metavar="LICENSE") + parser.add_argument("-m", "--web-manifest", help="Generate a web manifest file.", action="store_true", default=False, dest="generate_webmanifest") + parser.add_argument("-n", "--non-interactive-mode", help="Run in non-interactive mode, disabling progress bars.", action="store_true", default=False, dest="non_interactive_mode") + parser.add_argument("-p", "--root-directory", help="Root directory containing the images.", required=True, type=str, dest="root_directory", metavar="ROOT") + parser.add_argument("-r", "--regenerate-thumbnails", help="Regenerate thumbnails even if they already exist.", action="store_true", default=False, dest="regenerate_thumbnails") + parser.add_argument("-t", "--site-title", help="Title of the image hosting site.", required=True, type=str, dest="site_title", metavar="TITLE") + parser.add_argument("-w", "--web-root-url", help="Base URL of the web root for the image hosting site.", required=True, type=str, dest="web_root_url", metavar="URL") + parsed_args = parser.parse_args() + _args = Args() + _args.author_name = parsed_args.author_name + _args.exclude_folders = parsed_args.exclude_folders + _args.file_extensions = parsed_args.file_extensions + _args.generate_webmanifest = parsed_args.generate_webmanifest + _args.ignore_other_files = parsed_args.ignore_other_files + _args.license_type = parsed_args.license_type + _args.non_interactive_mode = parsed_args.non_interactive_mode + _args.regenerate_thumbnails = parsed_args.regenerate_thumbnails + _args.root_directory = parsed_args.root_directory + _args.site_title = parsed_args.site_title + _args.theme_path = parsed_args.theme_path + _args.use_fancy_folders = parsed_args.use_fancy_folders + _args.web_root_url = parsed_args.web_root_url + return _args diff --git a/cclicense.py b/modules/cclicense.py similarity index 71% rename from cclicense.py rename to modules/cclicense.py index bb95454..87966bc 100644 --- a/cclicense.py +++ b/modules/cclicense.py @@ -1,4 +1,21 @@ class License: + """ + A class to represent a Creative Commons license. + + Attributes: + ----------- + project : str + The name of the project. + author : str + The author of the work. + type : str + The type of the license. + url : str + The URL of the license. + pics : list of str + A list of URLs to the license images. + """ + project: str author: str type: str @@ -7,6 +24,19 @@ class License: def licenseurlswitch(cclicense: str) -> str: + """ + Get the URL for a given Creative Commons license type. + + Parameters: + ----------- + cclincense : str + The license type identifier. + + Returns: + -------- + str + The URL associated with the specified license type. + """ switch = { "cc-zero": "https://creativecommons.org/publicdomain/zero/1.0/", "cc-by": "https://creativecommons.org/licenses/by/4.0/", @@ -21,6 +51,19 @@ def licenseurlswitch(cclicense: str) -> str: def licensenameswitch(cclicense: str) -> str: + """ + Get the name for a given Creative Commons license type. + + Parameters: + ----------- + cclincense : str + The license type identifier. + + Returns: + -------- + str + The name associated with the specified license type. + """ switch = { "cc-zero": "CC0 1.0", "cc-by": "CC BY 4.0", @@ -35,6 +78,19 @@ def licensenameswitch(cclicense: str) -> str: def licensepicswitch(cclicense: str) -> list[str]: + """ + Get the list of image URLs for a given Creative Commons license type. + + Parameters: + ----------- + cclincense : str + The license type identifier. + + Returns: + -------- + list of str + A list of URLs to the license images. + """ switch = { "cc-zero": [ "https://mirrors.creativecommons.org/presskit/icons/cc.svg", diff --git a/modules/generate_html.py b/modules/generate_html.py new file mode 100644 index 0000000..9475b27 --- /dev/null +++ b/modules/generate_html.py @@ -0,0 +1,313 @@ +import os +import urllib.parse +import fnmatch +import json +from typing import Any, Dict, List, Tuple + +import numpy as np +from tqdm.auto import tqdm +from PIL import Image, ExifTags +from jinja2 import Environment, FileSystemLoader + +import modules.cclicense as cclicense +from modules.argumentparser import Args + +# Constants for file paths and exclusions +FAVICON_PATH = ".static/favicon.ico" +GLOBAL_CSS_PATH = ".static/global.css" +EXCLUDES = [".lock", "index.html", "manifest.json", ".sizelist.json", ".thumbnails", ".static"] + +# Set the maximum image pixels to prevent decompression bomb DOS attacks +Image.MAX_IMAGE_PIXELS = 933120000 + +# Initialize Jinja2 environment for template rendering +env = Environment(loader=FileSystemLoader(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "templates"))) +thumbnails: List[Tuple[str, str]] = [] +info: Dict[str, str] = {} +pbardict: Dict[str, tqdm] = {} + + +def initialize_sizelist(folder: str) -> Dict[str, Dict[str, int]]: + """ + Initializes the size list JSON file if it doesn't exist. + + Args: + folder (str): The folder in which the size list file is located. + + Returns: + Dict[str, Dict[str, int]]: The size list dictionary. + """ + sizelist = {} + sizelist_path = os.path.join(folder, ".sizelist.json") + if not os.path.exists(sizelist_path): + with open(sizelist_path, "x", encoding="utf-8") as sizelistfile: + sizelistfile.write("{}") + with open(sizelist_path, "r+", encoding="utf-8") as sizelistfile: + try: + sizelist = json.loads(sizelistfile.read()) + except json.decoder.JSONDecodeError: + sizelist = {} + return sizelist + + +def update_sizelist(sizelist: Dict[str, Dict[str, int]], folder: str) -> None: + """ + Updates the size list JSON file. + + Args: + sizelist (Dict[str, Dict[str, int]]): The size list dictionary to be written to the file. + folder (str): The folder in which the size list file is located. + """ + sizelist_path = os.path.join(folder, ".sizelist.json") + if sizelist != {}: + with open(sizelist_path, "w", encoding="utf-8") as sizelistfile: + sizelistfile.write(json.dumps(sizelist, indent=4)) + else: + if os.path.exists(sizelist_path): + os.remove(sizelist_path) + + +def get_image_info(item: str, folder: str) -> Dict[str, Any]: + """ + Extracts image information and EXIF data. + + Args: + item (str): The image file name. + folder (str): The folder containing the image. + + Returns: + Dict[str, Any]: A dictionary containing image width, height, and EXIF data. + """ + with Image.open(os.path.join(folder, item)) as img: + exif = img.getexif() + width, height = img.size + exifdata = {ExifTags.TAGS.get(key, key): val for key, val in exif.items()} + if "Orientation" in exifdata and exifdata["Orientation"] in [6, 8]: + width, height = height, width + return {"width": width, "height": height} + + +def process_image(item: str, folder: str, _args: Args, baseurl: str, sizelist: Dict[str, Dict[str, int]], raw: List[str]) -> Dict[str, Any]: + """ + Processes an image and prepares its data for the HTML template. + + Args: + item (str): The image file name. + folder (str): The folder containing the image. + _args (Args): Parsed command line arguments. + baseurl (str): Base URL for the web root. + sizelist (Dict[str, Dict[str, int]]): Dictionary containing size information for images. + raw (List[str]): List of raw image file extensions. + + Returns: + Dict[str, Any]: Dictionary containing image details for HTML rendering. + """ + extsplit = os.path.splitext(item) + if item not in sizelist or _args.regenerate_thumbnails: + sizelist[item] = get_image_info(item, folder) + + image = { + "url": f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}", + "thumbnail": f"{_args.web_root_url}.thumbnails/{baseurl}{urllib.parse.quote(item)}.jpg", + "name": item, + "width": sizelist[item]["width"], + "height": sizelist[item]["height"], + } + if not os.path.exists(os.path.join(_args.root_directory, ".thumbnails", baseurl, item)): + thumbnails.append((folder, item, _args.root_directory, _args.regenerate_thumbnails)) + + for _raw in raw: + if os.path.exists(os.path.join(folder, extsplit[0] + _raw)): + url = urllib.parse.quote(extsplit[0]) + _raw + if _raw in (".tif", ".tiff"): + image["tiff"] = f"{_args.web_root_url}{baseurl}{url}" + else: + image["raw"] = f"{_args.web_root_url}{baseurl}{url}" + return image + + +def generate_html(folder: str, title: str, _args: Args, raw: List[str]) -> None: + """ + Generates HTML content for a folder of images. + + Args: + folder (str): The folder to generate HTML for. + title (str): The title of the HTML page. + _args (Args): Parsed command line arguments. + raw (List[str]): Raw image file names. + """ + sizelist = initialize_sizelist(folder) + items = sorted(os.listdir(folder)) + + images = [] + subfolders = [] + foldername = folder.removeprefix(_args.root_directory) + foldername = f"{foldername}/" if foldername else "" + baseurl = urllib.parse.quote(foldername) + + create_thumbnail_folder(foldername, _args.root_directory) + + if not _args.non_interactive_mode: + pbardict[folder] = tqdm(total=len(items), desc=f"Getting image infos - {folder}", unit="files", ascii=True, dynamic_ncols=True) + + for item in items: + if item not in EXCLUDES: + if os.path.isdir(os.path.join(folder, item)): + process_subfolder(item, folder, baseurl, subfolders, _args, raw) + else: + if os.path.splitext(item)[1].lower() in _args.file_extensions: + images.append(process_image(item, folder, _args, baseurl, sizelist, raw)) + if item == "info": + process_info_file(folder, item) + + if not _args.non_interactive_mode: + pbardict[folder].update(1) + + if not _args.non_interactive_mode: + pbardict[folder].close() + + update_sizelist(sizelist, folder) + + if should_generate_html(images, _args): + create_html_file(folder, title, foldername, images, subfolders, _args) + + if not _args.non_interactive_mode: + pbardict["htmlbar"].update(1) + + +def create_thumbnail_folder(foldername: str, root_directory: str) -> None: + """ + Creates a folder for thumbnails if it doesn't exist. + + Args: + foldername (str): The name of the folder. + root_directory (str): The root directory path. + """ + thumbnails_path = os.path.join(root_directory, ".thumbnails", foldername) + if not os.path.exists(thumbnails_path): + os.mkdir(thumbnails_path) + + +def process_subfolder(item: str, folder: str, baseurl: str, subfolders: List[Dict[str, str]], _args: Args, raw: List[str]) -> None: + """ + Processes a subfolder. + + Args: + item (str): The name of the subfolder. + folder (str): The parent folder containing the subfolder. + baseurl (str): Base URL for the web root. + subfolders (List[Dict[str, str]]): List to store subfolder details. + _args (Args): Parsed command line arguments. + raw (List[str]): Raw image file extensions. + """ + subfolder_url = ( + f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}/index.html" + if _args.web_root_url.startswith("file://") + else f"{_args.web_root_url}{baseurl}{urllib.parse.quote(item)}" + ) + subfolders.append({"url": subfolder_url, "name": item}) + if item not in _args.exclude_folders: + if not any(fnmatch.fnmatchcase(os.path.join(folder, item), exclude) for exclude in _args.exclude_folders): + generate_html(os.path.join(folder, item), os.path.join(folder, item).removeprefix(_args.root_directory), _args, raw) + + +def process_info_file(folder: str, item: str) -> None: + """ + Processes an info file. + + Args: + folder (str): The folder containing the info file. + item (str): The info file name. + """ + with open(os.path.join(folder, item), encoding="utf-8") as f: + info[urllib.parse.quote(folder)] = f.read() + + +def should_generate_html(images: List[Dict[str, Any]], _args: Args) -> bool: + """ + Determines if HTML should be generated. + + Args: + images (List[Dict[str, Any]]): List of images. + _args (Args): Parsed command line arguments. + + Returns: + bool: True if HTML should be generated, False otherwise. + """ + return images or (_args.use_fancy_folders and (not images or _args.ignore_other_files)) + + +def create_html_file( + folder: str, title: str, foldername: str, images: List[Dict[str, Any]], subfolders: List[Dict[str, str]], _args: Args +) -> None: + """ + Creates the HTML file using the template. + + Args: + folder (str): The folder to create the HTML file in. + title (str): The title of the HTML page. + foldername (str): The name of the folder. + images (List[Dict[str, Any]]): A list of images to include in the HTML. + subfolders (List[Dict[str, str]]): A list of subfolders to include in the HTML. + _args (Args): Parsed command line arguments. + """ + image_chunks = np.array_split(images, 8) if images else [] + header = os.path.basename(folder) or title + parent = None if not foldername else f"{_args.web_root_url}{urllib.parse.quote(foldername.removesuffix(folder.split('/')[-1] + '/'))}" + if parent and _args.web_root_url.startswith("file://"): + parent += "index.html" + + license_info = ( + { + "project": _args.site_title, + "author": _args.author_name, + "type": cclicense.licensenameswitch(_args.license_type), + "url": cclicense.licenseurlswitch(_args.license_type), + "pics": cclicense.licensepicswitch(_args.license_type), + } + if _args.license_type + else None + ) + + folder_info = info.get(urllib.parse.quote(folder), "").split("\n") + _info = [i for i in folder_info if len(i) > 1] if folder_info else None + + html = env.get_template("index.html.j2") + content = html.render( + title=title, + favicon=f"{_args.web_root_url}{FAVICON_PATH}", + stylesheet=f"{_args.web_root_url}{GLOBAL_CSS_PATH}", + theme=f"{_args.web_root_url}.static/theme.css", + root=_args.web_root_url, + parent=parent, + header=header, + license=license_info, + subdirectories=subfolders, + images=image_chunks, + info=_info, + allimages=images, + webmanifest=_args.generate_webmanifest, + ) + + with open(os.path.join(folder, "index.html"), "w", encoding="utf-8") as f: + f.write(content) + + +def list_folder(total: int, folder: str, title: str, _args: Args, raw: List[str]) -> List[Tuple[str, str]]: + """ + Lists and processes a folder, generating HTML files. + + Args: + total (int): Total number of folders to process. + folder (str): The folder to process. + title (str): The title of the HTML page. + _args (Args): Parsed command line arguments. + raw (List[str]): Raw image file names. + + Returns: + List[Tuple[str, str]]: List of thumbnails generated. + """ + if not _args.non_interactive_mode: + pbardict["htmlbar"] = tqdm(total=total, desc="Generating HTML files", unit="folders", ascii=True, dynamic_ncols=True) + generate_html(folder, title, _args, raw) + return thumbnails diff --git a/modules/svg_handling.py b/modules/svg_handling.py new file mode 100644 index 0000000..b73fd96 --- /dev/null +++ b/modules/svg_handling.py @@ -0,0 +1,286 @@ +import os +import re +import shutil +from typing import List, Dict +from PIL import Image +from jinja2 import Environment, FileSystemLoader + +# Attempt to import cairosvg for SVG support, set flag based on success +try: + import cairosvg + from io import BytesIO + + SVGSUPPORT = True +except ImportError: + SVGSUPPORT = False + +from modules.argumentparser import Args + +# Define constants for static files directory and icon sizes +STATIC_FILES_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "files") +ICON_SIZES = ["36x36", "48x48", "72x72", "96x96", "144x144", "192x192", "512x512"] + +# Initialize Jinja2 environment for template rendering +env = Environment(loader=FileSystemLoader(os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "templates"))) + + +class Icon: + src: str + type: str + sizes: str + purpose: str + + +def extract_colorscheme(theme_path: str) -> Dict[str, str]: + """ + Extract color scheme from a CSS theme file. + + Parameters: + ----------- + theme_path : str + Path to the CSS theme file. + + Returns: + -------- + Dict[str, str] + Dictionary containing color scheme variables and their values. + """ + pattern = r"--(color[1-4]|bcolor1):\s*(#[0-9a-fA-F]+);" + colorscheme = {} + with open(theme_path, "r", encoding="utf-8") as f: + filecontent = f.read() + matches = re.findall(pattern, filecontent) + for match in matches: + colorscheme[match[0]] = match[1] + return colorscheme + + +def extract_theme_color(theme_path: str) -> str: + """ + Extract the theme color from a CSS theme file. + + Parameters: + ----------- + theme_path : str + Path to the CSS theme file. + + Returns: + -------- + str + The theme color value. + """ + pattern = r"--bcolor1:\s*(#[0-9a-fA-F]+);" + with open(theme_path, "r", encoding="utf-8") as f: + filecontent = f.read() + match = re.search(pattern, filecontent) + return match.group(1) if match else "" + + +def render_svg_icon(colorscheme: Dict[str, str], iconspath: str) -> str: + """ + Render an SVG icon using the provided color scheme. + + Parameters: + ----------- + colorscheme : Dict[str, str] + Dictionary containing color scheme variables and their values. + iconspath : str + Path to the directory where the icon will be saved. + + Returns: + -------- + str + The rendered SVG content. + """ + svg = env.get_template("icon.svg.j2") + content = svg.render(colorscheme=colorscheme) + with open(os.path.join(iconspath, "icon.svg"), "w+", encoding="utf-8") as f: + f.write(content) + return content + + +def save_png_icon(content: str, iconspath: str) -> None: + """ + Save the rendered SVG content as a PNG icon. + + Parameters: + ----------- + content : str + The rendered SVG content. + iconspath : str + Path to the directory where the PNG icon will be saved. + """ + tmpimg = BytesIO() + cairosvg.svg2png(bytestring=content, write_to=tmpimg) + with Image.open(tmpimg) as iconfile: + iconfile.save(os.path.join(iconspath, "icon.png")) + + +def generate_favicon(iconspath: str, root_directory: str) -> None: + """ + Generate a favicon from a PNG icon using ImageMagick. + + Parameters: + ----------- + iconspath : str + Path to the directory containing the PNG icon. + root_directory : str + Root directory of the project where the favicon will be saved. + """ + command = f'magick {os.path.join(iconspath, "icon.png")} -define icon:auto-resize=16,32,48,64,72,96,144,192 {os.path.join(root_directory, ".static", "favicon.ico")}' + if not shutil.which("magick"): + command = f'convert {os.path.join(iconspath, "icon.png")} -define icon:auto-resize=16,32,48,64,72,96,144,192 {os.path.join(root_directory, ".static", "favicon.ico")}' + os.system(command) + + +def icons(_args: Args) -> None: + """ + Generate icons and save them in the static directory. + + Parameters: + ----------- + _args : Args + Parsed command-line arguments. + """ + print("Generating icons...") + iconspath = os.path.join(_args.root_directory, ".static", "icons") + colorscheme = extract_colorscheme(_args.theme_path) + content = render_svg_icon(colorscheme, iconspath) + if not SVGSUPPORT: + print("Please install cairosvg to generate favicon from svg icon.") + return + save_png_icon(content, iconspath) + generate_favicon(iconspath, _args.root_directory) + + +def render_manifest_json(_args: Args, icon_list: List[Icon], colors: Dict[str, str]) -> None: + """ + Render the manifest.json file for the web application. + + Parameters: + ----------- + _args : Args + Parsed command-line arguments. + icon_list : List[Icon] + List of icons to be included in the manifest. + colors : Dict[str, str] + Dictionary containing color scheme and theme color. + """ + manifest = env.get_template("manifest.json.j2") + content = manifest.render( + name=_args.web_root_url.replace("https://", "").replace("http://", "").replace("/", ""), + short_name=_args.site_title, + icons=icon_list, + background_color=colors["bcolor1"], + theme_color=colors["theme_color"], + ) + with open(os.path.join(_args.root_directory, ".static", "manifest.json"), "w", encoding="utf-8") as f: + f.write(content) + + +def create_icons_from_svg(files: List[str], iconspath: str, _args: Args) -> List[Icon]: + """ + Create icons from an SVG file. + + Parameters: + ----------- + files : List[str] + List of files in the icons directory. + iconspath : str + Path to the directory where the icons will be saved. + _args : Args + Parsed command-line arguments. + + Returns: + -------- + List[Icon] + List of icons created from the SVG file. + """ + svg = [file for file in files if file.endswith(".svg")][0] + icon_list = [ + {"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "maskable"}, + {"src": f"{_args.web_root_url}.static/icons/{svg}", "type": "image/svg+xml", "sizes": "512x512", "purpose": "any"}, + ] + for size in ICON_SIZES: + tmpimg = BytesIO() + sizes = size.split("x") + iconpath = os.path.join(iconspath, os.path.splitext(svg)[0] + "-" + size + ".png") + cairosvg.svg2png( + url=os.path.join(iconspath, svg), + write_to=tmpimg, + output_width=int(sizes[0]), + output_height=int(sizes[1]), + scale=1, + ) + with Image.open(tmpimg) as iconfile: + iconfile.save(iconpath, format="PNG") + icon_list.append( + { + "src": f"{_args.web_root_url}.static/icons/{os.path.splitext(svg)[0]}-{size}.png", + "sizes": size, + "type": "image/png", + "purpose": "maskable", + } + ) + icon_list.append( + { + "src": f"{_args.web_root_url}.static/icons/{os.path.splitext(svg)[0]}-{size}.png", + "sizes": size, + "type": "image/png", + "purpose": "any", + } + ) + return icon_list + + +def create_icons_from_png(iconspath: str, web_root_url: str) -> List[Icon]: + """ + Create icons from PNG files. + + Parameters: + ----------- + iconspath : str + Path to the directory containing the PNG icons. + web_root_url : str + Base URL of the web root for the image hosting site. + + Returns: + -------- + List[Icon] + List of icons created from PNG files. + """ + icon_list = [] + for icon in os.listdir(iconspath): + if not icon.endswith(".png"): + continue + with Image.open(os.path.join(iconspath, icon)) as iconfile: + iconsize = f"{iconfile.size[0]}x{iconfile.size[1]}" + icon_list.append({"src": f"{web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "maskable"}) + icon_list.append({"src": f"{web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "any"}) + return icon_list + + +def webmanifest(_args: Args) -> None: + """ + Generate the web manifest file for the application. + + Parameters: + ----------- + _args : Args + Parsed command-line arguments. + """ + iconspath = os.path.join(_args.root_directory, ".static", "icons") + files = os.listdir(iconspath) + icon_list = ( + create_icons_from_svg(files, iconspath, _args) + if SVGSUPPORT and any(file.endswith(".svg") for file in files) + else create_icons_from_png(iconspath, _args.web_root_url) + ) + + if not icon_list: + print("No icons found in the static/icons folder!") + return + + colorscheme = extract_colorscheme(os.path.join(_args.root_directory, ".static", "theme.css")) + colorscheme["theme_color"] = extract_theme_color(os.path.join(_args.root_directory, ".static", "theme.css")) + render_manifest_json(_args, icon_list, colorscheme)