first commit

This commit is contained in:
hubaceks
2026-05-13 22:41:42 +02:00
commit cef8539e00
22 changed files with 937 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# Generátor katalogu
Generuje PDF katalog z YAML nebo CSV dat přes HTML/CSS šablonu.
## Instalace
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\\Scripts\\activate
pip install -r requirements.txt
```
> Poznámka: WeasyPrint může na některých systémech vyžadovat systémové knihovny. Pokud by instalace zlobila, lze později přepnout renderer na Playwright/Chromium.
## Použití
Z YAML:
```bash
python generate_catalog.py --input catalog.yaml
```
Z CSV:
```bash
python generate_catalog.py --input catalog.csv --title "BistroUšky" --subtitle "CAKE AND PATISSERIE"
```
Výstup je standardně `output/katalog.pdf`.
## Struktura dat
- `brand`: název, podtitulek, logo, pozadí, ikony
- `settings`: rozložení, počet sloupců, výstupní soubor
- `categories`: seznam kategorií
- `products`: produkty v kategoriích
Každý produkt má minimálně:
- `name`
- `price`
- `image`
Volitelně:
- `note`
- `tags`
- `show_allergen_icon`

81
README_FRONTEND.md Normal file
View File

@@ -0,0 +1,81 @@
# Webový frontend pro generátor katalogu
## Instalace na Debianu
```bash
apt update
apt install -y \
python3 python3-venv python3-pip \
libpango-1.0-0 libpangoft2-1.0-0 libcairo2 \
libgdk-pixbuf-2.0-0 libffi8 shared-mime-info fonts-dejavu-core
```
## Spuštění
```bash
cd /opt/catalog_generator
source venv/bin/activate
pip install -r requirements.txt
python app.py
```
Pak otevři:
```text
http://192.168.50.112:8080
```
## Formát CSV
Povinné sloupce:
```csv
category,name,price,image
Klasické zákusky,Věneček,"85,-",venecek.jpg
Dortíky,Red Velvet,"145,-",red-velvet.jpg
```
Volitelné sloupce:
```csv
note,tags,show_allergen_icon
```
Ve sloupci `image` může být jen název souboru (`venecek.jpg`) nebo cesta `images/venecek.jpg`.
## Nahrávání fotek
Frontend podporuje:
- více samostatných obrázků najednou,
- jeden ZIP archiv s obrázky.
Podporované formáty: JPG, JPEG, PNG, WebP.
## Systemd služba
Soubor `/etc/systemd/system/catalog-generator.service`:
```ini
[Unit]
Description=Catalog Generator Web App
After=network.target
[Service]
WorkingDirectory=/opt/catalog_generator
ExecStart=/opt/catalog_generator/venv/bin/python /opt/catalog_generator/app.py
Restart=always
User=root
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
Aktivace:
```bash
systemctl daemon-reload
systemctl enable --now catalog-generator
systemctl status catalog-generator
```

Binary file not shown.

96
app.py Normal file
View File

