10 Commits

Author SHA1 Message Date
8ec9701aa9 added json logger 2024-09-16 22:18:36 +02:00
a95a6e1722 added exifdata to sizelist 2024-09-16 13:10:57 +02:00
9de971d2ac Fixed package detection 2024-08-14 10:46:05 +02:00
e0cb13771c formatting 2024-07-24 17:39:47 +02:00
cd3f2959c0 upsii 2024-07-24 13:17:07 +02:00
285d286baf fixed dependency issue 2024-07-18 09:59:16 +02:00
08299abd2a added class constructor and regenerate now deletes .previews 2024-07-18 09:17:55 +02:00
d76d2e146d okidokili 2024-07-17 15:47:51 +02:00
f52b0e7778 idfk 2024-07-17 15:45:53 +02:00
d6a4e1cc82 please work 2024-07-17 15:39:15 +02:00
33 changed files with 296 additions and 69 deletions

View File

@@ -1,6 +1,6 @@
name: build-release name: build-release
run-name: build-release run-name: build-release
on: [push] on: push
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -15,6 +15,7 @@ jobs:
run: pyinstaller builder.py modules/*.py -n StaticGalleryBuilder -F --add-data files:files --add-data templates:templates --add-data .version:. run: pyinstaller builder.py modules/*.py -n StaticGalleryBuilder -F --add-data files:files --add-data templates:templates --add-data .version:.
- name: Release - name: Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with: with:
make_latest: true make_latest: true
generate_release_notes: true generate_release_notes: true

1
.gitignore vendored
View File

@@ -167,3 +167,4 @@ test/**/index.html
test/**/.sizelist.json test/**/.sizelist.json
test/manifest.json test/manifest.json
themes/previews themes/previews
logs

View File

@@ -1 +1 @@
2.2.5 2.3.0

View File

