From 7b4dfebbdcceb013f3637efdf7eb0ba95de9cb24 Mon Sep 17 00:00:00 2001 From: localhorst Date: Sun, 8 Jun 2025 20:21:42 +0200 Subject: [PATCH] cleanup --- layouter.py | 364 ++++++++++++++++++++++++++++++---------------------- output.png | Bin 5439 -> 5582 bytes 2 files changed, 214 insertions(+), 150 deletions(-) diff --git a/layouter.py b/layouter.py index 933ee1d..58dbde9 100644 --- a/layouter.py +++ b/layouter.py @@ -1,215 +1,279 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" Author: Hendrik Schutter, localhorst@mosad.xyz - Date of creation: 2022/11/16 - Date of last modification: 2022/11/23 +""" +Author: Hendrik Schutter, localhorst@mosad.xyz +Date of creation: 2022/11/16 +Date of last modification: 2025/06/08 """ import re import dataclasses import glob -import datetime +import datetime import json +import logging import qrcode -from PIL import Image -from PIL import ImageFont -from PIL import ImageDraw +from PIL import Image, ImageFont, ImageDraw + +# Constants +FONT_PATH = "/usr/share/fonts" +DEFAULT_FONT_REGULAR = "DejaVuSans.ttf" +DEFAULT_FONT_BOLD = "DejaVuSans-Bold.ttf" +OUTPUT_WIDTH = 696 # px +OUTPUT_HEIGHT = 300 # px +TEXT_X_OFFSET = 300 # px +QR_CODE_SIZE = 289 # px + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + @dataclasses.dataclass class DriveData: drive_index: int - drive_state: str #none, deleted, shredded - modelfamiliy: str - modelname: str - capacity: int #in bytes + drive_state: str + modelfamily: str + modelname: str + capacity: int serialnumber: str - power_on_hours: int #in hours + power_on_hours: int power_cycle: int smart_error_count: int - shred_timestamp: int #unix timestamp - shred_duration: int #in seconds - + shred_timestamp: int + shred_duration: int + + @dataclasses.dataclass class DriveDataPrintable: - modelfamiliy: str #max lenght 25 - modelname: str #max lenght 25 - capacity: str #max lenght 25, in human-readable format with unit (GB/TB) - serialnumber: str #max lenght 25 - power_on_hours: str #max lenght 25, in hours and days and years - power_cycle: str #max lenght 25 - smart_error_count: str #max lenght 25 - shred_timestamp: str #max lenght 25, human-readable - shred_duration: str #max lenght 25, human-readable + modelfamily: str + modelname: str + capacity: str + serialnumber: str + power_on_hours: str + power_cycle: str + smart_error_count: str + shred_timestamp: str + shred_duration: str + @dataclasses.dataclass class ReHddInfo: link: str version: str + @dataclasses.dataclass class DriveDataJson: drive: DriveData rehdd: ReHddInfo -def get_font_path_regular(): - path = "/usr/share/fonts" - files = glob.glob(path + "/**/DejaVuSans.ttf", recursive = True) - return files[0] -def get_font_path_bold(): - path = "/usr/share/fonts" - files = glob.glob(path + "/**/DejaVuSans-Bold.ttf", recursive = True) - return files[0] +def find_font_path(font_name): + """Finds the full path of the specified font.""" + try: + files = glob.glob(f"{FONT_PATH}/**/{font_name}", recursive=True) + return files[0] if files else None + except Exception as e: + logging.error(f"Error locating font {font_name}: {e}") + return None -def human_readable_capacity_1024(size, decimal_places=0): - for unit in ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']: - if size < 1024.0 or unit == 'PiB': - break - size /= 1024.0 - return f"{size:.{decimal_places}f} {unit}" -def human_readable_capacity_1000(size, decimal_places=0): - for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']: - if size < 1000.0 or unit == 'PB': - break - size /= 1000.0 - return f"{size:.{decimal_places}f} {unit}" +def human_readable_capacity(size, decimal_places=0, base=1000): + """Converts bytes to a human-readable string.""" + units = ( + ["B", "KB", "MB", "GB", "TB", "PB"] + if base == 1000 + else ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] + ) + for unit in units: + if size < base or unit == units[-1]: + return f"{size:.{decimal_places}f} {unit}" + size /= base + + +def human_readable_power_on_hours(hours): + """Converts hours to human-readable string in hours, days, and years.""" + return ( + str(hours) + + "h or " + + str(int(hours / 24)) + + "d or " + + str("{:.2f}".format(float(hours / 24 / 365))) + + "y" + ) + + +def cut_string(max_length, data, direction="end"): + """Trims a string to the maximum length, adding ellipses if necessary.""" + if len(data) > max_length: + return ( + f"{data[:max_length-4]} ..." + if direction == "end" + else f"... {data[-(max_length-4):]}" + ) + return data -def human_readable_power_on_hours(hours, decimal_places=2): - return str(hours) + "h or " + str(int(hours/24)) + "d or " + str("{:.2f}".format(float(hours/24/365))) + "y" - -def cut_string(max_lenght, data, direction): - if (len(data) > max_lenght): - if (direction == "end"): - return data[0:(max_lenght-4)] + " ..." - elif (direction == "start"): - return "... " + data[(len(data)-max_lenght+4):] - else: - return cut_string(max_lenght, data, "end") - else: - return data def format_to_printable(drive): return DriveDataPrintable( - cut_string(20, re.sub(r"[^a-zA-Z0-9. ]", "", drive.modelfamiliy), "end"),\ - cut_string(20, re.sub(r"[^a-zA-Z0-9. ]", "", drive.modelname), "end"),\ - cut_string(20, human_readable_capacity_1000(drive.capacity), "end"),\ - cut_string(16, re.sub(r"[^a-zA-Z0-9.-_]", "", drive.serialnumber), "start"),\ - cut_string(30, human_readable_power_on_hours(drive.power_on_hours), "end"),\ - cut_string(10, str(drive.power_cycle), "end"),\ - cut_string(10, str(drive.smart_error_count), "end"),\ - cut_string(30, datetime.datetime.utcfromtimestamp(drive.shred_timestamp).strftime('%Y-%m-%d %H:%M:%S'), "end"),\ - cut_string(30, str(datetime.timedelta(seconds = drive.shred_duration)), "end")) + cut_string(20, re.sub(r"[^a-zA-Z0-9. ]", "", drive.modelfamily), "end"), + cut_string(20, re.sub(r"[^a-zA-Z0-9. ]", "", drive.modelname), "end"), + cut_string(20, human_readable_capacity(drive.capacity), "end"), + cut_string(16, re.sub(r"[^a-zA-Z0-9.-_]", "", drive.serialnumber), "start"), + cut_string(30, human_readable_power_on_hours(drive.power_on_hours), "end"), + cut_string(10, str(drive.power_cycle), "end"), + cut_string(10, str(drive.smart_error_count), "end"), + cut_string( + 30, + datetime.datetime.fromtimestamp( + drive.shred_timestamp, datetime.UTC + ).strftime("%Y-%m-%d %H:%M:%S"), + "end", + ), + cut_string(30, str(datetime.timedelta(seconds=drive.shred_duration)), "end"), + ) -def draw_text(drawable, printable_data, text_x_offset): - try: - font_file_regular = get_font_path_regular() - font_file_bold = get_font_path_bold() - except Exception as ex: - print("unable to find font: " + str(ex)) - return - - font_size = 20 + +def draw_text(drawable, printable_data, font_regular, font_bold, font_bold_bigger): + """Draws the formatted text onto the image.""" text_y_offset = 10 - text_y_offset_increment = 25 - value_colum_x_offset = 120 + value_column_x_offset = 120 + font_size = 20 - drawable.text((text_x_offset, text_y_offset), printable_data.serialnumber,(0),font=ImageFont.truetype(font_file_bold, 30)) - text_y_offset += 40 + drawable.text( + (TEXT_X_OFFSET, text_y_offset), printable_data.serialnumber, (0), font=font_bold + ) + text_y_offset += 25 - drawable.text((text_x_offset, text_y_offset), "Familiy: ",(0),font=ImageFont.truetype(font_file_bold, font_size)) - drawable.text((text_x_offset+value_colum_x_offset, text_y_offset), printable_data.modelfamiliy,(0),font=ImageFont.truetype(font_file_regular, font_size)) - text_y_offset += text_y_offset_increment - drawable.text((text_x_offset, text_y_offset), "Model: ",(0),font=ImageFont.truetype(font_file_bold, font_size)) - drawable.text((text_x_offset+value_colum_x_offset, text_y_offset), printable_data.modelname,(0),font=ImageFont.truetype(font_file_regular, font_size)) - text_y_offset += text_y_offset_increment - drawable.text((text_x_offset, text_y_offset), "Hours: " ,(0),font=ImageFont.truetype(font_file_bold, font_size)) - drawable.text((text_x_offset+value_colum_x_offset, text_y_offset), printable_data.power_on_hours,(0),font=ImageFont.truetype(font_file_regular, font_size)) - text_y_offset += text_y_offset_increment - drawable.text((text_x_offset, text_y_offset), "Cycles: ",(0),font=ImageFont.truetype(font_file_bold, font_size)) - drawable.text((text_x_offset+value_colum_x_offset, text_y_offset), printable_data.power_cycle,(0),font=ImageFont.truetype(font_file_regular, font_size)) - text_y_offset += text_y_offset_increment - drawable.text((text_x_offset, text_y_offset), "Errors: ", (0),font=ImageFont.truetype(font_file_bold, font_size)) - drawable.text((text_x_offset+value_colum_x_offset, text_y_offset), printable_data.smart_error_count,(0),font=ImageFont.truetype(font_file_regular, font_size)) - text_y_offset += text_y_offset_increment - drawable.text((text_x_offset, text_y_offset), "Shred on: ",(0),font=ImageFont.truetype(font_file_bold, font_size)) - drawable.text((text_x_offset+value_colum_x_offset, text_y_offset), printable_data.shred_timestamp,(0),font=ImageFont.truetype(font_file_regular, font_size)) - text_y_offset += text_y_offset_increment - drawable.text((text_x_offset, text_y_offset), "Duration: " ,(0),font=ImageFont.truetype(font_file_bold, font_size)) - drawable.text((text_x_offset+value_colum_x_offset, text_y_offset), printable_data.shred_duration,(0),font=ImageFont.truetype(font_file_regular, font_size)) - text_y_offset += text_y_offset_increment + fields = [ + ("Family:", printable_data.modelfamily, font_regular), + ("Model:", printable_data.modelname, font_regular), + ("Hours:", printable_data.power_on_hours, font_regular), + ("Cycles:", printable_data.power_cycle, font_regular), + ("Errors:", printable_data.smart_error_count, font_regular), + ("Shred on:", printable_data.shred_timestamp, font_regular), + ("Duration:", printable_data.shred_duration, font_regular), + ] - drawable.text((text_x_offset, text_y_offset), printable_data.capacity,(0),font=ImageFont.truetype(font_file_bold, font_size*3)) + for label, value, font in fields: + drawable.text((TEXT_X_OFFSET, text_y_offset), label, fill=0, font=font_bold) + drawable.text( + (TEXT_X_OFFSET + value_column_x_offset, text_y_offset), + value, + fill=0, + font=font, + ) + text_y_offset += 25 + + drawable.text( + (TEXT_X_OFFSET, text_y_offset), + printable_data.capacity, + (0), + font=font_bold_bigger, + ) -def draw_outline(drawable, margin, width, output_width, output_height): - #upper - drawable.line([(margin,margin), (output_width -margin ,margin)], fill=None, width=width, joint=None) - #left - drawable.line([(margin,margin), (margin ,output_height-margin)], fill=None, width=width, joint=None) - #right - drawable.line([(output_width-margin,margin), (output_width-margin ,output_height-margin)], fill=None, width=width, joint=None) - #lower - drawable.line([(margin,output_height - margin), (output_width-margin,output_height -margin)], fill=None, width=width, joint=None) def draw_qr_code(image, data): + """ + Draws a QR code on the provided image at a specific location. + + Parameters: + image (Image.Image): The target image. + data (str): The data to encode in the QR code. + """ + # Generate the QR code qr_img = qrcode.make(data) - qr_img.thumbnail((291, 291), Image.Resampling.LANCZOS) - image.paste(qr_img, (7, 7)) + qr_img = qr_img.convert("1") # Ensure QR code is in binary (black/white) + + # Remove white border + bbox = qr_img.getbbox() # Get the bounding box of the non-white area + qr_img = qr_img.crop(bbox) + + # Resize to desired size + qr_img = qr_img.resize((QR_CODE_SIZE, QR_CODE_SIZE), Image.Resampling.LANCZOS) + + # Paste the QR code onto the image + region = (5, 5, 5 + QR_CODE_SIZE, 5 + QR_CODE_SIZE) + image.paste(qr_img, box=region) + + +def draw_outline(drawable, margin, width, output_width, output_height): + """ + Draws a rectangular outline on the drawable canvas. + + Parameters: + drawable (ImageDraw.Draw): The drawable canvas. + margin (int): The margin from the edges of the image. + width (int): The width of the outline. + output_width (int): The total width of the image. + output_height (int): The total height of the image. + """ + # Define the four corners of the rectangle, adjusting for line width + top_left = (margin, margin) + top_right = (output_width - margin - width, margin) + bottom_left = (margin, output_height - margin - width) + bottom_right = (output_width - margin - width, output_height - margin - width) + + # Draw the outline lines with adjusted coordinates + lines = [ + (top_left, top_right), # Top edge + (top_left, bottom_left), # Left edge + (top_right, bottom_right), # Right edge + (bottom_left, bottom_right), # Bottom edge + ] + + for line in lines: + drawable.line(line, fill=0, width=width) def generate_image(drive, rehdd_info, output_file): - output_width = 696 #in px set by used paper - output_height = 300 #in px - text_x_offset= 300 #in px - - qr_data = DriveDataJson(drive, rehdd_info) - try: - json_qr_daten = json.dumps(dataclasses.asdict(qr_data)) - except Exception as ex: - print("unable to generate json: " + str(ex)) - return - + """Generates an image containing drive data and a QR code.""" try: + qr_data = json.dumps(dataclasses.asdict(DriveDataJson(drive, rehdd_info))) printable_data = format_to_printable(drive) - except Exception as ex: - print("unable to format data: " + str(ex)) + except Exception as e: + logging.error(f"Error preparing data: {e}") return - - #print(printable_data.serialnumber) - #create black and white (binary) image with white background - output_image = Image.new('1', (output_width, output_height), "white") - - #create draw pane + output_image = Image.new("1", (OUTPUT_WIDTH, OUTPUT_HEIGHT), "white") draw = ImageDraw.Draw(output_image) - draw_outline(draw, 1, 4, output_width, output_height) - draw_text(draw, printable_data, text_x_offset) - draw_qr_code(output_image, str(json_qr_daten).replace(" ", "")) + font_regular = ImageFont.truetype(find_font_path(DEFAULT_FONT_REGULAR), 20) + font_bold = ImageFont.truetype(find_font_path(DEFAULT_FONT_BOLD), 20) + font_bold_bigger = ImageFont.truetype(find_font_path(DEFAULT_FONT_BOLD), 60) - output_image.save(output_file) + draw_outline(draw, 1, 4, OUTPUT_WIDTH, OUTPUT_HEIGHT) + draw_text(draw, printable_data, font_regular, font_bold, font_bold_bigger) + draw_qr_code(output_image, qr_data) + + try: + output_image.save(output_file) + logging.info(f"Image saved to {output_file}") + except Exception as e: + logging.error(f"Error saving image: {e}") def main(): - - rehdd_info = ReHddInfo("https://git.mosad.xyz/localhorst/reHDD", "bV0.2.2") # read this from rehdd process + rehdd_info = ReHddInfo("https://git.mosad.xyz/localhorst/reHDD", "bV0.2.2") temp_drive = DriveData( - drive_index=0,\ - drive_state="shredded",\ - modelfamiliy="Toshiba 2.5\\ HDD MK..65GSSX",\ - modelname="TOSHIBA MK3265GSDX",\ - capacity=343597383680,\ - serialnumber="YG6742U56UDRL123456789ABCDEFGJKL",\ - power_on_hours=7074,\ - power_cycle=4792,\ - smart_error_count=1,\ - shred_timestamp=1647937421,\ - shred_duration=81718) + drive_index=0, + drive_state="shredded", + modelfamily='Toshiba 2.5" HDD MK..65GSSX', + modelname="TOSHIBA MK3265GSDX", + capacity=343597383680, + serialnumber="YG6742U56UDRL123456789ABCDEFGJKL", + power_on_hours=7074, + power_cycle=4792, + smart_error_count=1, + shred_timestamp=datetime.datetime.now(datetime.timezone.utc).timestamp(), + shred_duration=81718, + ) generate_image(temp_drive, rehdd_info, "output.png") - if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/output.png b/output.png index b30175a88829e98b5b6b56a67f02fdc1900a769b..b210d77d8c4708d5906dca1a6f3a3516b8e12172 100644 GIT binary patch literal 5582 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 literal 5439 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