@@ -0,0 +1,96 @@
from pathlib import Path
import shutil
import subprocess
import uuid
import zipfile
from flask import Flask, render_template, request, send_file, abort
BASE_DIR = Path(__file__).resolve().parent
UPLOAD_DIR = BASE_DIR / "uploads"
OUTPUT_DIR = BASE_DIR / "web_output"
UPLOAD_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
app = Flask(__name__)
@app.get("/")
def index():
return render_template("upload.html")
@app.post("/generate")
def generate():
job_id = uuid.uuid4().hex
job_dir = UPLOAD_DIR / job_id
images_dir = job_dir / "images"
job_dir.mkdir(parents=True)
images_dir.mkdir(parents=True)
csv_file = request.files.get("csv_file")
if not csv_file or not csv_file.filename:
return "Chybí CSV soubor", 400
csv_path = job_dir / "catalog.csv"
csv_file.save(csv_path)
for image in request.files.getlist("images"):
if image and image.filename:
image.save(images_dir / Path(image.filename).name)
zip_file = request.files.get("images_zip")
if zip_file and zip_file.filename:
zip_path = job_dir / "images.zip"
zip_file.save(zip_path)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(images_dir)
output_pdf = OUTPUT_DIR / f"catalog_{job_id}.pdf"
cmd = [
"python",
str(BASE_DIR / "generate_catalog.py"),
"-i",
str(csv_path),
"--output",
str(output_pdf),
"--images-dir",
str(images_dir),
]
result = subprocess.run(
cmd,
cwd=BASE_DIR,
text=True,
capture_output=True,
)
if result.returncode != 0:
return (
"<h2>Generování selhalo</h2>"
"<h3>STDOUT</h3><pre>" + result.stdout + "</pre>"
"<h3>STDERR</h3><pre>" + result.stderr + "</pre>"
), 500
if not output_pdf.exists():
return "PDF se nevytvořilo.", 500
return f"""
<h1>Katalog je hotový</h1>
<p><a href="/download/{job_id}">Stáhnout PDF</a></p>
<p><a href="/">Vygenerovat další</a></p>
"""
@app.get("/download/<job_id>")
def download(job_id):
pdf_path = OUTPUT_DIR / f"catalog_{job_id}.pdf"
if not pdf_path.exists():
abort(404)
return send_file(pdf_path, as_attachment=True, download_name="catalog.pdf")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)

BIN
assets/allergen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
assets/background.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

4
catalog.csv Normal file
View File

@@ -0,0 +1,4 @@
category,name,price,image,show_allergen_icon,note
Klasické zákusky,Likérová špička,"103,-",images/likerova_spicka.jpg,true,
Klasické zákusky,Laskonka - mini,"45,-",images/laskonka_mini.jpg,true,
Dortíky,Red Velvet,"145,-",images/red_velvet.jpg,true,
1 category name price image show_allergen_icon note
2 Klasické zákusky Likérová špička 103,- images/likerova_spicka.jpg true
3 Klasické zákusky Laskonka - mini 45,- images/laskonka_mini.jpg true
4 Dortíky Red Velvet 145,- images/red_velvet.jpg true

35
catalog.yaml Normal file
View File

@@ -0,0 +1,35 @@
brand:
title: "BistroUšky"
subtitle: "CAKE AND PATISSERIE"
cover_logo: "static/logo.png" # volitelné
background: "static/background.jpg" # volitelné, použije se na každé stránce
allergen_icon: "static/allergen_icon.png" # volitelné
settings:
page_size: "landscape-a4" # landscape-a4 / portrait-a4
products_per_page: 8 # při 4 sloupcích a 2 řádcích
columns: 4
show_prices: true
currency_suffix: ""
output_file: "output/katalog.pdf"
categories:
- name: "Klasické zákusky"
products:
- name: "Likérová špička"
price: "103,-"
image: "images/likerova_spicka.jpg"
tags: []
note: ""
show_allergen_icon: true
- name: "Laskonka - mini"
price: "45,-"
image: "images/laskonka_mini.jpg"
show_allergen_icon: true
- name: "Dortíky"
products:
- name: "Red Velvet"
price: "145,-"
image: "images/red_velvet.jpg"
show_allergen_icon: true

184
generate_catalog.py Normal file
View File