@@ -101,7 +101,7 @@
"editor.defaultFormatter": "ms-python.black-formatter", "editor.defaultFormatter": "ms-python.black-formatter",
}, },
"black-formatter.args": [ "black-formatter.args": [
"-l 140", "-l 260",
], ],
"black-formatter.interpreter": [ "black-formatter.interpreter": [
"/usr/bin/python3", "/usr/bin/python3",

View File

@@ -11,17 +11,21 @@ from typing import Dict, List, Tuple
from tqdm.auto import tqdm from tqdm.auto import tqdm
from PIL import Image, ImageOps from PIL import Image, ImageOps
from modules.logger import logger
from modules.argumentparser import parse_arguments, Args from modules.argumentparser import parse_arguments, Args
from modules.svg_handling import icons, webmanifest, extract_colorscheme from modules.svg_handling import icons, webmanifest, extract_colorscheme
from modules.generate_html import list_folder, EXCLUDES from modules.generate_html import list_folder, EXCLUDES
# fmt: off # fmt: off
# Constants # Constants
if __package__ == None: if __package__ is None:
__package__ = "" PACKAGE = ""
SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__).removesuffix(__package__)) else:
STATIC_FILES_DIR = os.path.join(os.path.abspath(SCRIPT_DIR), "files") PACKAGE = __package__
VERSION = open(os.path.join(SCRIPT_DIR, ".version"), "r", encoding="utf-8").read() SCRIPTDIR = os.path.abspath(os.path.dirname(__file__).removesuffix(PACKAGE))
STATIC_FILES_DIR = os.path.join(os.path.abspath(SCRIPTDIR), "files")
VERSION = open(os.path.join(SCRIPTDIR, ".version"), "r", encoding="utf-8").read()
RAW_EXTENSIONS = [ RAW_EXTENSIONS = [
".3fr", ".ari", ".arw", ".bay", ".braw", ".crw", ".cr2", ".cr3", ".cap", ".data", ".dcs", ".dcr", ".3fr", ".ari", ".arw", ".bay", ".braw", ".crw", ".cr2", ".cr3", ".cap", ".data", ".dcs", ".dcr",
".dng", ".drf", ".eip", ".erf", ".fff", ".gpr", ".iiq", ".k25", ".kdc", ".mdc", ".mef", ".mos", ".dng", ".drf", ".eip", ".erf", ".fff", ".gpr", ".iiq", ".k25", ".kdc", ".mdc", ".mef", ".mos",
@@ -30,6 +34,7 @@ RAW_EXTENSIONS = [
] ]
IMG_EXTENSIONS = [".jpg", ".jpeg", ".png"] IMG_EXTENSIONS = [".jpg", ".jpeg", ".png"]
NOT_LIST = ["*/Galleries/*", "Archives"] NOT_LIST = ["*/Galleries/*", "Archives"]
LOG_FILE = os.path.join(SCRIPTDIR, "log.json")
# fmt: on # fmt: on
pbardict: Dict[str, tqdm] = {} pbardict: Dict[str, tqdm] = {}
@@ -75,10 +80,13 @@ def copy_static_files(_args: Args) -> None:
static_dir = os.path.join(_args.root_directory, ".static") static_dir = os.path.join(_args.root_directory, ".static")
if os.path.exists(static_dir): if os.path.exists(static_dir):
print("Removing existing .static folder...") print("Removing existing .static folder...")
logger.info("removing existing .static folder")
shutil.rmtree(static_dir) shutil.rmtree(static_dir)
print("Copying static files...") print("Copying static files...")
logger.info("copying static files")
shutil.copytree(STATIC_FILES_DIR, static_dir, dirs_exist_ok=True) shutil.copytree(STATIC_FILES_DIR, static_dir, dirs_exist_ok=True)
logger.info("reading theme file", extra={"theme": _args.theme_path})
with open(_args.theme_path, "r", encoding="utf-8") as f: with open(_args.theme_path, "r", encoding="utf-8") as f:
theme = f.read() theme = f.read()
split = theme.split(".foldericon {") split = theme.split(".foldericon {")
@@ -90,20 +98,26 @@ def copy_static_files(_args: Args) -> None:
for match in re.finditer(r"content: (.*);", foldericon): for match in re.finditer(r"content: (.*);", foldericon):
foldericon = match[1] foldericon = match[1]
foldericon = foldericon.replace('"', "") foldericon = foldericon.replace('"', "")
logger.info("found foldericon", extra={"foldericon": foldericon})
break break
if "url" in foldericon: if "url" in foldericon:
shutil.copyfile(_args.theme_path, os.path.join(static_dir, "theme.css")) shutil.copyfile(_args.theme_path, os.path.join(static_dir, "theme.css"))
logger.info("foldericon in theme file, using it")
return return
with open(os.path.join(SCRIPT_DIR, foldericon), "r", encoding="utf-8") as f: with open(os.path.join(SCRIPTDIR, foldericon), "r", encoding="utf-8") as f:
logger.info("Reading foldericon svg")
svg = f.read() svg = f.read()
if "svg.j2" in foldericon: if "svg.j2" in foldericon:
logger.info("foldericon in theme file is a jinja2 template")
colorscheme = extract_colorscheme(_args.theme_path) colorscheme = extract_colorscheme(_args.theme_path)
svg = svg.replace("{{ color1 }}", colorscheme["color1"]) svg = svg.replace("{{ color1 }}", colorscheme["color1"])
svg = svg.replace("{{ color2 }}", colorscheme["color2"]) svg = svg.replace("{{ color2 }}", colorscheme["color2"])
svg = svg.replace("{{ color3 }}", colorscheme["color3"]) svg = svg.replace("{{ color3 }}", colorscheme["color3"])
svg = svg.replace("{{ color4 }}", colorscheme["color4"]) svg = svg.replace("{{ color4 }}", colorscheme["color4"])
logger.info("replaced colors in svg")
svg = urllib.parse.quote(svg) svg = urllib.parse.quote(svg)
with open(os.path.join(static_dir, "theme.css"), "x", encoding="utf-8") as f: with open(os.path.join(static_dir, "theme.css"), "x", encoding="utf-8") as f:
logger.info("writing theme file")
f.write(themehead + '\n.foldericon {\n content: url("data:image/svg+xml,' + svg + '");\n}\n' + themetail) f.write(themehead + '\n.foldericon {\n content: url("data:image/svg+xml,' + svg + '");\n}\n' + themetail)
@@ -117,6 +131,7 @@ def generate_thumbnail(arguments: Tuple[str, str, str]) -> None:
A tuple containing the folder, item, root directory, and regenerate thumbnails flag. A tuple containing the folder, item, root directory, and regenerate thumbnails flag.
""" """
folder, item, root_directory = arguments folder, item, root_directory = arguments
image = os.path.join(folder, item)
path = os.path.join(root_directory, ".thumbnails", folder.removeprefix(root_directory), item) + ".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" oldpath = os.path.join(root_directory, ".thumbnails", folder.removeprefix(root_directory), os.path.splitext(item)[0]) + ".jpg"
if os.path.exists(oldpath): if os.path.exists(oldpath):
@@ -125,14 +140,19 @@ def generate_thumbnail(arguments: Tuple[str, str, str]) -> None:
except FileNotFoundError: except FileNotFoundError:
pass pass
if not os.path.exists(path): if not os.path.exists(path):
logger.info("generating thumbnail for %s", item, extra={"path": image})
try: try:
with Image.open(os.path.join(folder, item)) as imgfile: with Image.open(image) as imgfile:
imgrgb = imgfile.convert("RGB") imgrgb = imgfile.convert("RGB")
img = ImageOps.exif_transpose(imgrgb) img = ImageOps.exif_transpose(imgrgb)
img.thumbnail((512, 512)) img.thumbnail((512, 512))
img.save(path, "JPEG", quality=75, optimize=True, mode="RGB") img.save(path, "JPEG", quality=75, optimize=True, mode="RGB")
except OSError: except OSError:
print(f"Failed to generate thumbnail for {os.path.join(folder, item)}") logger.error("Failed to generate thumbnail for %s", item, extra={"path": image})
print(f"Failed to generate thumbnail for {image}")
return
else:
logger.info("thumbnail already exists for %s", item, extra={"path": image})
def get_total_folders(folder: str, _args: Args, _total: int = 0) -> int: def get_total_folders(folder: str, _args: Args, _total: int = 0) -> int:
@@ -159,8 +179,9 @@ def get_total_folders(folder: str, _args: Args, _total: int = 0) -> int:
items = sorted(os.listdir(folder)) items = sorted(os.listdir(folder))
for item in items: for item in items:
if item not in EXCLUDES and os.path.isdir(os.path.join(folder, item)): if item not in EXCLUDES and os.path.isdir(os.path.join(folder, item)) and not item.startswith("."):
if item not in _args.exclude_folders and not any(fnmatch.fnmatchcase(os.path.join(folder, item), exclude) for exclude in _args.exclude_folders): if item not in _args.exclude_folders and not any(fnmatch.fnmatchcase(os.path.join(folder, item), exclude) for exclude in _args.exclude_folders):
logger.debug("Found folder %s in %s", item, folder)
_total = get_total_folders(os.path.join(folder, item), _args, _total) _total = get_total_folders(os.path.join(folder, item), _args, _total)
return _total return _total
@@ -177,27 +198,37 @@ def main() -> None:
lock_file = os.path.join(args.root_directory, ".lock") lock_file = os.path.join(args.root_directory, ".lock")
if os.path.exists(lock_file): if os.path.exists(lock_file):
print("Another instance of this program is running.") print("Another instance of this program is running.")
logger.info("nother instance of this program is running")
exit() exit()
try: try:
Path(lock_file).touch() Path(lock_file).touch()
if args.regenerate_thumbnails:
logger.warning("regenerate thumbnails flag is set to true, all thumbnails will be regenerated")
if os.path.exists(os.path.join(args.root_directory, ".thumbnails")):
logger.info("removing old thumbnails folder")
shutil.rmtree(os.path.join(args.root_directory, ".thumbnails"))
os.makedirs(os.path.join(args.root_directory, ".thumbnails"), exist_ok=True) os.makedirs(os.path.join(args.root_directory, ".thumbnails"), exist_ok=True)
copy_static_files(args) copy_static_files(args)
icons(args) icons(args)
if args.generate_webmanifest: if args.generate_webmanifest:
logger.info("generating webmanifest")
print("Generating webmanifest...") print("Generating webmanifest...")
webmanifest(args) webmanifest(args)
if args.non_interactive_mode: if args.non_interactive_mode:
logger.info("generating HTML files")
print("Generating HTML files...") print("Generating HTML files...")
thumbnails = list_folder(0, args.root_directory, args.site_title, args, raw, VERSION) thumbnails = list_folder(0, args.root_directory, args.site_title, args, raw, VERSION)
with Pool(os.cpu_count()) as pool: with Pool(os.cpu_count()) as pool:
logger.info("generating thumbnails")
print("Generating thumbnails...") print("Generating thumbnails...")
pool.map(generate_thumbnail, thumbnails) pool.map(generate_thumbnail, thumbnails)
else: else:
pbardict["traversingbar"] = tqdm(desc="Traversing filesystem", unit="folders", ascii=True, dynamic_ncols=True) pbardict["traversingbar"] = tqdm(desc="Traversing filesystem", unit="folders", ascii=True, dynamic_ncols=True)
logger.info("getting total number of folders to process")
total = get_total_folders(args.root_directory, args) total = get_total_folders(args.root_directory, args)
pbardict["traversingbar"].desc = "Traversing filesystem" pbardict["traversingbar"].desc = "Traversing filesystem"
pbardict["traversingbar"].update(0) pbardict["traversingbar"].update(0)
@@ -206,6 +237,7 @@ def main() -> None:
thumbnails = list_folder(total, args.root_directory, args.site_title, args, raw, VERSION) thumbnails = list_folder(total, args.root_directory, args.site_title, args, raw, VERSION)
with Pool(os.cpu_count()) as pool: with Pool(os.cpu_count()) as pool:
logger.info("generating thumbnails")
for _ in tqdm( for _ in tqdm(
pool.imap_unordered(generate_thumbnail, thumbnails), pool.imap_unordered(generate_thumbnail, thumbnails),
total=len(thumbnails), total=len(thumbnails),

View File

@@ -1,15 +1,21 @@
from dataclasses import dataclass
from typing import List, Optional from typing import List, Optional
import os import os
import argparse import argparse
from rich_argparse import RichHelpFormatter, HelpPreviewAction from rich_argparse import RichHelpFormatter, HelpPreviewAction
from modules.logger import logger
if __package__ == None: if __package__ is None:
__package__ = "" PACKAGE = ""
DEFAULT_THEME_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__).removesuffix(__package__)), "templates", "default.css") else:
PACKAGE = __package__
SCRIPTDIR = os.path.abspath(os.path.dirname(__file__).removesuffix(PACKAGE))
DEFAULT_THEME_PATH = os.path.join(SCRIPTDIR, "templates", "default.css")
DEFAULT_AUTHOR = "Author" DEFAULT_AUTHOR = "Author"
@dataclass(init=True)
class Args: class Args:
""" """
A class to store command-line arguments for the script. A class to store command-line arguments for the script.
@@ -58,6 +64,24 @@ class Args:
use_fancy_folders: bool use_fancy_folders: bool
web_root_url: str web_root_url: str
def to_dict(self) -> dict:
result: dict = {}
result["author_name"] = self.author_name
result["exclude_folders"] = self.exclude_folders
result["file_extensions"] = self.file_extensions
result["generate_webmanifest"] = self.generate_webmanifest
result["ignore_other_files"] = self.ignore_other_files
if self.license_type is not None:
result["license_type"] = self.license_type
result["non_interactive_mode"] = self.non_interactive_mode
result["regenerate_thumbnails"] = self.regenerate_thumbnails
result["root_directory"] = self.root_directory
result["site_title"] = self.site_title
result["theme_path"] = self.theme_path
result["use_fancy_folders"] = self.use_fancy_folders
result["web_root_url"] = self.web_root_url
return result
def parse_arguments(version: str) -> Args: def parse_arguments(version: str) -> Args:
""" """
@@ -73,6 +97,7 @@ def parse_arguments(version: str) -> Args:
Args Args
An instance of the Args class containing the parsed arguments. An instance of the Args class containing the parsed arguments.
""" """
# fmt: off
parser = argparse.ArgumentParser(description="Generate HTML files for a static image hosting website.", formatter_class=RichHelpFormatter) 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("--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("--generate-help-preview", action=HelpPreviewAction, path="help.svg")
@@ -90,18 +115,21 @@ def parse_arguments(version: str) -> Args:
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("-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") 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() parsed_args = parser.parse_args()
_args = Args() # fmt: on
_args.author_name = parsed_args.author_name _args = Args(
_args.exclude_folders = parsed_args.exclude_folders author_name=parsed_args.author_name,
_args.file_extensions = parsed_args.file_extensions exclude_folders=parsed_args.exclude_folders,
_args.generate_webmanifest = parsed_args.generate_webmanifest file_extensions=parsed_args.file_extensions,
_args.ignore_other_files = parsed_args.ignore_other_files generate_webmanifest=parsed_args.generate_webmanifest,
_args.license_type = parsed_args.license_type ignore_other_files=parsed_args.ignore_other_files,
_args.non_interactive_mode = parsed_args.non_interactive_mode license_type=parsed_args.license_type,
_args.regenerate_thumbnails = parsed_args.regenerate_thumbnails non_interactive_mode=parsed_args.non_interactive_mode,
_args.root_directory = parsed_args.root_directory regenerate_thumbnails=parsed_args.regenerate_thumbnails,
_args.site_title = parsed_args.site_title root_directory=parsed_args.root_directory,
_args.theme_path = parsed_args.theme_path site_title=parsed_args.site_title,
_args.use_fancy_folders = parsed_args.use_fancy_folders theme_path=parsed_args.theme_path,
_args.web_root_url = parsed_args.web_root_url use_fancy_folders=parsed_args.use_fancy_folders,
web_root_url=parsed_args.web_root_url,
)
logger.debug("parsed arguments", extra={"args": _args.to_dict()})
return _args return _args

View File

@@ -2,6 +2,8 @@ import re
import colorsys import colorsys
from typing import Dict from typing import Dict
from modules.logger import logger
def extract_colorscheme(theme_path: str) -> Dict[str, str]: def extract_colorscheme(theme_path: str) -> Dict[str, str]:
""" """
@@ -17,6 +19,7 @@ def extract_colorscheme(theme_path: str) -> Dict[str, str]:
Dict[str, str] Dict[str, str]
Dictionary containing color scheme variables and their hexadecimal values. Dictionary containing color scheme variables and their hexadecimal values.
""" """
logger.info("extracting color scheme from theme file", extra={"theme_path": theme_path})
pattern = r"--(color[1-4]|bcolor1):\s*(#[0-9a-fA-F]+|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+);" pattern = r"--(color[1-4]|bcolor1):\s*(#[0-9a-fA-F]+|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+);"
colorscheme = {} colorscheme = {}
@@ -30,6 +33,8 @@ def extract_colorscheme(theme_path: str) -> Dict[str, str]:
color_value = match[1] color_value = match[1]
hex_color_value = css_color_to_hex(color_value) hex_color_value = css_color_to_hex(color_value)
colorscheme[variable_name] = hex_color_value colorscheme[variable_name] = hex_color_value
logger.debug("extracted variable", extra={"variable": variable_name, "value": hex_color_value})
logger.info("extracted color scheme", extra={"colorscheme": colorscheme})
return colorscheme return colorscheme
@@ -86,10 +91,12 @@ def css_color_to_hex(css_color: str) -> str:
# Helper function to convert RGB tuple to hexadecimal string # Helper function to convert RGB tuple to hexadecimal string
def rgb_to_hex(rgb: tuple[int, int, int]) -> str: def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
logger.debug("converting rgb tuple to hex string", extra={"rgb": rgb})
return "#{:02x}{:02x}{:02x}".format(*rgb) return "#{:02x}{:02x}{:02x}".format(*rgb)
# Helper function to convert HSL tuple to RGB tuple # Helper function to convert HSL tuple to RGB tuple
def hsl_to_rgb(hsl: tuple[int, float, float]) -> tuple[int, int, int]: def hsl_to_rgb(hsl: tuple[int, float, float]) -> tuple[int, int, int]:
logger.debug("converting hsl tuple to rgb tuple", extra={"hsl": hsl})
return tuple(round(c * 255) for c in colorsys.hls_to_rgb(hsl[0] / 360, hsl[1] / 100, hsl[2] / 100)) return tuple(round(c * 255) for c in colorsys.hls_to_rgb(hsl[0] / 360, hsl[1] / 100, hsl[2] / 100))
# Regular expression pattern to match CSS colors # Regular expression pattern to match CSS colors
@@ -103,6 +110,7 @@ def css_color_to_hex(css_color: str) -> str:
match = color_pattern.match(css_color.strip()) match = color_pattern.match(css_color.strip())
if not match: if not match:
logger.error("invalid CSS color format", extra={"css_color": css_color})
raise ValueError("Invalid CSS color format") raise ValueError("Invalid CSS color format")
groups = match.groupdict() groups = match.groupdict()
@@ -119,8 +127,10 @@ def css_color_to_hex(css_color: str) -> str:
b = int(groups["b"].rstrip("%")) * 255 // 100 if "%" in groups["b"] else int(groups["b"]) b = int(groups["b"].rstrip("%")) * 255 // 100 if "%" in groups["b"] else int(groups["b"])
a = float(groups["a"]) if groups["a"] else 1.0 a = float(groups["a"]) if groups["a"] else 1.0
if a < 1.0: if a < 1.0:
logger.debug("converting rgba color to hex", extra={"color": css_color, "r": r, "g": g, "b": b, "a": a})
return rgb_to_hex((r, g, b)) + "{:02x}".format(round(a * 255)) return rgb_to_hex((r, g, b)) + "{:02x}".format(round(a * 255))
else: else:
logger.debug("converting rgb color to hex", extra={"color": css_color, "r": r, "g": g, "b": b})
return rgb_to_hex((r, g, b)) return rgb_to_hex((r, g, b))
elif groups["hsl"]: elif groups["hsl"]:
@@ -130,8 +140,10 @@ def css_color_to_hex(css_color: str) -> str:
a = float(groups["a"]) if groups["a"] else 1.0 a = float(groups["a"]) if groups["a"] else 1.0
rgb_color = hsl_to_rgb((h, s, l)) rgb_color = hsl_to_rgb((h, s, l))
if a < 1.0: if a < 1.0:
logger.debug("converting hsla color to hex", extra={"color": css_color, "hsl": (h, s, l), "a": a})
return rgb_to_hex(rgb_color) + "{:02x}".format(round(a * 255)) return rgb_to_hex(rgb_color) + "{:02x}".format(round(a * 255))
else: else:
logger.debug("converting hsl color to hex", extra={"color": css_color, "hsl": (h, s, l)})
return rgb_to_hex(rgb_color) return rgb_to_hex(rgb_color)
# fmt: off # fmt: off
@@ -182,7 +194,9 @@ def css_color_to_hex(css_color: str) -> str:
'turquoise': '#40e0d0', 'violet': '#ee82ee', 'wheat': '#f5deb3', 'white': '#ffffff', 'turquoise': '#40e0d0', 'violet': '#ee82ee', 'wheat': '#f5deb3', 'white': '#ffffff',
'whitesmoke': '#f5f5f5', 'yellow': '#ffff00', 'yellowgreen': '#9acd32' 'whitesmoke': '#f5f5f5', 'yellow': '#ffff00', 'yellowgreen': '#9acd32'
} }
logger.debug("parsing css color string", extra={"css_color": css_color})
return named_colors[groups['name'].lower()] return named_colors[groups['name'].lower()]
# fmt: on # fmt: on
logger.error("invalid CSS color format", extra={"css_color": css_color})
raise ValueError("Invalid CSS color format") raise ValueError("Invalid CSS color format")

