Compare commits

...

8 Commits

4 changed files with 786 additions and 567 deletions

View File

@@ -2,8 +2,6 @@
CatNote is a minimalist web application that lets you generate Markdown-based receipts or notes, render them as PNGs, and send them directly to an MXW01 Cat Thermal Printer. Ideal for making quick, beautiful, and portable printed notes using Markdown, with optional image upload. CatNote is a minimalist web application that lets you generate Markdown-based receipts or notes, render them as PNGs, and send them directly to an MXW01 Cat Thermal Printer. Ideal for making quick, beautiful, and portable printed notes using Markdown, with optional image upload.
**Note:** The default UI is currently *only available in Spanish*, as this project began for my own use. Support for both English and Spanish is planned for a future release.
## Features ## Features
* Enter Markdown and preview output. * Enter Markdown and preview output.
@@ -16,6 +14,9 @@ CatNote is a minimalist web application that lets you generate Markdown-based re
* Python 3.8+ * Python 3.8+
* Flask * Flask
* Requests
* Markdown
* JSON
* Pillow (PIL) * Pillow (PIL)
* `bleh` v1.0.0 or later (see below) * `bleh` v1.0.0 or later (see below)
@@ -42,7 +43,7 @@ CatNote is a minimalist web application that lets you generate Markdown-based re
It's easiest to just get the latest release via wget: It's easiest to just get the latest release via wget:
``` ```
wget -O ./bleh https://git.netpaws.cc/igna/bleh/releases/download/latest/bleh wget -O ./bleh https://git.netpaws.cc/igna/bleh/releases/download/latest/bleh_amd64
``` ```
Place the built `bleh` binary in the app root directory (next to `app.py`). Place the built `bleh` binary in the app root directory (next to `app.py`).

625
app.py
View File