@@ -0,0 +1,184 @@
from pathlib import Path
import argparse
import csv
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
def load_csv(csv_path):
categories = {}
with open(csv_path, newline="", encoding="utf-8-sig") as f:
sample = f.read(4096)
f.seek(0)
dialect = csv.Sniffer().sniff(sample, delimiters=",;")
reader = csv.DictReader(f, dialect=dialect)
for row in reader:
normalized = {
(key or "").strip().lower().replace(" ", "_"): (value or "").strip()
for key, value in row.items()
}
category = (
normalized.get("category")
or normalized.get("catagory")
or normalized.get("kategorie")
or "Ostatní"
)
name = (
normalized.get("name")
or normalized.get("nazev")
or normalized.get("název")
or ""
)
price = (
normalized.get("price")
or normalized.get("cena")
or ""
)
image = (
normalized.get("image")
or normalized.get("foto")
or normalized.get("fotka")
or normalized.get("obrazek")
or normalized.get("obrázek")
or ""
)
show_allergen_icon = (
normalized.get("show_alergen_icon")
or normalized.get("show_allergen_icon")
or normalized.get("alergen_icon")
or normalized.get("allergen_icon")
or ""
).lower() in ("1", "true", "yes", "ano", "y")
note = (
normalized.get("note")
or normalized.get("description")
or normalized.get("poznamka")
or normalized.get("poznámka")
or ""
)
if category not in categories:
categories[category] = []
categories[category].append({
"name": name,
"price": price,
"image": image,
"show_allergen_icon": show_allergen_icon,
"description": note,
})
return categories
def build_catalog_data(title, subtitle, categories):
return {
"title": title,
"subtitle": subtitle,
"categories": [
{
"name": name,
"products": products,
}
for name, products in categories.items()
],
}
def render_html(data, template_dir, images_dir):
env = Environment(
loader=FileSystemLoader(template_dir)
)
template = env.get_template("catalog.html.j2")
return template.render(
catalog=data,
brand=data,
settings={
"columns": 4,
"page_size": "1024px 768px",
"margin": "0",
},
images_dir=images_dir.as_posix(),
assets_dir=(Path(__file__).resolve().parent / "assets").as_posix(),
logo_exists=(Path(__file__).resolve().parent / "assets" / "logo.png").exists(),
)
def generate_pdf(html_content, output_path):
HTML(string=html_content).write_pdf(output_path)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"-i",
"--input",
required=True,
help="CSV input file"
)
parser.add_argument(
"--output",
default="output/catalog.pdf",
help="Output PDF path"
)
parser.add_argument(
"--images-dir",
default="images",
help="Directory with product images"
)
parser.add_argument(
"--title",
default="Bistro Ušky",
)
parser.add_argument(
"--subtitle",
default="Cake & Patisserie",
)
args = parser.parse_args()
base_dir = Path(__file__).resolve().parent
csv_path = Path(args.input)
output_path = Path(args.output)
images_dir = Path(args.images_dir)
output_path.parent.mkdir(parents=True, exist_ok=True)
categories = load_csv(csv_path)
catalog_data = build_catalog_data(
args.title,
args.subtitle,
categories,
)
html = render_html(
catalog_data,
base_dir / "templates",
images_dir,
)
generate_pdf(html, output_path)
print(f"PDF generated: {output_path}")
if __name__ == "__main__":
main()

236
output/katalog.html Normal file
View File