View File

@@ -6,15 +6,19 @@ from typing import Any, Dict, List, Tuple
import numpy as np import numpy as np
from tqdm.auto import tqdm from tqdm.auto import tqdm
from PIL import Image, ExifTags from PIL import Image, ExifTags, TiffImagePlugin
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from modules.logger import logger
import modules.cclicense as cclicense import modules.cclicense as cclicense
from modules.argumentparser import Args from modules.argumentparser import Args
# Constants for file paths and exclusions # Constants for file paths and exclusions
if __package__ == None: if __package__ is None:
__package__ = "" PACKAGE = ""
else:
PACKAGE = __package__
SCRIPTDIR = os.path.abspath(os.path.dirname(__file__).removesuffix(PACKAGE))
FAVICON_PATH = ".static/favicon.ico" FAVICON_PATH = ".static/favicon.ico"
GLOBAL_CSS_PATH = ".static/global.css" GLOBAL_CSS_PATH = ".static/global.css"
EXCLUDES = ["index.html", "manifest.json", "robots.txt"] EXCLUDES = ["index.html", "manifest.json", "robots.txt"]
@@ -23,7 +27,7 @@ EXCLUDES = ["index.html", "manifest.json", "robots.txt"]
Image.MAX_IMAGE_PIXELS = 933120000 Image.MAX_IMAGE_PIXELS = 933120000
# Initialize Jinja2 environment for template rendering # Initialize Jinja2 environment for template rendering
env = Environment(loader=FileSystemLoader(os.path.join(os.path.abspath(os.path.dirname(__file__).removesuffix(__package__)), "templates"))) env = Environment(loader=FileSystemLoader(os.path.join(SCRIPTDIR, "templates")))
thumbnails: List[Tuple[str, str]] = [] thumbnails: List[Tuple[str, str]] = []
info: Dict[str, str] = {} info: Dict[str, str] = {}
pbardict: Dict[str, tqdm] = {} pbardict: Dict[str, tqdm] = {}
@@ -42,17 +46,20 @@ def initialize_sizelist(folder: str) -> Dict[str, Dict[str, int]]:
sizelist = {} sizelist = {}
sizelist_path = os.path.join(folder, ".sizelist.json") sizelist_path = os.path.join(folder, ".sizelist.json")
if not os.path.exists(sizelist_path): if not os.path.exists(sizelist_path):
logger.info("creating new size list file", extra={"file": sizelist_path})
with open(sizelist_path, "x", encoding="utf-8") as sizelistfile: with open(sizelist_path, "x", encoding="utf-8") as sizelistfile:
sizelistfile.write("{}") sizelistfile.write("{}")
with open(sizelist_path, "r+", encoding="utf-8") as sizelistfile: with open(sizelist_path, "r+", encoding="utf-8") as sizelistfile:
logger.info("reading size list file", extra={"file": sizelist_path})
try: try:
sizelist = json.loads(sizelistfile.read()) sizelist = json.loads(sizelistfile.read())
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
logger.warning("invalid JSON in size list file", extra={"file": sizelist_path})
sizelist = {} sizelist = {}
return sizelist return sizelist
def update_sizelist(sizelist: Dict[str, Dict[str, int]], folder: str) -> None: def update_sizelist(sizelist: Dict[str, Dict[str, Any]], folder: str) -> None:
""" """
Updates the size list JSON file. Updates the size list JSON file.
@@ -61,11 +68,13 @@ def update_sizelist(sizelist: Dict[str, Dict[str, int]], folder: str) -> None:
folder (str): The folder in which the size list file is located. folder (str): The folder in which the size list file is located.
""" """
sizelist_path = os.path.join(folder, ".sizelist.json") sizelist_path = os.path.join(folder, ".sizelist.json")
if sizelist != {}: if sizelist:
with open(sizelist_path, "w", encoding="utf-8") as sizelistfile: with open(sizelist_path, "w", encoding="utf-8") as sizelistfile:
logger.info("writing size list file", extra={"file": sizelist_path})
sizelistfile.write(json.dumps(sizelist, indent=4)) sizelistfile.write(json.dumps(sizelist, indent=4))
else: else:
if os.path.exists(sizelist_path): if os.path.exists(sizelist_path):
logger.info("deleting empty size list file", extra={"file": sizelist_path})
os.remove(sizelist_path) os.remove(sizelist_path)
@@ -81,11 +90,35 @@ def get_image_info(item: str, folder: str) -> Dict[str, Any]:
Dict[str, Any]: A dictionary containing image width, height, and EXIF data. Dict[str, Any]: A dictionary containing image width, height, and EXIF data.
""" """
with Image.open(os.path.join(folder, item)) as img: with Image.open(os.path.join(folder, item)) as img:
logger.info("extracting image information", extra={"file": item})
exif = img.getexif() exif = img.getexif()
width, height = img.size width, height = img.size
exifdata = {ExifTags.TAGS.get(key, key): val for key, val in exif.items()} if exif:
ifd = exif.get_ifd(ExifTags.IFD.Exif)
exifdatas = dict(exif.items()) | ifd
exifdata = {}
for tag_id in exifdatas:
tag = ExifTags.TAGS.get(tag_id, tag_id)
content = exifdatas.get(tag_id)
if isinstance(content, bytes):
content = content.hex(" ")
if isinstance(content, TiffImagePlugin.IFDRational):
content = content.limit_rational(1000000)
if isinstance(content, tuple):
newtuple = ()
for i in content:
if isinstance(i, TiffImagePlugin.IFDRational):
newtuple = newtuple + (i.limit_rational(1000000),)
if newtuple:
content = newtuple
exifdata[tag] = content
if "Orientation" in exifdata and exifdata["Orientation"] in [6, 8]: if "Orientation" in exifdata and exifdata["Orientation"] in [6, 8]:
width, height = height, width width, height = height, width
for key in ["PrintImageMatching", "UserComment", "MakerNote"]:
if key in exifdata:
del exifdata[key]
return {"width": width, "height": height, "exifdata": exifdata}
else:
return {"width": width, "height": height} return {"width": width, "height": height}
@@ -141,6 +174,10 @@ def generate_html(folder: str, title: str, _args: Args, raw: List[str], version:
_args (Args): Parsed command line arguments. _args (Args): Parsed command line arguments.
raw (List[str]): Raw image file names. raw (List[str]): Raw image file names.
""" """
if _args.regenerate_thumbnails:
if os.path.exists(os.path.join(folder, ".sizelist.json")):
logger.info("removing .sizelist.json", extra={"folder": folder})
os.remove(os.path.join(folder, ".sizelist.json"))
sizelist = initialize_sizelist(folder) sizelist = initialize_sizelist(folder)
items = sorted(os.listdir(folder)) items = sorted(os.listdir(folder))
@@ -179,6 +216,7 @@ def generate_html(folder: str, title: str, _args: Args, raw: List[str], version:
create_html_file(folder, title, foldername, images, subfolders, _args, version) create_html_file(folder, title, foldername, images, subfolders, _args, version)
else: else:
if os.path.exists(os.path.join(folder, "index.html")): if os.path.exists(os.path.join(folder, "index.html")):
logger.info("removing existing index.html", extra={"folder": folder})
os.remove(os.path.join(folder, "index.html")) os.remove(os.path.join(folder, "index.html"))
if not _args.non_interactive_mode: if not _args.non_interactive_mode:
@@ -195,6 +233,7 @@ def create_thumbnail_folder(foldername: str, root_directory: str) -> None:
""" """
thumbnails_path = os.path.join(root_directory, ".thumbnails", foldername) thumbnails_path = os.path.join(root_directory, ".thumbnails", foldername)
if not os.path.exists(thumbnails_path): if not os.path.exists(thumbnails_path):
logger.info("creating thumbnail folder", extra={"path": thumbnails_path})
os.mkdir(thumbnails_path) os.mkdir(thumbnails_path)
@@ -226,6 +265,7 @@ def process_info_file(folder: str, item: str) -> None:
item (str): The info file name. item (str): The info file name.
""" """
with open(os.path.join(folder, item), encoding="utf-8") as f: with open(os.path.join(folder, item), encoding="utf-8") as f:
logger.info("processing info file", extra={"path": os.path.join(folder, item)})
info[urllib.parse.quote(folder)] = f.read() info[urllib.parse.quote(folder)] = f.read()
@@ -255,6 +295,8 @@ def create_html_file(folder: str, title: str, foldername: str, images: List[Dict
subfolders (List[Dict[str, str]]): A list of subfolders 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. _args (Args): Parsed command line arguments.
""" """
html_file = os.path.join(folder, "index.html")
logger.info("generating html file with jinja2", extra={"path": html_file})
image_chunks = np.array_split(images, 8) if images else [] image_chunks = np.array_split(images, 8) if images else []
header = os.path.basename(folder) or title 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] + '/'))}" parent = None if not foldername else f"{_args.web_root_url}{urllib.parse.quote(foldername.removesuffix(folder.split('/')[-1] + '/'))}"
@@ -294,7 +336,8 @@ def create_html_file(folder: str, title: str, foldername: str, images: List[Dict
version=version, version=version,
) )
with open(os.path.join(folder, "index.html"), "w", encoding="utf-8") as f: with open(html_file, "w", encoding="utf-8") as f:
logger.info("writing html file", extra={"path": html_file})
f.write(content) f.write(content)

