Compare commits
13 Commits
84c0be9742
...
daemon
| Author | SHA1 | Date | |
|---|---|---|---|
| 34733206c2 | |||
| 052723bdbc | |||
| 3a06ccbb91 | |||
| 0d47741293 | |||
| 072f70c7aa | |||
| b6223caa80 | |||
| d7fd4cd226 | |||
| 4392e811e6 | |||
| 338f489582 | |||
| 3014f18e16 | |||
| 49f326b5ae | |||
| 1b75c799e5 | |||
| a146cbbb9c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.venv
|
||||
__pycache__
|
||||
bleh
|
||||
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Optional but useful for Pillow runtime compatibility in slim images
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libjpeg62-turbo \
|
||||
zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Non-root runtime user
|
||||
RUN useradd -m -u 10001 catnote && chown -R catnote:catnote /app
|
||||
USER catnote
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# BLEHD_SOCKET can be overridden at runtime
|
||||
ENV BLEHD_SOCKET=/run/bleh/blehd.sock
|
||||
|
||||
CMD ["python", "app.py", "--port", "5000"]
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
* 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+
|
||||
* Flask
|
||||
* Requests
|
||||
* Markdown
|
||||
* JSON
|
||||
* Pillow (PIL)
|
||||
* `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:
|
||||
|
||||
```
|
||||
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`).
|
||||
@@ -67,4 +68,6 @@ CatNote is a minimalist web application that lets you generate Markdown-based re
|
||||
|
||||
This project is licensed under the terms of the GNU General Public License v3.0 or later. See [COPYING](./COPYING) for details.
|
||||
|
||||
The Ubuntu Font is licensed under the Ubuntu Font Licence 1.0. See [UFL.txt](./fonts/ubuntu/UFL.txt) for details.
|
||||
|
||||
> Disclaimer: While a license was only added after the project’s initial commits, the current license applies retroactively to all previous commits of this repository.
|
||||
|
||||
818
app.py
818
app.py
@@ -1,486 +1,217 @@
|
||||
import io
|
||||
import tempfile
|
||||
import subprocess
|
||||
import requests
|
||||
import base64
|
||||
from flask import Flask, render_template_string, request, send_from_directory, session
|
||||
import socket
|
||||
from flask import Flask, render_template, request, send_from_directory, session
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
import re
|
||||
import argparse
|
||||
import json
|
||||
|
||||
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 = 24
|
||||
HEADER_SIZE_1 = 56
|
||||
HEADER_SIZE_2 = 34
|
||||
HEADER_SIZE_3 = 28
|
||||
FONT_PATH = "./fonts/ubuntu/Ubuntu-Regular.ttf"
|
||||
FONT_PATH_BOLD = "./fonts/ubuntu/Ubuntu-Bold.ttf"
|
||||
FONT_PATH_OBLIQUE = "./fonts/ubuntu/Ubuntu-Italic.ttf"
|
||||
FONT_PATH_BOLDITALIC = "./fonts/ubuntu/Ubuntu-BoldItalic.ttf"
|
||||
DEFAULT_FONT_SIZE = 24
|
||||
FONT_SIZE_OPTIONS = {
|
||||
"small": 18,
|
||||
"normal": 24,
|
||||
"large": 30,
|
||||
"xlarge": 36,
|
||||
}
|
||||
HEADER_RATIO_1 = 56 / 24
|
||||
HEADER_RATIO_2 = 34 / 24
|
||||
HEADER_RATIO_3 = 28 / 24
|
||||
BANNER_FONT_SIZE = 300
|
||||
IMAGE_WIDTH = 384
|
||||
BULLET_CHAR = "• "
|
||||
MIN_LINES = 86
|
||||
BLEHD_SOCKET = os.environ.get("BLEHD_SOCKET", "/run/bleh/blehd.sock")
|
||||
|
||||
DITHERING_MODES = {
|
||||
"Sin dithering": "none",
|
||||
"Floyd-Steinberg": "floyd",
|
||||
"Bayer 2x2": "bayer2x2",
|
||||
"Bayer 4x4": "bayer4x4",
|
||||
"Bayer 8x8": "bayer8x8",
|
||||
"Bayer 16x16": "bayer16x16",
|
||||
"Atkinson": "atkinson",
|
||||
"Jarvis-Judice-Ninke": "jjn"
|
||||
}
|
||||
with open('static/translations.json', 'r', encoding='utf-8') as f:
|
||||
TRANSLATIONS = json.load(f)
|
||||
|
||||
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%; }
|
||||
}
|
||||
@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;
|
||||
}
|
||||
}
|
||||
</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 for="bannermode">
|
||||
<input type="checkbox" name="bannermode" value="1" {% if current_bannermode %}checked{% endif %}>
|
||||
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></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 _dither_none(gray_img, levels):
|
||||
if levels == 2:
|
||||
return gray_img.point(lambda p: 0 if p < 128 else 255, mode="L")
|
||||
step = 255 / (levels - 1)
|
||||
return gray_img.point(lambda p: int(round(p / step) * step), mode="L")
|
||||
|
||||
|
||||
def _dither_ordered(gray_img, matrix, levels, strength=1.0):
|
||||
src = gray_img.load()
|
||||
w, h = gray_img.size
|
||||
out = Image.new("L", (w, h), 255)
|
||||
dst = out.load()
|
||||
n = len(matrix)
|
||||
step = 255 / (levels - 1)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
t = (matrix[y % n][x % n] / (n * n) - 0.5) * step * strength
|
||||
v = max(0, min(255, int(src[x, y] + t)))
|
||||
q = int(round(v / step) * step)
|
||||
dst[x, y] = q
|
||||
return out
|
||||
|
||||
|
||||
def _dither_error_diffusion(gray_img, levels, matrix, divisor):
|
||||
w, h = gray_img.size
|
||||
buf = [[float(gray_img.getpixel((x, y))) for x in range(w)] for y in range(h)]
|
||||
step = 255 / (levels - 1)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
old = buf[y][x]
|
||||
new = round(old / step) * step
|
||||
new = max(0, min(255, new))
|
||||
err = old - new
|
||||
buf[y][x] = new
|
||||
for dx, dy, weight in matrix:
|
||||
nx = x + dx
|
||||
ny = y + dy
|
||||
if 0 <= nx < w and 0 <= ny < h:
|
||||
buf[ny][nx] += err * (weight / divisor)
|
||||
out = Image.new("L", (w, h), 255)
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
out.putpixel((x, y), int(max(0, min(255, round(buf[y][x])))))
|
||||
return out
|
||||
|
||||
|
||||
def _build_bayer(n):
|
||||
m = [[0, 2], [3, 1]]
|
||||
size = 2
|
||||
while size < n:
|
||||
prev = m
|
||||
size *= 2
|
||||
m = [[0] * size for _ in range(size)]
|
||||
for y in range(size // 2):
|
||||
for x in range(size // 2):
|
||||
v = prev[y][x] * 4
|
||||
m[y][x] = v + 0
|
||||
m[y][x + size // 2] = v + 2
|
||||
m[y + size // 2][x] = v + 3
|
||||
m[y + size // 2][x + size // 2] = v + 1
|
||||
return m
|
||||
|
||||
|
||||
def apply_dither(gray_img, dither_type, levels):
|
||||
if dither_type == "none":
|
||||
return _dither_none(gray_img, levels)
|
||||
if dither_type == "floyd":
|
||||
# x+1,y ; x-1,y+1 ; x,y+1 ; x+1,y+1
|
||||
fs = [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)]
|
||||
return _dither_error_diffusion(gray_img, levels, fs, 16)
|
||||
if dither_type == "atkinson":
|
||||
at = [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)]
|
||||
return _dither_error_diffusion(gray_img, levels, at, 8)
|
||||
if dither_type == "jjn":
|
||||
# Jarvis-Judice-Ninke kernel centered at current pixel
|
||||
jjn = [
|
||||
(1, 0, 7), (2, 0, 5),
|
||||
(-2, 1, 3), (-1, 1, 5), (0, 1, 7), (1, 1, 5), (2, 1, 3),
|
||||
(-2, 2, 1), (-1, 2, 3), (0, 2, 5), (1, 2, 3), (2, 2, 1),
|
||||
]
|
||||
return _dither_error_diffusion(gray_img, levels, jjn, 48)
|
||||
if dither_type == "bayer2x2":
|
||||
return _dither_ordered(gray_img, _build_bayer(2), levels, strength=1.0 if levels == 2 else 0.2)
|
||||
if dither_type == "bayer4x4":
|
||||
return _dither_ordered(gray_img, _build_bayer(4), levels, strength=1.0 if levels == 2 else 0.2)
|
||||
if dither_type == "bayer8x8":
|
||||
return _dither_ordered(gray_img, _build_bayer(8), levels, strength=1.0 if levels == 2 else 0.2)
|
||||
if dither_type == "bayer16x16":
|
||||
return _dither_ordered(gray_img, _build_bayer(16), levels, strength=1.0 if levels == 2 else 0.2)
|
||||
return _dither_none(gray_img, levels)
|
||||
|
||||
|
||||
def preprocess_for_mode(img, dithering, mode):
|
||||
img = remove_alpha(img)
|
||||
if img.width <= 0 or img.height <= 0:
|
||||
raise ValueError("Invalid image dimensions")
|
||||
|
||||
ratio = img.width / img.height
|
||||
height = int(IMAGE_WIDTH / ratio)
|
||||
if height <= 0:
|
||||
raise ValueError("Computed invalid image height")
|
||||
|
||||
img = img.resize((IMAGE_WIDTH, height), Image.Resampling.LANCZOS).convert("L")
|
||||
levels = 2 if mode == "1bpp" else 16
|
||||
out = apply_dither(img, dithering, levels)
|
||||
return out
|
||||
|
||||
|
||||
def pack_pixels(img, mode):
|
||||
width, height = img.size
|
||||
if width != IMAGE_WIDTH:
|
||||
img = img.resize((IMAGE_WIDTH, height), Image.Resampling.LANCZOS)
|
||||
width = IMAGE_WIDTH
|
||||
|
||||
if mode == "1bpp":
|
||||
packed = bytearray((width * height) // 8)
|
||||
px = img.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if px[x, y] < 128:
|
||||
idx = (y * width + x) // 8
|
||||
packed[idx] |= 1 << (x % 8)
|
||||
return bytes(packed), height
|
||||
|
||||
# 4bpp
|
||||
packed = bytearray((width * height) // 2)
|
||||
px = img.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
gray = px[x, y]
|
||||
level = (255 - gray) >> 4
|
||||
idx = (y * width + x) >> 1
|
||||
shift = ((x & 1) ^ 1) << 2
|
||||
packed[idx] |= (level & 0x0F) << shift
|
||||
return bytes(packed), height
|
||||
|
||||
|
||||
def image_from_url(url, dithering, mode):
|
||||
resp = requests.get(url, stream=True, timeout=20)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
return preprocess_for_mode(img, dithering, mode)
|
||||
|
||||
|
||||
def image_from_bytes(image_bytes, dithering, mode):
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
return preprocess_for_mode(img, dithering, mode)
|
||||
|
||||
|
||||
def pad_image_to_min_lines(img):
|
||||
if img.height >= MIN_LINES:
|
||||
return img
|
||||
out = Image.new("L", (img.width, MIN_LINES), 255)
|
||||
out.paste(img, (0, 0))
|
||||
return out
|
||||
|
||||
|
||||
def blehd_call(method, params=None, socket_path=BLEHD_SOCKET, timeout=90):
|
||||
req = {"id": "1", "method": method, "params": params or {}}
|
||||
data = (json.dumps(req, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(timeout)
|
||||
s.connect(socket_path)
|
||||
s.sendall(data)
|
||||
f = s.makefile("rb")
|
||||
line = f.readline()
|
||||
|
||||
if not line:
|
||||
raise RuntimeError("No response from blehd")
|
||||
resp = json.loads(line.decode("utf-8"))
|
||||
if not resp.get("ok"):
|
||||
err = resp.get("error") or {}
|
||||
raise RuntimeError(err.get("message", "Unknown blehd error"))
|
||||
return resp.get("result")
|
||||
|
||||
def remove_uploaded_img():
|
||||
path = session.pop('uploaded_img_path', None)
|
||||
@@ -490,33 +221,25 @@ def remove_uploaded_img():
|
||||
except Exception:
|
||||
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):
|
||||
resp = requests.get(url, stream=True)
|
||||
resp.raise_for_status()
|
||||
bleh = subprocess.Popen(
|
||||
["./bleh", "-o", "-", "-mode", f"{mode}", "-d", f"{dithering}", "-"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out, err = bleh.communicate(resp.content)
|
||||
if bleh.returncode != 0:
|
||||
raise RuntimeError(f"Driver failed: {err.decode()}")
|
||||
img = Image.open(io.BytesIO(out)).convert("L")
|
||||
# Optionally check width, pad/resize if needed
|
||||
if img.width != IMAGE_WIDTH:
|
||||
img = img.resize((IMAGE_WIDTH, img.height), Image.LANCZOS)
|
||||
return img
|
||||
return image_from_url(url, dithering, mode)
|
||||
|
||||
|
||||
def bleh_image_from_bytes(image_bytes, dithering, mode):
|
||||
bleh = subprocess.Popen(
|
||||
["./bleh", "-o", "-", "-mode", f"{mode}", "-d", f"{dithering}", "-"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
out, err = bleh.communicate(image_bytes)
|
||||
if bleh.returncode != 0:
|
||||
raise RuntimeError(f"Driver failed: {err.decode()}")
|
||||
img = Image.open(io.BytesIO(out)).convert("L")
|
||||
if img.width != IMAGE_WIDTH:
|
||||
img = img.resize((IMAGE_WIDTH, img.height), Image.LANCZOS)
|
||||
return img
|
||||
return image_from_bytes(image_bytes, dithering, mode)
|
||||
|
||||
def rotate_image_bytes(image_bytes, rotation):
|
||||
if rotation == 0:
|
||||
@@ -626,18 +349,24 @@ def wrap_segments(segments, font, font_bold, font_italic, font_bolditalic, max_w
|
||||
if line:
|
||||
yield line
|
||||
|
||||
def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False):
|
||||
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)
|
||||
def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False, font_size=None):
|
||||
if font_size is None:
|
||||
font_size = DEFAULT_FONT_SIZE
|
||||
header_size_1 = round(font_size * HEADER_RATIO_1)
|
||||
header_size_2 = round(font_size * HEADER_RATIO_2)
|
||||
header_size_3 = round(font_size * HEADER_RATIO_3)
|
||||
|
||||
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)
|
||||
font_banner = ImageFont.truetype(FONT_PATH_BOLD, BANNER_FONT_SIZE)
|
||||
try:
|
||||
font_bolditalic = ImageFont.truetype(FONT_PATH_BOLDITALIC, FONT_SIZE)
|
||||
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)
|
||||
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)
|
||||
|
||||
if bannermode:
|
||||
# Remove line breaks for single-line banner
|
||||
@@ -675,17 +404,17 @@ def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False):
|
||||
image = bleh_image_from_url(tag[1], dithering, printmode)
|
||||
lines_out.append(('image', image))
|
||||
except Exception as e:
|
||||
lines_out.append(('text', [('text', f"[Imagen inválida: {e}]")], font, FONT_SIZE))
|
||||
lines_out.append(('text', [('text', f"[Imagen inválida: {e}]")], font, font_size))
|
||||
elif tag[0] == 'userimage':
|
||||
if uploaded_img_bytes:
|
||||
try:
|
||||
image = bleh_image_from_bytes(uploaded_img_bytes, dithering, printmode)
|
||||
lines_out.append(('image', image))
|
||||
except Exception as e:
|
||||
lines_out.append(('text', [('text', f"[Error al procesar imagen]")], font, FONT_SIZE))
|
||||
lines_out.append(('text', [('text', f"[Error al procesar imagen]")], font, font_size))
|
||||
print(f"Image processing error: {e}")
|
||||
else:
|
||||
lines_out.append(('text', [('text', "[No se subió una imagen]")], font, FONT_SIZE))
|
||||
lines_out.append(('text', [('text', "[No se subió una imagen]")], font, font_size))
|
||||
elif tag[0] == 'hr':
|
||||
lines_out.append(('hr',))
|
||||
elif tag[0] == 'header':
|
||||
@@ -693,13 +422,13 @@ def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False):
|
||||
segments = tag[2]
|
||||
if header_level == 1:
|
||||
font_h = font_h1
|
||||
size_h = HEADER_SIZE_1
|
||||
size_h = header_size_1
|
||||
elif header_level == 2:
|
||||
font_h = font_h2
|
||||
size_h = HEADER_SIZE_2
|
||||
size_h = header_size_2
|
||||
else:
|
||||
font_h = font_h3
|
||||
size_h = HEADER_SIZE_3
|
||||
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':
|
||||
@@ -709,9 +438,9 @@ def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False):
|
||||
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))
|
||||
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))
|
||||
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}. "
|
||||
@@ -720,13 +449,13 @@ def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False):
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
lines_out.append(('text', wrapped, font, font_size))
|
||||
|
||||
# Compute total height, including images
|
||||
height = 10 # Top margin
|
||||
@@ -736,7 +465,7 @@ def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False):
|
||||
elif item[0] == 'hr':
|
||||
height += 10
|
||||
elif item[0] == 'blank':
|
||||
height += FONT_SIZE
|
||||
height += font_size
|
||||
elif item[0] == 'image':
|
||||
img = item[1]
|
||||
height += img.height + 10 # add margin below image
|
||||
@@ -746,7 +475,7 @@ def render(md, dithering, printmode, uploaded_img_bytes=None, bannermode=False):
|
||||
y = 0
|
||||
for item in lines_out:
|
||||
if item[0] == 'blank':
|
||||
y += FONT_SIZE
|
||||
y += font_size
|
||||
elif item[0] == 'hr':
|
||||
draw.line((0, y + 5, IMAGE_WIDTH, y + 5), fill=0, width=2)
|
||||
y += 10
|
||||
@@ -807,6 +536,20 @@ def index():
|
||||
md = ""
|
||||
printed = False
|
||||
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":
|
||||
userimg = request.files.get("userimg")
|
||||
uploaded_img_bytes = None
|
||||
@@ -841,11 +584,14 @@ def index():
|
||||
dithering = request.form.get("dithering", "floyd")
|
||||
printmode = request.form.get("printmode", "1bpp")
|
||||
bannermode = bool(request.form.get("bannermode"))
|
||||
image = render(md, dithering, printmode, uploaded_img_bytes, bannermode=bannermode)
|
||||
fontsize_key = request.form.get("fontsize", "normal")
|
||||
font_size = FONT_SIZE_OPTIONS.get(fontsize_key, DEFAULT_FONT_SIZE)
|
||||
image = render(md, dithering, printmode, uploaded_img_bytes, bannermode=bannermode, font_size=font_size)
|
||||
session['dithering'] = dithering
|
||||
session['printmode'] = printmode
|
||||
session['rotation'] = rotation
|
||||
session['bannermode'] = bannermode
|
||||
session['fontsize'] = fontsize_key
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
@@ -856,30 +602,40 @@ def index():
|
||||
if intensity < 0 or intensity > 100:
|
||||
raise ValueError("Intensity must be between 0 and 100")
|
||||
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
|
||||
session['intensity'] = intensity
|
||||
# If print button pressed, send to driver
|
||||
# If print button pressed, send to blehd directly via Unix socket
|
||||
if "print" in request.form:
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmpfile:
|
||||
image.save(tmpfile, format="PNG")
|
||||
tmpfile.flush()
|
||||
# Run the bleh command
|
||||
result = subprocess.run([
|
||||
"./bleh",
|
||||
"-mode", f"{printmode}",
|
||||
"-intensity", f"{intensity}",
|
||||
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
|
||||
printable = pad_image_to_min_lines(image)
|
||||
pixels, height = pack_pixels(printable, printmode)
|
||||
params = {
|
||||
"mode": printmode,
|
||||
"intensity": intensity,
|
||||
"height": height,
|
||||
"pixels_b64": base64.b64encode(pixels).decode("ascii"),
|
||||
}
|
||||
blehd_call("print.start", params)
|
||||
printed = True
|
||||
except Exception as e:
|
||||
error = f"Failed to print: {e}"
|
||||
return render_template_string(HTML_FORM, dithering_modes=DITHERING_MODES, img=img_data, default_md=md,
|
||||
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))
|
||||
error = f"Failed to print via blehd socket: {e}"
|
||||
return render_template(
|
||||
"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),
|
||||
current_fontsize=session.get('fontsize', 'normal'),
|
||||
t=t,
|
||||
lang=lang
|
||||
)
|
||||
|
||||
@app.route('/manifest.json')
|
||||
def manifest():
|
||||
|
||||
96
fonts/ubuntu/UFL.txt
Normal file
96
fonts/ubuntu/UFL.txt
Normal file
@@ -0,0 +1,96 @@
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
BIN
fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu/Ubuntu-BoldItalic.ttf
Normal file
BIN
fonts/ubuntu/Ubuntu-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu/Ubuntu-Italic.ttf
Normal file
BIN
fonts/ubuntu/Ubuntu-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
BIN
fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
Binary file not shown.
84
static/translations.json
Normal file
84
static/translations.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"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",
|
||||
"font_size": "Font size:",
|
||||
"font_small": "Small",
|
||||
"font_normal": "Normal",
|
||||
"font_large": "Large",
|
||||
"font_xlarge": "Extra Large"
|
||||
},
|
||||
"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",
|
||||
"font_size": "Tamaño de fuente:",
|
||||
"font_small": "Pequeño",
|
||||
"font_normal": "Normal",
|
||||
"font_large": "Grande",
|
||||
"font_xlarge": "Extra grande"
|
||||
}
|
||||
}
|
||||
654
templates/index.html
Normal file
654
templates/index.html
Normal file
@@ -0,0 +1,654 @@
|
||||
<!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 %}>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-fontsize">
|
||||
<label for="fontsize">{{ t['font_size'] }}</label>
|
||||
<select name="fontsize">
|
||||
<option value="small" {% if "small"==current_fontsize %}selected="selected" {% endif %}>{{ t['font_small'] }}</option>
|
||||
<option value="normal" {% if "normal"==current_fontsize %}selected="selected" {% endif %}>{{ t['font_normal'] }}</option>
|
||||
<option value="large" {% if "large"==current_fontsize %}selected="selected" {% endif %}>{{ t['font_large'] }}</option>
|
||||
<option value="xlarge" {% if "xlarge"==current_fontsize %}selected="selected" {% endif %}>{{ t['font_xlarge'] }}</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>
|
||||
Reference in New Issue
Block a user