@@ -0,0 +1,236 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<title>BistroUšky</title>
<style>
@page {
size: A4 landscape;
margin: 0;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "DejaVu Sans", Arial, sans-serif;
color: #383633;
background: #f4efe5;
}
.page {
width: 297mm;
height: 210mm;
position: relative;
page-break-after: always;
overflow: hidden;
padding: 20mm 19mm 18mm 19mm;
background-color: #f4efe5;
background-image: linear-gradient(rgba(246,241,231,.82), rgba(246,241,231,.82)), url("static/background.jpg");
background-size: cover;
background-position: center;
}
.page::before,
.page::after {
content: "";
position: absolute;
left: 19mm;
right: 19mm;
border-top: 1px solid rgba(56,54,51,.65);
}
.page::before { top: 14mm; }
.page::after { bottom: 14mm; }
.cover {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.cover-title {
font-size: 34pt;
font-weight: 300;
letter-spacing: .02em;
margin: 0;
}
.cover-subtitle {
font-size: 16pt;
letter-spacing: .35em;
margin-top: 2mm;
}
.cover-logo {
margin-top: 26mm;
max-width: 48mm;
max-height: 48mm;
opacity: .9;
}
h1.category-title {
font-size: 18pt;
font-weight: 400;
text-decoration: underline;
margin: 6mm 0 14mm 0;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
column-gap: 9mm;
row-gap: 10mm;
}
.product { break-inside: avoid; }
.photo-wrap {
width: 100%;
height: 48mm;
border-radius: 6mm;
overflow: hidden;
background: rgba(255,255,255,.45);
}
.photo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 10pt;
color: #777;
border: 1px dashed #aaa;
}
.name-row {
margin-top: 2.5mm;
border-top: 1px solid rgba(56,54,51,.7);
border-bottom: 1px solid rgba(56,54,51,.45);
min-height: 9mm;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2mm;
}
.product-name {
font-size: 8.5pt;
text-transform: uppercase;
letter-spacing: .28em;
line-height: 1.35;
padding: 1.5mm 0;
}
.allergen-icon {
width: 6.8mm;
height: 6.8mm;
object-fit: contain;
flex: 0 0 auto;
opacity: .85;
}
.price {
text-align: center;
font-size: 11pt;
letter-spacing: .18em;
margin-top: 2mm;
min-height: 6mm;
}
.note {
text-align: center;
font-size: 7pt;
margin-top: 1mm;
color: #666;
}
</style>
</head>
<body>
<section class="page cover">
<div>
<h1 class="cover-title">BistroUšky</h1>
<div class="cover-subtitle">CAKE AND PATISSERIE</div>
<img class="cover-logo" src="static/logo.png" alt="logo">
</div>
</section>
<section class="page">
<h1 class="category-title">
Klasické zákusky
</h1>
<div class="grid">
<article class="product">
<div class="photo-wrap">
<img class="photo" src="images/likerova_spicka.jpg" alt="Likérová špička">
</div>
<div class="name-row">
<div class="product-name">Likérová špička</div>
<img class="allergen-icon" src="static/allergen_icon.png" alt="ikona">
</div>
<div class="price">103,-</div>
</article>
<article class="product">
<div class="photo-wrap">
<img class="photo" src="images/laskonka_mini.jpg" alt="Laskonka - mini">
</div>
<div class="name-row">
<div class="product-name">Laskonka - mini</div>
<img class="allergen-icon" src="static/allergen_icon.png" alt="ikona">
</div>
<div class="price">45,-</div>
</article>
</div>
</section>
<section class="page">
<h1 class="category-title">
Dortíky
</h1>
<div class="grid">
<article class="product">
<div class="photo-wrap">
<img class="photo" src="images/red_velvet.jpg" alt="Red Velvet">
</div>
<div class="name-row">
<div class="product-name">Red Velvet</div>
<img class="allergen-icon" src="static/allergen_icon.png" alt="ikona">
</div>
<div class="price">145,-</div>
</article>
</div>
</section>
</body>
</html>

BIN
output/katalog.pdf Normal file

Binary file not shown.

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
jinja2>=3.1.0
pyyaml>=6.0.0
weasyprint>=62.0
pillow>=10.0.0
flask>=3.0.0
werkzeug>=3.0.0

BIN
static/background.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

187
templates/catalog.html.j2 Normal file
View File