96
modules/logger.py Normal file
View File

@@ -0,0 +1,96 @@
"""
loggerdabn.py
This module provides functionality for setting up a centralized logging system using the
`logging` library and the `python-json-logger` to output logs in JSON format. It handles
log rotation by renaming old log files and saving them based on the first timestamp entry.
Functions:
- log_format(keys): Generates the logging format string based on the list of keys.
- rotate_log_file(): Handles renaming the existing log file to a timestamp-based name.
- setup_logger(): Configures the logging system, applies a JSON format, and returns a logger instance.
"""
import logging
import os
import json
from datetime import datetime
from pythonjsonlogger import jsonlogger
# Constants for file paths and exclusions
if __package__ is None:
PACKAGE = ""
else:
PACKAGE = __package__
SCRIPTDIR = os.path.abspath(os.path.dirname(__file__).removesuffix(PACKAGE))
LOG_DIR = os.path.join(SCRIPTDIR, "logs")
LATEST_LOG_FILE = os.path.join(LOG_DIR, "latest.jsonl")
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
def log_format(keys):
"""
Generates a list of format strings based on the given keys.
Args:
keys (list): A list of string keys that represent the log attributes (e.g., 'asctime', 'levelname').
Returns:
list: A list of formatted strings for each key, in the format "%(key)s".
"""
return [f"%({i})s" for i in keys]
def rotate_log_file():
"""
Renames the existing 'latest.jsonl' file to a timestamped file based on the first log entry's asctime.
If 'latest.jsonl' exists, it's renamed to the first timestamp found in the log entry.
"""
if os.path.exists(LATEST_LOG_FILE):
with open(LATEST_LOG_FILE, "r", encoding="utf-8") as f:
first_line = f.readline()
try:
first_log = json.loads(first_line)
first_timestamp = first_log.get("asctime")
first_timestamp = first_timestamp.split(",")[0]
except (json.JSONDecodeError, KeyError):
first_timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
safe_timestamp = first_timestamp.replace(":", "-").replace(" ", "_")
old_log_filename = os.path.join(LOG_DIR, f"{safe_timestamp}.jsonl")
os.rename(LATEST_LOG_FILE, old_log_filename)
def setup_logger():
"""
Configures the logging system with a custom format and outputs logs in JSON format.
The logger will write to the 'logs/latest.jsonl' file, and it will include
multiple attributes such as the time of logging, the filename, function name, log level, etc.
If 'latest.jsonl' already exists, it will be renamed to a timestamped file before creating a new one.
Returns:
logging.Logger: A configured logger instance that can be used to log messages.
"""
rotate_log_file()
app_logger = logging.getLogger()
supported_keys = ["asctime", "created", "filename", "funcName", "levelname", "levelno", "lineno", "module", "msecs", "message", "name", "pathname", "process", "processName", "relativeCreated", "thread", "threadName", "taskName"]
custom_format = " ".join(log_format(supported_keys))
formatter = jsonlogger.JsonFormatter(custom_format)
log_handler = logging.FileHandler(LATEST_LOG_FILE)
log_handler.setFormatter(formatter)
app_logger.addHandler(log_handler)
app_logger.setLevel(logging.INFO)
return app_logger
logger = setup_logger()

