From 6669fdc945b2770b3658c4e00fedb5b722ce8226 Mon Sep 17 00:00:00 2001 From: Ignacio Rivero Date: Sat, 21 Jun 2025 15:38:57 -0300 Subject: [PATCH] Initial commit --- .gitignore | 1 + app.py | 392 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 3 files changed, 396 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d17dae --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/app.py b/app.py new file mode 100644 index 0000000..de04777 --- /dev/null +++ b/app.py @@ -0,0 +1,392 @@ +import io +import tempfile +import subprocess +from flask import Flask, render_template_string, request, send_file, redirect, url_for, flash +from PIL import Image, ImageDraw, ImageFont +import re + +app = Flask(__name__) +app.secret_key = 'TpfxYMxUJxJMtCCTraBSg1rbd6NLz38JTmIfpBLsotcI47EqXU' + +FONT_PATH = "/usr/share/fonts/truetype/DejaVuSansMono.ttf" +FONT_PATH_BOLD = "/usr/share/fonts/truetype/DejaVuSansMono-Bold.ttf" +FONT_PATH_OBLIQUE = "/usr/share/fonts/truetype/DejaVuSansMono-Oblique.ttf" +FONT_PATH_BOLDITALIC = "/usr/share/fonts/truetype/DejaVuSansMono-BoldOblique.ttf" +FONT_SIZE = 20 +HEADER_SIZE_1 = 34 +HEADER_SIZE_2 = 28 +HEADER_SIZE_3 = 24 +IMAGE_WIDTH = 384 +BULLET_CHAR = "• " + +HTML_FORM = ''' + +Markdown Thermal Note + +
+
+

CatNotepad

+
+
+
+ + +
+
+ {% if printed %} +
✅ Enviado a impresora
+ {% endif %} + {% if error %} +
⚠️ {{ error }}
+ {% endif %} +
+
+ {% if img %} +

Vista previa

+ + {% else %} +

Su vista previa aparecerá aquí

+ {% endif %} +
+
+''' + +def parse_line(line): + header_level = 0 + header_match = re.match(r"^(#{1,3}) +(.*)", line) + if header_match: + header_level = len(header_match.group(1)) + line = header_match.group(2) + return ('header', header_level, parse_segments(line)) + bullet_match = re.match(r"^\s*([-*\u2022]) +(.*)", line) + if bullet_match: + return ('bullet', parse_segments(bullet_match.group(2))) + ordered_match = re.match(r"^\s*(\d+)\. +(.*)", line) + if ordered_match: + return ('ordered', int(ordered_match.group(1)), parse_segments(ordered_match.group(2))) + return ('text', parse_segments(line)) + +def parse_segments(line): + bi = re.findall(r"\*\*\*(.+?)\*\*\*", line) + for x in bi: + line = line.replace(f"***{x}***", f"\x01{x}\x02") + b = re.findall(r"\*\*(.+?)\*\*", line) + for x in b: + line = line.replace(f"**{x}**", f"\x03{x}\x04") + i = re.findall(r"\*(.+?)\*", line) + for x in i: + line = line.replace(f"*{x}*", f"\x05{x}\x06") + segments = [] + i = 0 + style = 'text' + while i < len(line): + if line[i] == '\x01': + style = 'bolditalic' + i += 1 + start = i + while i < len(line) and line[i] != '\x02': i += 1 + segments.append(('bolditalic', line[start:i])) + style = 'text' + i += 1 + elif line[i] == '\x03': + style = 'bold' + i += 1 + start = i + while i < len(line) and line[i] != '\x04': i += 1 + segments.append(('bold', line[start:i])) + style = 'text' + i += 1 + elif line[i] == '\x05': + style = 'italic' + i += 1 + start = i + while i < len(line) and line[i] != '\x06': i += 1 + segments.append(('italic', line[start:i])) + style = 'text' + i += 1 + else: + start = i + while i < len(line) and line[i] not in '\x01\x03\x05': i += 1 + if i > start: + segments.append(('text', line[start:i])) + return segments + +def font_for_style(style, font, font_bold, font_italic, font_bolditalic): + if style == 'bolditalic': + return font_bolditalic or font_bold or font_italic or font + elif style == 'bold': + return font_bold + elif style == 'italic': + return font_italic + else: + return font + +def wrap_segments(segments, font, font_bold, font_italic, font_bolditalic, max_width, start_x=0): + line = [] + x = start_x + for style, text in segments: + words = re.split(r'(\s+)', text) + for word in words: + if word == '': + continue + f = font_for_style(style, font, font_bold, font_italic, font_bolditalic) + w = f.getbbox(word)[2] - f.getbbox(word)[0] + if x + w > max_width and line: + yield line + line = [] + x = start_x + line.append((style, word)) + x += w + if line: + yield line + +def render(md): + font = ImageFont.truetype(FONT_PATH, FONT_SIZE) + font_bold = ImageFont.truetype(FONT_PATH_BOLD, FONT_SIZE) + font_italic = ImageFont.truetype(FONT_PATH_OBLIQUE, FONT_SIZE) + try: + font_bolditalic = ImageFont.truetype(FONT_PATH_BOLDITALIC, FONT_SIZE) + except: + font_bolditalic = None + font_h1 = ImageFont.truetype(FONT_PATH_BOLD, HEADER_SIZE_1) + font_h2 = ImageFont.truetype(FONT_PATH_BOLD, HEADER_SIZE_2) + font_h3 = ImageFont.truetype(FONT_PATH_BOLD, HEADER_SIZE_3) + lines_out = [] + for src_line in md.splitlines(): + if src_line.strip() == '': + lines_out.append(('blank', [])) + continue + tag = parse_line(src_line) + if tag[0] == 'header': + header_level = tag[1] + segments = tag[2] + if header_level == 1: + font_h = font_h1 + size_h = HEADER_SIZE_1 + elif header_level == 2: + font_h = font_h2 + size_h = HEADER_SIZE_2 + else: + font_h = font_h3 + size_h = HEADER_SIZE_3 + for wrapped in wrap_segments(segments, font_h, font_h, font_h, font_h, IMAGE_WIDTH): + lines_out.append(('header', wrapped, font_h, size_h)) + elif tag[0] == 'bullet': + segments = tag[1] + bullet_font = font + bullet_w = bullet_font.getbbox(BULLET_CHAR)[2] - bullet_font.getbbox(BULLET_CHAR)[0] + wrapped_lines = list(wrap_segments(segments, font, font_bold, font_italic, font_bolditalic, IMAGE_WIDTH - bullet_w, start_x=0)) + for i, wrapped in enumerate(wrapped_lines): + if i == 0: + lines_out.append(('bullet', wrapped, bullet_font, FONT_SIZE, True, bullet_w)) + else: + lines_out.append(('bullet', wrapped, bullet_font, FONT_SIZE, False, bullet_w)) + elif tag[0] == 'ordered': + idx, segments = tag[1], tag[2] + num_str = f"{idx}. " + number_font = font + num_w = number_font.getbbox(num_str)[2] - number_font.getbbox(num_str)[0] + wrapped_lines = list(wrap_segments(segments, font, font_bold, font_italic, font_bolditalic, IMAGE_WIDTH - num_w, start_x=0)) + for i, wrapped in enumerate(wrapped_lines): + if i == 0: + lines_out.append(('ordered', wrapped, number_font, FONT_SIZE, num_str, True, num_w)) + else: + lines_out.append(('ordered', wrapped, number_font, FONT_SIZE, num_str, False, num_w)) + else: # normal text + segments = tag[1] + for wrapped in wrap_segments(segments, font, font_bold, font_italic, font_bolditalic, IMAGE_WIDTH): + lines_out.append(('text', wrapped, font, FONT_SIZE)) + height = sum(item[3] if item[0] in ('header','text','bullet','ordered') else FONT_SIZE for item in lines_out) + 10 + image = Image.new("L", (IMAGE_WIDTH, height), 255) + draw = ImageDraw.Draw(image) + y = 0 + for item in lines_out: + if item[0] == 'blank': + y += FONT_SIZE + elif item[0] == 'header': + segments, fnt, sz = item[1], item[2], item[3] + x = 0 + for style, text in segments: + draw.text((x, y), text, font=fnt, fill=0) + x += fnt.getbbox(text)[2] - fnt.getbbox(text)[0] + y += sz + elif item[0] == 'bullet': + segments, bullet_font, sz, show_bullet, bullet_w = item[1], item[2], item[3], item[4], item[5] + x = 0 + if show_bullet: + draw.text((x, y), BULLET_CHAR, font=bullet_font, fill=0) + x += bullet_w + else: + x += bullet_w + for style, text in segments: + f = font_for_style(style, bullet_font, font_bold, font_italic, font_bolditalic) + draw.text((x, y), text, font=f, fill=0) + x += f.getbbox(text)[2] - f.getbbox(text)[0] + y += sz + elif item[0] == 'ordered': + segments, number_font, sz, num_str, show_num, num_w = item[1], item[2], item[3], item[4], item[5], item[6] + x = 0 + if show_num: + draw.text((x, y), num_str, font=number_font, fill=0) + x += num_w + else: + x += num_w + for style, text in segments: + f = font_for_style(style, number_font, font_bold, font_italic, font_bolditalic) + draw.text((x, y), text, font=f, fill=0) + x += f.getbbox(text)[2] - f.getbbox(text)[0] + y += sz + elif item[0] == 'text': + segments, fnt, sz = item[1], item[2], item[3] + x = 0 + for style, text in segments: + f = font_for_style(style, font, font_bold, font_italic, font_bolditalic) + draw.text((x, y), text, font=f, fill=0) + x += f.getbbox(text)[2] - f.getbbox(text)[0] + y += sz + return image + + +@app.route("/", methods=["GET", "POST"]) +def index(): + img_data = None + md = "" + printed = False + error = None + if request.method == "POST": + md = request.form["md"] + image = render(md) + buf = io.BytesIO() + image.save(buf, format="PNG") + buf.seek(0) + import base64 + img_data = base64.b64encode(buf.getvalue()).decode() + # If print button pressed, send to catprinter-ble + if "print" in request.form: + try: + with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmpfile: + image.save(tmpfile, format="PNG") + tmpfile.flush() + # Run the catprinter-ble command + # You can set dither method here if desired + result = subprocess.run([ + "sudo", + "./catprinter-ble", + "-mode", "1bpp", + "-intensity", "100", + tmpfile.name + ], capture_output=True, text=True, timeout=90) + if result.returncode != 0: + error = f"Printer error: {result.stderr or result.stdout}" + else: + printed = True + except Exception as e: + error = f"Failed to print: {e}" + return render_template_string(HTML_FORM, img=img_data, default_md=md, + printed=printed, error=error) + +if __name__ == "__main__": + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fd632f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask +markdown +Pillow \ No newline at end of file