@@ -0,0 +1,187 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<style>
@page {
size: 1024px 768px;
margin: 0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "DejaVu Sans", Arial, sans-serif;
color: #333;
}
.page {
width: 1024px;
height: 768px;
position: relative;
padding: 68px 68px 64px 68px;
page-break-after: always;
background-image:
linear-gradient(rgba(255,255,255,.58), rgba(255,255,255,.58)),
url("file://{{ assets_dir }}/background.png");
background-size: cover;
background-position: center;
}
.page::before,
.page::after {
content: "";
position: absolute;
left: 66px;
right: 66px;
height: 1px;
background: rgba(45,45,45,.65);
}
.page::before { top: 67px; }
.page::after { bottom: 68px; }
.cover {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
}
.cover-title {
font-size: 42px;
font-weight: 300;
margin-top: 140px;
letter-spacing: -1px;
}
.cover-subtitle {
font-size: 23px;
letter-spacing: 9px;
margin-top: 4px;
}
.cover-logo {
margin-top: 130px;
width: 190px;
opacity: .82;
}
.category-title {
font-size: 26px;
font-weight: 400;
text-decoration: underline;
margin: 28px 0 50px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
column-gap: 31px;
row-gap: 38px;
}
.product {
width: 205px;
break-inside: avoid;
page-break-inside: avoid;
}
.product-image {
width: 205px;
height: 169px;
object-fit: cover;
border-radius: 18px;
display: block;
}
.product-name-row {
margin-top: 9px;
border-top: 1px solid rgba(50,50,50,.7);
border-bottom: 1px solid rgba(50,50,50,.7);
min-height: 31px;
display: flex;
align-items: center;
justify-content: space-between;
}
.product-name {
font-size: 13px;
letter-spacing: 3px;
text-transform: uppercase;
line-height: 1.25;
padding-right: 6px;
}
.allergen {
width: 23px;
height: 23px;
object-fit: contain;
flex: 0 0 auto;
opacity: .85;
}
.price {
text-align: center;
font-size: 16px;
letter-spacing: 4px;
margin-top: 7px;
}
.placeholder {
width: 205px;
height: 169px;
border-radius: 18px;
background: rgba(230,230,230,.75);
display: flex;
align-items: center;
justify-content: center;
color: #888;
}
</style>
</head>
<body>
<section class="page cover">
<div class="cover-title">{{ catalog.title }}</div>
<div class="cover-subtitle">{{ catalog.subtitle|upper }}</div>
{% if logo_exists %}
<img class="cover-logo" src="file://{{ assets_dir }}/logo.png">
{% endif %}
</section>
{% for category in catalog.categories %}
<section class="page">
<h1 class="category-title">{{ category.name }}</h1>
<div class="grid">
{% for product in category.products %}
<div class="product">
{% if product.image %}
<img class="product-image" src="file://{{ images_dir }}/{{ product.image }}">
{% else %}
<div class="placeholder">bez fotky</div>
{% endif %}
<div class="product-name-row">
<div class="product-name">{{ product.name }}</div>
{% if product.show_allergen_icon %}
<img class="allergen" src="file://{{ assets_dir }}/allergen.png">
{% endif %}
</div>
<div class="price">{{ product.price }}</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</body>
</html>

64
templates/upload.html Normal file
View File

@@ -0,0 +1,64 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<title>Generátor katalogu</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 760px;
margin: 40px auto;
background: #f7f3ee;
color: #222;
}
.box {
background: white;
padding: 28px;
border-radius: 18px;
box-shadow: 0 8px 30px rgba(0,0,0,.08);
}
label {
display: block;
margin-top: 18px;
font-weight: bold;
}
input {
margin-top: 8px;
width: 100%;
}
button {
margin-top: 28px;
padding: 14px 22px;
border: 0;
border-radius: 999px;
background: #222;
color: white;
font-size: 16px;
cursor: pointer;
}
.hint {
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div class="box">
<h1>Generátor katalogu</h1>
<p class="hint">Nahraj CSV a fotky produktů. Názvy fotek musí odpovídat sloupci <code>image</code> v CSV.</p>
<form action="/generate" method="post" enctype="multipart/form-data">
<label>CSV soubor</label>
<input type="file" name="csv_file" accept=".csv" required>
<label>Fotky produktů</label>
<input type="file" name="images" accept="image/*" multiple>
<label>ZIP s fotkami</label>
<input type="file" name="images_zip" accept=".zip">
<button type="submit">Vygenerovat katalog PDF</button>
</form>
</div>
</body>
</html>

BIN
uploads/.DS_Store vendored Normal file

Binary file not shown.

BIN
web_jobs/.DS_Store vendored Normal file

Binary file not shown.

BIN
web_jobs/dda19e80c065/.DS_Store vendored Normal file

Binary file not shown.

BIN
web_output/.DS_Store vendored Normal file

Binary file not shown.