@@ -3,11 +3,12 @@ import tempfile
import subprocess import subprocess
import requests import requests
import base64 import base64
from flask import Flask, render_template_string, request, send_from_directory, session from flask import Flask, render_template, request, send_from_directory, session
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import os import os
import re import re
import argparse import argparse
import json
app = Flask(__name__) app = Flask(__name__)
app.secret_key = 'TpfxYMxUJxJMtCCTraBSg1rbd6NLz38JTmIfpBLsotcI47EqXU' app.secret_key = 'TpfxYMxUJxJMtCCTraBSg1rbd6NLz38JTmIfpBLsotcI47EqXU'
@@ -24,562 +25,8 @@ BANNER_FONT_SIZE = 300
IMAGE_WIDTH = 384 IMAGE_WIDTH = 384
BULLET_CHAR = "" BULLET_CHAR = ""
DITHERING_MODES = { with open('static/translations.json', 'r', encoding='utf-8') as f:
"Sin dithering": "none", TRANSLATIONS = json.load(f)
"Floyd-Steinberg": "floyd",
"Bayer 2x2": "bayer2x2",
"Bayer 4x4": "bayer4x4",
"Bayer 8x8": "bayer8x8",
"Bayer 16x16": "bayer16x16",
"Atkinson": "atkinson",
"Jarvis-Judice-Ninke": "jjn"
}
HTML_FORM = '''
<!doctype html>
<html>
<head>
<title>CatNote</title>
<link rel="manifest" href="/static/manifest.json">
<style>
body {
font-family: sans-serif;
background: #181c1f;
color: #ddd;
min-height: 100vh;
margin: 0;
}
.centered-flex {
display: flex;
justify-content: center;
align-items: flex-start;
height: 100vh;
gap: 2.6em;
padding-top: 2em;
}
.form-card {
background: #22282c;
padding: 2em 2em 1em 2em;
border-radius: 1.5em;
box-shadow: 0 0 12px 0 #000a;
min-width: 410px;
max-width: 450px;
margin-right: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.markdown-ref {
background: #22282c;
border-radius: 1.2em;
box-shadow: 0 0 12px 0 #000a;
padding: 2em 2em 1.5em 2em;
color: #b6c8e0;
font-size: 1.08rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
max-width: 410px;
}
.markdown-ref h4 {
margin: 0 0 0.4em 0;
color: #88e0ff;
font-size: 1.12rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.markdown-ref ul {
margin: 0.6em 0 0.3em 0.1em;
padding-left: 1.1em;
}
.markdown-ref li {
margin-bottom: 0.36em;
}
.markdown-ref code {
background: #181c1f;
border-radius: 0.5em;
padding: 0.12em 0.46em;
color: #aef6cb;
font-size: 0.98rem;
margin-left: 0.18em;
}
textarea {
width: 100%;
height: 14em;
padding: 0.75em;
border-radius: 0.7em;
border: 1.5px solid #444;
background: #181c1f;
color: #eee;
font-size: 1.06rem;
margin-bottom: 1em;
resize: vertical;
font-family: 'DejaVu Sans Mono', 'Fira Mono', 'monospace';
box-sizing: border-box;
margin-right: 0;
min-width: 0;
}
.buttons {
display: flex;
gap: 1em;
justify-content: center;
}
button[type=submit] {
background: linear-gradient(90deg, #8ee3c1, #35a7ff);
color: #222;
font-weight: bold;
font-size: 1.12rem;
border: none;
border-radius: 0.7em;
padding: 0.5em 1.6em;
margin-top: 0.1em;
box-shadow: 0 2px 12px #1116;
cursor: pointer;
transition: filter 0.2s, box-shadow 0.2s;
filter: brightness(1);
}
button[type=submit]:hover {
filter: brightness(1.12);
box-shadow: 0 4px 18px #2229;
}
button[type=submit][name=print] {
background: linear-gradient(90deg, #ffeb3b, #ff9100);
color: #181c1f;
}
.preview-card {
display: flex;
flex-direction: column;
background: #23282d;
padding: 2em 1.4em 1em 1.4em;
border-radius: 1.5em;
box-shadow: 0 0 12px 0 #0007;
min-width: 400px;
text-align: center;
box-sizing: border-box;
margin-left: 0;
align-items: center;
}
img {
margin-top: 1em;
max-width: 100%;
background: #fff;
box-shadow: 0 0 8px 1px #111a;
}
.status-msg {
margin: 0.6em 0 0.1em 0;
font-size: 1.12rem;
border-radius: 0.7em;
background: #294b3a;
color: #9fffc6;
padding: 0.55em 1.2em;
font-weight: 600;
box-shadow: 0 0 6px #183a24a0;
display: inline-block;
border-left: 4px solid #4ffab0;
}
.status-err {
background: #48202c;
color: #ffdbe4;
border-left: 4px solid #ff6384;
box-shadow: 0 0 8px #22000690;
}
label {
font-size: 1em;
padding: 0 1em 0 1em;
}
input[type=file] {
margin: 0.5em 1em 1.5em 1em;
}
input[type=file]::file-selector-button {
background: linear-gradient(90deg, #8ee3c1, #35a7ff);
color: #222;
font-weight: bold;
border: none;
padding: 0.5em 1.2em;
border-radius: 0.7em;
box-shadow: 0 2px 12px #1116;
cursor: pointer;
transition: filter 0.2s, box-shadow 0.2s;
margin-right: 0.5em;
}
input[type=file]::file-selector-button:hover {
filter: brightness(1.12);
box-shadow: 0 4px 18px #2229;
}
.print-frame {
position: relative;
padding: 10px 0 10px 0;
margin-top: 1em;
overflow: hidden;
max-width: 424px;
}
.print-frame::before,
.print-frame::after {
content: "";
position: absolute;
left: 0;
width: 100%;
height: 10px;
background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDQuMjMzMyA0LjIzMzMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIHRyYW5zZm9ybT0ibWF0cml4KC4wMzYxMzUgMCAwIC4wNDE3MjUgMS4xNTY4IDEuMTI4OSkiIGQ9Im04NS4xNDEgNzQuNDAzLTExNy4xNS0xZS02IDU4LjU3Ny0xMDEuNDZ6IiBmaWxsPSIjZmZmIiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIiBzdHJva2Utb3BhY2l0eT0iLjk3NjQ3IiBzdHJva2Utd2lkdGg9Ii4yNjQ1OCIvPgo8L3N2Zz4K') repeat-x left;
background-size: 10px 384px;
z-index: 1;
}
.print-frame::before {
top: 1px;
}
.print-frame::after {
bottom: 1px;
transform: rotate(180deg);
}
.print-frame img {
background: #fff;
padding: 20px;
display: block;
margin: 0 auto;
box-shadow: none;
position: relative;
width: 100%;
max-width: 384px;
z-index: 2;
}
textarea {
font-size: 1.16em !important;
padding: 1em;
min-height: 12em;
}
.options {
display: flex;
flex-direction: column;
align-items: left;
justify-content: space-between;
margin-bottom: 1.2em;
}
select {
background: #181c1f;
text-align: center;
text-align-last: center;
border: 1.5px solid #444;
color: #eee;
border-radius: 0.7em;
margin: 1em;
padding: 0.5em 1em;
}
select option {
text-align: left;
}
input[type=number] {
background: #181c1f;
text-align: center;
border: 1.5px solid #444;
color: #eee;
border-radius: 0.7em;
margin: 1em;
padding: 0.5em 1em;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.options-dithering,
.options-intensity,
.options-rotation,
.options-printmode {
display: flex;
flex-direction: column;
}
#loading-box {
display: none;
flex-direction: column;
text-align: center;
box-sizing: border-box;
width: 100%;
padding: 1em;
}
#printing-text {
color: #eee;
font-size: 1em;
font-weight: bold;
margin-bottom: 1em;
}
.progress-bar {
position: relative;
width: 100%;
height: 18px;
background: #181c1f;
border: 1.5px solid #444;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px #1116;
}
.progress-bar-inner {
width: 40%;
height: 100%;
background: linear-gradient(90deg, #8ee3c1, #35a7ff);
border-radius: 12px;
position: absolute;
left: -40%;
animation: indeterminate-bar 1.2s infinite
}
@keyframes indeterminate-bar {
0% { left: 0%; }
50% { left: calc(100% - 40%); }
100% { left: 0%; }
}
/* this next bit is a mess but it works */
.options-bannermode {
display: flex;
align-items: left;
justify-content: space-between;
margin-left: 1em;
margin-right: 1em;
}
.options-bannermode label {
font-size: 1em;
color: #eee;
margin-bottom: 1em;
display: block;
}
.options-bannermode-inner {
display: block;
align-items: center;
position: relative;
padding-left: 35px;
margin-bottom: 12px;
cursor: pointer;
font-size: 1em;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Hide the browser's default checkbox */
.options-bannermode-inner input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* Create a custom checkbox */
.checkmark {
position: absolute;
top: -25%;
left: 0;
height: 25px;
width: 25px;
background-color: #181c1f;
border-radius: 0.35em;
border: 1.5px solid #444;
}
/* On mouse-over, add a grey background color */
.options-bannermode-inner:hover input ~ .checkmark {
filter: brightness(1.12);
}
/* When the checkbox is checked, add a blue background */
.options-bannermode-inner input:checked ~ .checkmark {
background: linear-gradient(135deg, #8ee3c1, #35a7ff);
}
/* Create the checkmark/indicator (hidden when not checked) */
.checkmark:after {
content: "";
position: absolute;
display: none;
}
/* Show the checkmark when checked */
.options-bannermode-inner input:checked ~ .checkmark:after {
display: block;
}
/* Style the checkmark/indicator */
.options-bannermode-inner .checkmark:after {
left: 9px;
top: 5px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 3px 3px 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
@media (max-width: 1000px) {
body {
font-size: 1.28em !important;
}
h2, h3, h4 {
font-size: 2em !important;
}
.centered-flex {
flex-direction: column;
align-items: center;
height: auto;
gap: 1.6em;
}
.form-card,
.preview-card,
.markdown-ref {
font-size: 1.18em !important;
min-width: auto;
max-width: 90vw;
width: 100%;
margin: 0.8em 0;
}
textarea {
font-size: 1.16em !important;
padding: 1em;
min-height: 12em;
}
.status-msg {
font-size: 1.25em !important;
padding: 0.8em 1.6em;
}
button[type=submit] {
font-size: 1.3em !important;
padding: 0.9em 2.2em;
}
input[type=file] {
font-size: 1.16em !important;
padding: inherit 2.2em;
}
label {
font-size: 1.16em !important;
padding: inherit 2.2em;
}
select {
font-size: 1.16em !important;
padding: 0.6em 1.2em;
}
input[type=number] {
font-size: 1.16em !important;
padding: 0.6em 1.2em;
}
/* i'm so sorry, i'm not bothered to find out why or how this works */
.options-bannermode-inner .checkmark {
top: -10%;
}
.options-bannermode-inner .checkmark::after {
left: 13px;
top: 7px;
transform: rotate(45deg) scale(1.66);
}
.checkmark {
width: 1.2em;
height: 1.2em;
}
.options-bannermode-inner {
padding-left: 55px;
}
</style>
</head>
<body>
<div class="centered-flex">
<div class="form-card">
<center>
<h2 style="margin-top:0.2em;">😺 CatNote 🖨️</h2>
</center>
<form method="post" enctype="multipart/form-data">
<textarea name="md" placeholder="Ingrese Markdown aquí...">{{ default_md }}</textarea><br>
<label for="userimg" style="display: inline-block; margin-bottom: 0.5em;">Subir imagen (opcional):</label><br>
<input type="file" name="userimg" accept="image/png, image/jpeg"><br>
<div class="options">
<div class="options-bannermode">
<label class="options-bannermode-inner">
<input type="checkbox" name="bannermode" value="1" {% if current_bannermode %}checked{% endif %}>
<span class="checkmark"></span>
Modo Banner (vertical)
</label>
</div>
<div class="options-dithering">
<label for="dithering">Dithering:</label>
<select name="dithering">
{% for label, mode in dithering_modes.items() %}
<option value="{{ mode }}" {% if mode == current_dithering %}selected="selected"{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="options-intensity">
<label for="intensity">Intensidad:</label>
<input type="number" name="intensity" value="85" min="0" max="100" step="1">
</div>
<div class="options-rotation">
<label for="rotation">Rotar imagen:</label>
<select name="rotation">
<option value="0" {% if 0 == current_rotation %}selected="selected"{% endif %}>0°</option>
<option value="90" {% if 90 == current_rotation %}selected="selected"{% endif %}>90°</option>
<option value="180" {% if 180 == current_rotation %}selected="selected"{% endif %}>180°</option>
<option value="270" {% if 270 == current_rotation %}selected="selected"{% endif %}>270°</option>
</select>
</div>
<div class="options-printmode">
<label for="printmode">Modo de impresión:</label>
<select name="printmode">
<option value="1bpp" {% if "1bpp" == current_printmode %}selected="selected"{% endif %}>1-bit</option>
<option value="4bpp" {% if "4bpp" == current_printmode %}selected="selected"{% endif %}>4-bit</option>
</select>
</div>
<div id="loading-box">
<div id="printing-text">Imprimiendo...</div>
<div class="progress-bar">
<div class="progress-bar-inner"></div>
</div>
</div>
</div>
<div class="buttons">
<button type="submit" name="generate">📷 Generar</button>
<button type="submit" name="print">🖨️ Imprimir</button>
</div>
</form>
{% if printed %}
<div class="status-msg">✅ Enviado a impresora</div>
{% endif %}
{% if error %}
<div class="status-msg status-err">⚠️ {{ error }}</div>
{% endif %}
</div>
<div class="preview-card">
{% if img %}
<h2 style="margin-top:0.1em; margin-bottom:0;">Vista previa</h2>
<div class="print-frame">
<img src="data:image/png;base64,{{ img }}">
</div>
{% else %}
<p style="opacity:.6;">Su vista previa aparecerá aquí</p>
{% endif %}
</div>
<div class="markdown-ref">
<h4>Referencia rápida de Markdown</h4>
<ul>
<li><b>Negrita:</b> <code>**texto**</code></li>
<li><b>Cursiva:</b> <code>*texto*</code></li>
<li><b>Negrita y cursiva:</b> <code>***texto***</code></li>
<li><b>Encabezado grande:</b> <code># Título</code></li>
<li><b>Encabezado mediano:</b> <code>## Título</code></li>
<li><b>Encabezado chico:</b> <code>### Título</code></li>
<li><b>Lista con viñetas:</b> <code>- Elemento</code></li>
<li><b>Lista numerada:</b> <code>1. Elemento</code></li>
<li><b>Imágen:</b> <code>![Texto alternativo](Enlace a imágen)</code></li>
<li><b>Salto de línea:</b> Deje una línea vacía</li>
<li><b>Imagen subida:</b> <code>!(img)</code> (usa la imagen cargada abajo)</li>
</ul>
</div>
</div>
<script>
document.querySelector('form').addEventListener('submit', function(e) {
if (document.activeElement && document.activeElement.name === "print") {
document.getElementById('loading-box').style.display = 'flex';
}
});
</script>
</body>
</html>
'''
def remove_uploaded_img(): def remove_uploaded_img():
path = session.pop('uploaded_img_path', None) path = session.pop('uploaded_img_path', None)
@@ -589,13 +36,31 @@ def remove_uploaded_img():
except Exception: except Exception:
pass pass
def remove_alpha(img):
if img.mode in ("RGBA", "LA"):
background = Image.new("RGBA", img.size, (255, 255, 255, 255)) # White background
img = Image.alpha_composite(background, img.convert("RGBA"))
return img.convert("RGB")
elif img.mode == "P" and 'transparency' in img.info:
img = img.convert("RGBA")
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(background, img)
return img.convert("RGB")
else:
return img
def bleh_image_from_url(url, dithering, mode): def bleh_image_from_url(url, dithering, mode):
resp = requests.get(url, stream=True) resp = requests.get(url, stream=True)
resp.raise_for_status() resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
img = remove_alpha(img)
buf = io.BytesIO()
img.save(buf, format="PNG")
image_bytes_no_alpha = buf.getvalue()
bleh = subprocess.Popen( bleh = subprocess.Popen(
["./bleh", "-o", "-", "-mode", f"{mode}", "-d", f"{dithering}", "-"], ["./bleh", "-o", "-", "-mode", f"{mode}", "-d", f"{dithering}", "-"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = bleh.communicate(resp.content) out, err = bleh.communicate(image_bytes_no_alpha)
if bleh.returncode != 0: if bleh.returncode != 0:
raise RuntimeError(f"Driver failed: {err.decode()}") raise RuntimeError(f"Driver failed: {err.decode()}")
img = Image.open(io.BytesIO(out)).convert("L") img = Image.open(io.BytesIO(out)).convert("L")
@@ -604,12 +69,20 @@ def bleh_image_from_url(url, dithering, mode):
img = img.resize((IMAGE_WIDTH, img.height), Image.LANCZOS) img = img.resize((IMAGE_WIDTH, img.height), Image.LANCZOS)
return img return img
def bleh_image_from_bytes(image_bytes, dithering, mode): def bleh_image_from_bytes(image_bytes, dithering, mode):
# OPEN the uploaded image and remove alpha
img = Image.open(io.BytesIO(image_bytes))
img = remove_alpha(img)
buf = io.BytesIO()
img.save(buf, format="PNG")
image_bytes_no_alpha = buf.getvalue()
# pass that to bleh
bleh = subprocess.Popen( bleh = subprocess.Popen(
["./bleh", "-o", "-", "-mode", f"{mode}", "-d", f"{dithering}", "-"], ["./bleh", "-o", "-", "-mode", f"{mode}", "-d", f"{dithering}", "-"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
) )
out, err = bleh.communicate(image_bytes) out, err = bleh.communicate(image_bytes_no_alpha)
if bleh.returncode != 0: if bleh.returncode != 0:
raise RuntimeError(f"Driver failed: {err.decode()}") raise RuntimeError(f"Driver failed: {err.decode()}")
img = Image.open(io.BytesIO(out)).convert("L") img = Image.open(io.BytesIO(out)).convert("L")
@@ -906,6 +379,20 @@ def index():
md = "" md = ""
printed = False printed = False
error = None error = None
lang = request.args.get('lang') or session.get('lang', 'en')
session['lang'] = lang
t = TRANSLATIONS.get(lang, TRANSLATIONS['en'])
dithering_modes = {
t['no_dithering']: "none",
"Floyd-Steinberg": "floyd",
"Bayer 2x2": "bayer2x2",
"Bayer 4x4": "bayer4x4",
"Bayer 8x8": "bayer8x8",
"Bayer 16x16": "bayer16x16",
"Atkinson": "atkinson",
"Jarvis-Judice-Ninke": "jjn"
}
if request.method == "POST": if request.method == "POST":
userimg = request.files.get("userimg") userimg = request.files.get("userimg")
uploaded_img_bytes = None uploaded_img_bytes = None
@@ -955,7 +442,7 @@ def index():
if intensity < 0 or intensity > 100: if intensity < 0 or intensity > 100:
raise ValueError("Intensity must be between 0 and 100") raise ValueError("Intensity must be between 0 and 100")
except ValueError: except ValueError:
error = "Intensidad debe ser un número entre 0 y 100" error = "Intensity must be a number between 0 and 100"
intensity = 85 intensity = 85
session['intensity'] = intensity session['intensity'] = intensity
# If print button pressed, send to driver # If print button pressed, send to driver
@@ -977,8 +464,21 @@ def index():
printed = True printed = True
except Exception as e: except Exception as e:
error = f"Failed to print: {e}" error = f"Failed to print: {e}"
return render_template_string(HTML_FORM, dithering_modes=DITHERING_MODES, img=img_data, default_md=md, return render_template(
printed=printed, error=error, current_dithering=session.get('dithering', 'floyd'), current_rotation=session.get('rotation', 0), current_printmode=session.get('printmode', '1bpp'), current_bannermode=session.get('bannermode', False)) "index.html",
dithering_modes=dithering_modes,
img=img_data,
default_md=md,
printed=printed,
error=error,
current_intensity=session.get('intensity', 85),
current_dithering=session.get('dithering', 'floyd'),
current_rotation=session.get('rotation', 0),
current_printmode=session.get('printmode', '1bpp'),
current_bannermode=session.get('bannermode', False),
t=t,
lang=lang
)
@app.route('/manifest.json') @app.route('/manifest.json')
def manifest(): def manifest():
@@ -997,4 +497,3 @@ if __name__ == "__main__":
parser.add_argument('-p', '--port', type=int, default=5000, help='Port to run the server on (default: 5000)') parser.add_argument('-p', '--port', type=int, default=5000, help='Port to run the server on (default: 5000)')
args = parser.parse_args() args = parser.parse_args()
app.run(host='0.0.0.0', port=args.port) app.run(host='0.0.0.0', port=args.port)

74
static/translations.json Normal file
View File

@@ -0,0 +1,74 @@
{
"en": {
"catnote": "CatNote",
"enter_markdown_here": "Enter Markdown here...",
"upload_image_optional": "Upload image (optional):",
"banner_mode_vertical": "Banner Mode (vertical)",
"dithering": "Dithering:",
"intensity": "Intensity:",
"rotate_image": "Rotate image:",
"print_mode": "Print mode:",
"printing": "Printing...",
"sent_to_printer": "Sent to printer",
"preview": "Preview",
"your_preview_will_appear_here": "Your preview will appear here",
"quick_markdown_reference": "Quick Markdown Cheatsheet",
"bold": "Bold:",
"italic": "Italic:",
"bold_and_italic": "Bold and Italic:",
"large_header": "Large Header:",
"medium_header": "Medium Header:",
"small_header": "Small Header:",
"bullet_list": "Bullet List:",
"numbered_list": "Numbered List:",
"image": "Image:",
"line_break": "Line break:",
"uploaded_image": "Uploaded image:",
"generate_btn": "🖼️ Generate",
"print_btn": "🖨️ Print",
"text": "text",
"title": "Title",
"element": "Element",
"alt_text": "Alt text",
"image_url": "Image URL",
"leave_an_empty_line": "Leave an empty line",
"uses_the_uploaded_image": "(uses the uploaded image)",
"no_dithering": "No dithering"
},
"es": {
"catnote": "CatNote",
"enter_markdown_here": "Ingrese Markdown aquí...",
"upload_image_optional": "Subir imagen (opcional):",
"banner_mode_vertical": "Modo Banner (vertical)",
"dithering": "Dithering:",
"intensity": "Intensidad:",
"rotate_image": "Rotar imagen:",
"print_mode": "Modo de impresión:",
"printing": "Imprimiendo...",
"sent_to_printer": "Enviado a impresora",
"preview": "Vista previa",
"your_preview_will_appear_here": "Su vista previa aparecerá aquí",
"quick_markdown_reference": "Referencia rápida de Markdown",
"bold": "Negrita:",
"italic": "Cursiva:",
"bold_and_italic": "Negrita y cursiva:",
"large_header": "Encabezado grande:",
"medium_header": "Encabezado mediano:",
"small_header": "Encabezado chico:",
"bullet_list": "Lista con viñetas:",
"numbered_list": "Lista numerada:",
"image": "Imágen:",
"line_break": "Salto de línea:",
"uploaded_image": "Imagen subida:",
"generate_btn": "🖼️ Generar",
"print_btn": "🖨️ Imprimir",
"text": "texto",
"title": "Título",
"element": "Elemento",
"alt_text": "Texto alternativo",
"image_url": "URL de la imagen",
"leave_an_empty_line": "Deje una línea vacía",
"uses_the_uploaded_image": "(usa la imagen cargada)",
"no_dithering": "Sin dithering"
}
}

645
templates/index.html Normal file
View File

@@ -0,0 +1,645 @@
<!doctype html>
<html>
<head>
<title>{{ t['catnote'] }}</title>
<link rel="manifest" href="/static/manifest.json">
<style>
body {
font-family: sans-serif;
background: #181c1f;
color: #ddd;
min-height: 100vh;
margin: 0;
}
.centered-flex {
display: flex;
justify-content: center;
align-items: flex-start;
height: 100vh;
gap: 2.6em;
padding-top: 2em;
}
.form-card {
background: #22282c;
padding: 2em 2em 1em 2em;
border-radius: 1.5em;
box-shadow: 0 0 12px 0 #000a;
min-width: 410px;
max-width: 450px;
margin-right: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.markdown-ref {
background: #22282c;
border-radius: 1.2em;
box-shadow: 0 0 12px 0 #000a;
padding: 2em 2em 1.5em 2em;
color: #b6c8e0;
font-size: 1.08rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
max-width: 410px;
}
.markdown-ref h4 {
margin: 0 0 0.4em 0;
color: #88e0ff;
font-size: 1.12rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.markdown-ref ul {
margin: 0.6em 0 0.3em 0.1em;
padding-left: 1.1em;
}
.markdown-ref li {
margin-bottom: 0.36em;
}
.markdown-ref code {
background: #181c1f;
border-radius: 0.5em;
padding: 0.12em 0.46em;
color: #aef6cb;
font-size: 0.98rem;
margin-left: 0.18em;
}
textarea {
width: 100%;
height: 14em;
padding: 0.75em;
border-radius: 0.7em;
border: 1.5px solid #444;
background: #181c1f;
color: #eee;
font-size: 1.06rem;
margin-bottom: 1em;
resize: vertical;
font-family: 'DejaVu Sans Mono', 'Fira Mono', 'monospace';
box-sizing: border-box;
margin-right: 0;
min-width: 0;
}
.buttons {
display: flex;
gap: 1em;
justify-content: center;
}
button[type=submit] {
background: linear-gradient(90deg, #8ee3c1, #35a7ff);
color: #222;
font-weight: bold;
font-size: 1.12rem;
border: none;
border-radius: 0.7em;
padding: 0.5em 1.6em;
margin-top: 0.1em;
box-shadow: 0 2px 12px #1116;
cursor: pointer;
transition: filter 0.2s, box-shadow 0.2s;
filter: brightness(1);
min-width: 150px;
}
button[type=submit]:hover {
filter: brightness(1.12);
box-shadow: 0 4px 18px #2229;
}
button[type=submit][name=print] {
background: linear-gradient(90deg, #ffeb3b, #ff9100);
color: #181c1f;
}
.preview-card {
display: flex;
flex-direction: column;
background: #23282d;
padding: 2em 1.4em 1em 1.4em;
border-radius: 1.5em;
box-shadow: 0 0 12px 0 #0007;
min-width: 400px;
text-align: center;
box-sizing: border-box;
margin-left: 0;
align-items: center;
}
img {
margin-top: 1em;
max-width: 100%;
background: #fff;
box-shadow: 0 0 8px 1px #111a;
}
.status-msg {
margin: 0.6em 0 0.1em 0;
font-size: 1.12rem;
border-radius: 0.7em;
background: #294b3a;
color: #9fffc6;
padding: 0.55em 1.2em;
font-weight: 600;
box-shadow: 0 0 6px #183a24a0;
display: inline-block;
border-left: 4px solid #4ffab0;
}
.status-err {
background: #48202c;
color: #ffdbe4;
border-left: 4px solid #ff6384;
box-shadow: 0 0 8px #22000690;
}
label {
font-size: 1em;
padding: 0 1em 0 1em;
}
input[type=file] {
margin: 0.5em 1em 1.5em 1em;
}
input[type=file]::file-selector-button {
background: linear-gradient(90deg, #8ee3c1, #35a7ff);
color: #222;
font-weight: bold;
border: none;
padding: 0.5em 1.2em;
border-radius: 0.7em;
box-shadow: 0 2px 12px #1116;
cursor: pointer;
transition: filter 0.2s, box-shadow 0.2s;
margin-right: 0.5em;
}
input[type=file]::file-selector-button:hover {
filter: brightness(1.12);
box-shadow: 0 4px 18px #2229;
}
.print-frame {
position: relative;
padding: 10px 0 10px 0;
margin-top: 1em;
overflow: hidden;
max-width: 424px;
}
.print-frame::before,
.print-frame::after {
content: "";
position: absolute;
left: 0;
width: 100%;
height: 10px;
background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDQuMjMzMyA0LjIzMzMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIHRyYW5zZm9ybT0ibWF0cml4KC4wMzYxMzUgMCAwIC4wNDE3MjUgMS4xNTY4IDEuMTI4OSkiIGQ9Im04NS4xNDEgNzQuNDAzLTExNy4xNS0xZS02IDU4LjU3Ny0xMDEuNDZ6IiBmaWxsPSIjZmZmIiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIiBzdHJva2Utb3BhY2l0eT0iLjk3NjQ3IiBzdHJva2Utd2lkdGg9Ii4yNjQ1OCIvPgo8L3N2Zz4K') repeat-x left;
background-size: 10px 384px;
z-index: 1;
}
.print-frame::before {
top: 1px;
}
.print-frame::after {
bottom: 1px;
transform: rotate(180deg);
}
.print-frame img {
background: #fff;
padding: 20px;
display: block;
margin: 0 auto;
box-shadow: none;
position: relative;
width: 100%;
max-width: 384px;
z-index: 2;
}
textarea {
font-size: 1.16em !important;
padding: 1em;
min-height: 12em;
}
.options {
display: flex;
flex-direction: column;
align-items: left;
justify-content: space-between;
margin-bottom: 1.2em;
}
select {
background: #181c1f;
text-align: center;
text-align-last: center;
border: 1.5px solid #444;
color: #eee;
border-radius: 0.7em;
margin: 1em;
padding: 0.5em 1em;
}
select option {
text-align: left;
}
input[type=number] {
background: #181c1f;
text-align: center;
border: 1.5px solid #444;
color: #eee;
border-radius: 0.7em;
margin: 1em;
padding: 0.5em 1em;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.options-dithering,
.options-intensity,
.options-rotation,
.options-printmode {
display: flex;
flex-direction: column;
}
#loading-box {
display: none;
flex-direction: column;
text-align: center;
box-sizing: border-box;
width: 100%;
padding: 1em;
}
#printing-text {
color: #eee;
font-size: 1em;
font-weight: bold;
margin-bottom: 1em;
}
.progress-bar {
position: relative;
width: 100%;
height: 18px;
background: #181c1f;
border: 1.5px solid #444;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px #1116;
}
.progress-bar-inner {
width: 40%;
height: 100%;
background: linear-gradient(90deg, #8ee3c1, #35a7ff);
border-radius: 12px;
position: absolute;
left: -40%;
animation: indeterminate-bar 1.2s infinite
}
@keyframes indeterminate-bar {
0% {
left: 0%;
}
50% {
left: calc(100% - 40%);
}
100% {
left: 0%;
}
}
/* this next bit is a mess but it works */
.options-bannermode {
display: flex;
align-items: left;
justify-content: space-between;
margin-left: 1em;
margin-right: 1em;
}
.options-bannermode label {
font-size: 1em;
color: #eee;
margin-bottom: 1em;
display: block;
}
.options-bannermode-inner {
display: block;
align-items: center;
position: relative;
padding-left: 35px;
margin-bottom: 12px;
cursor: pointer;
font-size: 1em;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Hide the browser's default checkbox */
.options-bannermode-inner input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* Create a custom checkbox */
.checkmark {
position: absolute;
top: -25%;
left: 0;
height: 25px;
width: 25px;
background-color: #181c1f;
border-radius: 0.35em;
border: 1.5px solid #444;
}
/* On mouse-over, add a grey background color */
.options-bannermode-inner:hover input~.checkmark {
filter: brightness(1.12);
}
/* When the checkbox is checked, add a blue background */
.options-bannermode-inner input:checked~.checkmark {
background: linear-gradient(135deg, #8ee3c1, #35a7ff);
}
/* Create the checkmark/indicator (hidden when not checked) */
.checkmark:after {
content: "";
position: absolute;
display: none;
}
/* Show the checkmark when checked */
.options-bannermode-inner input:checked~.checkmark:after {
display: block;
}
/* Style the checkmark/indicator */
.options-bannermode-inner .checkmark:after {
left: 9px;
top: 5px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 3px 3px 0;
-webkit-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
#lang-switcher {
position: fixed;
right: 24px;
bottom: 18px;
z-index: 1000;
background: #23282d;
border-radius: 0.7em;
box-shadow: 0 2px 12px #1116;
padding: 0.5em 1em;
opacity: 0.95;
}
#lang-select {
background: #181c1f;
color: #eee;
border: 1.5px solid #444;
border-radius: 0.7em;
padding: 0.4em 1em;
font-size: 1em;
}
@media (max-width: 1000px) {
body {
font-size: 1.28em !important;
}
h2,
h3,
h4 {
font-size: 2em !important;
}
.centered-flex {
flex-direction: column;
align-items: center;
height: auto;
gap: 1.6em;
}
.form-card,
.preview-card,
.markdown-ref {
font-size: 1.18em !important;
min-width: auto;
max-width: 90vw;
width: 100%;
margin: 0.8em 0;
}
textarea {
font-size: 1.16em !important;
padding: 1em;
min-height: 12em;
}
.status-msg {
font-size: 1.25em !important;
padding: 0.8em 1.6em;
}
button[type=submit] {
font-size: 1.3em !important;
padding: 0.9em 2.2em;
}
input[type=file] {
font-size: 1.16em !important;
padding: inherit 2.2em;
}
label {
font-size: 1.16em !important;
padding: inherit 2.2em;
}
select {
font-size: 1.16em !important;
padding: 0.6em 1.2em;
}
input[type=number] {
font-size: 1.16em !important;
padding: 0.6em 1.2em;
}
/* i'm so sorry, i'm not bothered to find out why or how this works */
.options-bannermode-inner .checkmark {
top: -10%;
}
.options-bannermode-inner .checkmark::after {
left: 13px;
top: 7px;
transform: rotate(45deg) scale(1.66);
}
.checkmark {
width: 1.2em;
height: 1.2em;
}
.options-bannermode-inner {
padding-left: 55px;
}
}
</style>
</head>
<body>
<div class="centered-flex">
<div class="form-card">
<center>
<h2 style="margin-top:0.2em;">😺 {{ t['catnote'] }} 🖨️</h2>
</center>
<form method="post" enctype="multipart/form-data">
<textarea name="md" placeholder="{{ t['enter_markdown_here'] }}">{{ default_md }}</textarea><br>
<label for="userimg" style="display: inline-block; margin-bottom: 0.5em;">{{ t['upload_image_optional']
}}</label><br>
<input type="file" name="userimg" accept="image/png, image/jpeg"><br>
<div class="options">
<div class="options-bannermode">
<label class="options-bannermode-inner">
<input type="checkbox" name="bannermode" value="1" {% if current_bannermode %}checked{% endif %}>
<span class="checkmark"></span>
{{ t['banner_mode_vertical'] }}
</label>
</div>
<div class="options-dithering">
<label for="dithering">{{ t['dithering'] }}</label>
<select name="dithering">
{% for label, mode in dithering_modes.items() %}
<option value="{{ mode }}" {% if mode==current_dithering %}selected="selected" {% endif %}>{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="options-intensity">
<label for="intensity">{{ t['intensity'] }}</label>
<input type="number" name="intensity" value="{{ current_intensity }}" min="0" max="100" step="1">
</div>
<div class="options-rotation">
<label for="rotation">{{ t['rotate_image'] }}</label>
<select name="rotation">
<option value="0" {% if 0==current_rotation %}selected="selected" {% endif %}></option>
<option value="90" {% if 90==current_rotation %}selected="selected" {% endif %}>90°</option>
<option value="180" {% if 180==current_rotation %}selected="selected" {% endif %}>180°</option>
<option value="270" {% if 270==current_rotation %}selected="selected" {% endif %}>270°</option>
</select>
</div>
<div class="options-printmode">
<label for="printmode">{{ t['print_mode'] }}</label>
<select name="printmode">
<option value="1bpp" {% if "1bpp"==current_printmode %}selected="selected" {% endif %}>1-bit</option>
<option value="4bpp" {% if "4bpp"==current_printmode %}selected="selected" {% endif %}>4-bit</option>
</select>
</div>
<div id="loading-box">
<div id="printing-text">{{ t['printing'] }}</div>
<div class="progress-bar">
<div class="progress-bar-inner"></div>
</div>
</div>
</div>
<div class="buttons">
<button type="submit" name="generate">{{ t['generate_btn'] }}</button>
<button type="submit" name="print">{{ t['print_btn'] }}</button>
</div>
</form>
{% if printed %}
<div class="status-msg">✅ {{ t['sent_to_printer'] }}</div>
{% endif %}
{% if error %}
<div class="status-msg status-err">⚠️ {{ error }}</div>
{% endif %}
</div>
<div class="preview-card">
{% if img %}
<h2 style="margin-top:0.1em; margin-bottom:0;">{{ t['preview'] }}</h2>
<div class="print-frame">
<img src="data:image/png;base64,{{ img }}">
</div>
{% else %}
<p style="opacity:.6;">{{ t['your_preview_will_appear_here'] }}</p>
{% endif %}
</div>
<div class="markdown-ref">
<h4>{{ t['quick_markdown_reference'] }}</h4>
<ul>
<li><b>{{ t['bold'] }}</b> <code>**{{ t['text'] }}**</code></li>
<li><b>{{ t['italic'] }}</b> <code>*{{ t['text'] }}*</code></li>
<li><b>{{ t['bold_and_italic'] }}</b> <code>***{{ t['text'] }}***</code></li>
<li><b>{{ t['large_header'] }}</b> <code># {{ t['title'] }}</code></li>
<li><b>{{ t['medium_header'] }}</b> <code>## {{ t['title'] }}</code></li>
<li><b>{{ t['small_header'] }}</b> <code>### {{ t['title'] }}</code></li>
<li><b>{{ t['bullet_list'] }}</b> <code>- {{ t['element'] }}</code></li>
<li><b>{{ t['numbered_list'] }}</b> <code>1. {{ t['element'] }}</code></li>
<li><b>{{ t['image'] }}</b> <code>![{{ t['alt_text'] }}]({{ t['image_url'] }})</code></li>
<li><b>{{ t['line_break'] }}</b> {{ t['Leave an empty line'] }}</li>
<li><b>{{ t['uploaded_image'] }}</b> <code>!(img)</code> {{ t['uses_the_uploaded_image'] }}</li>
</ul>
</div>
</div>
<div id="lang-switcher">
<select id="lang-select">
<option value="es" {% if lang=='es' %}selected{% endif %}>Español</option>
<option value="en" {% if lang=='en' %}selected{% endif %}>English</option>
</select>
</div>
<script>
document.getElementById('lang-select').addEventListener('change', function () {
window.location.search = '?lang=' + this.value;
});
document.querySelector('form').addEventListener('submit', function (e) {
if (document.activeElement && document.activeElement.name === "print") {
document.getElementById('loading-box').style.display = 'flex';
}
});
</script>
</body>
</html>