View File

@@ -13,17 +13,21 @@ try:
except ImportError: except ImportError:
SVGSUPPORT = False SVGSUPPORT = False
from modules.logger import logger
from modules.argumentparser import Args from modules.argumentparser import Args
from modules.css_color import css_color_to_hex, extract_theme_color, extract_colorscheme from modules.css_color import extract_theme_color, extract_colorscheme
# Define constants for static files directory and icon sizes # Define constants for static files directory and icon sizes
if __package__ == None: if __package__ is None:
__package__ = "" PACKAGE = ""
STATIC_FILES_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__).removesuffix(__package__)), "files") else:
PACKAGE = __package__
SCRIPTDIR = os.path.abspath(os.path.dirname(__file__).removesuffix(PACKAGE))
STATIC_FILES_DIR = os.path.join(SCRIPTDIR, "files")
ICON_SIZES = ["36x36", "48x48", "72x72", "96x96", "144x144", "192x192", "512x512"] ICON_SIZES = ["36x36", "48x48", "72x72", "96x96", "144x144", "192x192", "512x512"]
# Initialize Jinja2 environment for template rendering # Initialize Jinja2 environment for template rendering
env = Environment(loader=FileSystemLoader(os.path.join(os.path.abspath(os.path.dirname(__file__).removesuffix(__package__)), "templates"))) env = Environment(loader=FileSystemLoader(os.path.join(SCRIPTDIR, "templates")))
class Icon: class Icon:
@@ -52,6 +56,7 @@ def render_svg_icon(colorscheme: Dict[str, str], iconspath: str) -> str:
svg = env.get_template("icon.svg.j2") svg = env.get_template("icon.svg.j2")
content = svg.render(colorscheme=colorscheme) content = svg.render(colorscheme=colorscheme)
with open(os.path.join(iconspath, "icon.svg"), "w+", encoding="utf-8") as f: with open(os.path.join(iconspath, "icon.svg"), "w+", encoding="utf-8") as f:
logger.info("writing svg icon", extra={"iconspath": iconspath})
f.write(content) f.write(content)
return content return content
@@ -70,6 +75,7 @@ def save_png_icon(content: str, iconspath: str) -> None:
tmpimg = BytesIO() tmpimg = BytesIO()
cairosvg.svg2png(bytestring=content, write_to=tmpimg) cairosvg.svg2png(bytestring=content, write_to=tmpimg)
with Image.open(tmpimg) as iconfile: with Image.open(tmpimg) as iconfile:
logger.info("saving png icon", extra={"iconspath": iconspath})
iconfile.save(os.path.join(iconspath, "icon.png")) iconfile.save(os.path.join(iconspath, "icon.png"))
@@ -84,9 +90,11 @@ def generate_favicon(iconspath: str, root_directory: str) -> None:
root_directory : str root_directory : str
Root directory of the project where the favicon will be saved. 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")}' favicon = os.path.join(root_directory, ".static", "favicon.ico")
logger.info("generating favicon with imagemagick", extra={"iconspath": iconspath, "favicon": favicon})
command = f'magick {os.path.join(iconspath, "icon.png")} -define icon:auto-resize=16,32,48,64,72,96,144,192 {favicon}'
if not shutil.which("magick"): 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")}' command = f'convert {os.path.join(iconspath, "icon.png")} -define icon:auto-resize=16,32,48,64,72,96,144,192 {favicon}'
os.system(command) os.system(command)
@@ -99,12 +107,14 @@ def icons(_args: Args) -> None:
_args : Args _args : Args
Parsed command-line arguments. Parsed command-line arguments.
""" """
print("Generating icons...")
iconspath = os.path.join(_args.root_directory, ".static", "icons") iconspath = os.path.join(_args.root_directory, ".static", "icons")
logger.info("generating icons", extra={"iconspath": iconspath})
print("Generating icons...")
colorscheme = extract_colorscheme(_args.theme_path) colorscheme = extract_colorscheme(_args.theme_path)
content = render_svg_icon(colorscheme, iconspath) content = render_svg_icon(colorscheme, iconspath)
if not SVGSUPPORT: if not SVGSUPPORT:
print("Please install cairosvg to generate favicon from svg icon.") print("Please install cairosvg to generate favicon from svg icon.")
logger.error("svg support not available")
return return
save_png_icon(content, iconspath) save_png_icon(content, iconspath)
generate_favicon(iconspath, _args.root_directory) generate_favicon(iconspath, _args.root_directory)
@@ -132,6 +142,7 @@ def render_manifest_json(_args: Args, icon_list: List[Icon], colors: Dict[str, s
theme_color=colors["theme_color"], theme_color=colors["theme_color"],
) )
with open(os.path.join(_args.root_directory, ".static", "manifest.json"), "w", encoding="utf-8") as f: with open(os.path.join(_args.root_directory, ".static", "manifest.json"), "w", encoding="utf-8") as f:
logger.info("rendering manifest.json", extra={"path": os.path.join(_args.root_directory, ".static", "manifest.json")})
f.write(content) f.write(content)
@@ -153,6 +164,7 @@ def create_icons_from_svg(files: List[str], iconspath: str, _args: Args) -> List
List[Icon] List[Icon]
List of icons created from the SVG file. List of icons created from the SVG file.
""" """
logger.info("creating icons for web application", extra={"iconspath": iconspath})
svg = [file for file in files if file.endswith(".svg")][0] svg = [file for file in files if file.endswith(".svg")][0]
icon_list = [ 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": "maskable"},
@@ -162,6 +174,7 @@ def create_icons_from_svg(files: List[str], iconspath: str, _args: Args) -> List
tmpimg = BytesIO() tmpimg = BytesIO()
sizes = size.split("x") sizes = size.split("x")
iconpath = os.path.join(iconspath, os.path.splitext(svg)[0] + "-" + size + ".png") iconpath = os.path.join(iconspath, os.path.splitext(svg)[0] + "-" + size + ".png")
logger.info("converting svg to png", extra={"iconpath": iconpath, "size": size})
cairosvg.svg2png( cairosvg.svg2png(
url=os.path.join(iconspath, svg), url=os.path.join(iconspath, svg),
write_to=tmpimg, write_to=tmpimg,
@@ -170,6 +183,7 @@ def create_icons_from_svg(files: List[str], iconspath: str, _args: Args) -> List
scale=1, scale=1,
) )
with Image.open(tmpimg) as iconfile: with Image.open(tmpimg) as iconfile:
logger.info("saving png file", extra={"iconpath": iconpath})
iconfile.save(iconpath, format="PNG") iconfile.save(iconpath, format="PNG")
icon_list.append( icon_list.append(
{ {
@@ -212,6 +226,7 @@ def create_icons_from_png(iconspath: str, web_root_url: str) -> List[Icon]:
continue continue
with Image.open(os.path.join(iconspath, icon)) as iconfile: with Image.open(os.path.join(iconspath, icon)) as iconfile:
iconsize = f"{iconfile.size[0]}x{iconfile.size[1]}" iconsize = f"{iconfile.size[0]}x{iconfile.size[1]}"
logger.info("using icon", extra={"icon": icon, "size": iconsize})
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": "maskable"})
icon_list.append({"src": f"{web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "any"}) icon_list.append({"src": f"{web_root_url}.static/icons/{icon}", "sizes": iconsize, "type": "image/png", "purpose": "any"})
return icon_list return icon_list
@@ -228,14 +243,11 @@ def webmanifest(_args: Args) -> None:
""" """
iconspath = os.path.join(_args.root_directory, ".static", "icons") iconspath = os.path.join(_args.root_directory, ".static", "icons")
files = os.listdir(iconspath) files = os.listdir(iconspath)
icon_list = ( 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)
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: if not icon_list:
print("No icons found in the static/icons folder!") print("No icons found in the static/icons folder!")
logger.error("no icons found in the static/icons folder", extra={"iconspath": iconspath})
return return
colorscheme = extract_colorscheme(os.path.join(_args.root_directory, ".static", "theme.css")) colorscheme = extract_colorscheme(os.path.join(_args.root_directory, ".static", "theme.css"))

View File

@@ -3,5 +3,7 @@ Jinja2==3.1.4
numpy==2.0.0 numpy==2.0.0
pillow==10.4.0 pillow==10.4.0
pyinstaller==6.9.0 pyinstaller==6.9.0
python-json-logger==2.0.7
rich-argparse==1.5.2 rich-argparse==1.5.2
setuptools==70.3.0
tqdm==4.66.4 tqdm==4.66.4

BIN
test/example/DSC03470.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

BIN
test/example/DSC03508.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

View File

@@ -12,8 +12,7 @@
--bcolor2: #20123b; --bcolor2: #20123b;
--bcolor3: #2b1753; --bcolor3: #2b1753;
--bcolor4: #321c64; --bcolor4: #321c64;
--gradient: linear-gradient( --gradient: linear-gradient(80deg,
80deg,
var(--bcolor2) 0%, var(--bcolor2) 0%,
var(--color3) 1%, var(--color3) 1%,
var(--color4) 2%, var(--color4) 2%,
@@ -23,8 +22,7 @@
var(--color5) 97%, var(--color5) 97%,
var(--color4) 98%, var(--color4) 98%,
var(--color3) 99%, var(--color3) 99%,
var(--bcolor2) 100% var(--bcolor2) 100%);
);
} }
body { body {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB