working prototype

This commit is contained in:
Aron Petau 2025-09-17 16:35:11 +02:00
parent 4f2723b767
commit 1a4abe978f
21 changed files with 706 additions and 145 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,9 +1,8 @@
# Dockerfile
FROM python:3.12-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /cost-assistant
@ -11,7 +10,14 @@ WORKDIR /cost-assistant
# Copy requirements first (for caching)
COPY requirements.txt .
# Install dependencies
# Install system dependencies: OpenCV, Poppler, and minimal utils
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
poppler-utils \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the project files
@ -23,5 +29,5 @@ RUN mkdir -p data/uploads
# Expose port
EXPOSE 8000
# Run FastAPI with Uvicorn
# Run FastAPI with Uvicorn (reload for dev)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

111
archive/result.html Normal file
View file

@ -0,0 +1,111 @@
<!-- templates/result.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Print Cost Result</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
body {
font-family: sans-serif;
margin: 2em;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 1em;
}
th,
td {
border: 1px solid #ccc;
padding: 0.5em;
text-align: center;
}
th {
background-color: #f2f2f2;
}
.color {
background-color: #ffdede;
}
.black {
background-color: #e0e0ff;
}
.totals {
font-weight: bold;
}
.container {
max-width: 900px;
margin: auto;
}
h1,
h2 {
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<h1>Print Cost for {{ result.filename }}</h1>
<table>
<thead>
<tr>
<th>Page</th>
<th>Width (m)</th>
<th>Height (m)</th>
<th>Area (m²)</th>
<th>Ink %</th>
<th>Type</th>
<th>Cost (€)</th>
</tr>
</thead>
<tbody>
{% for page in result.pages %}
<tr class="{{ 'color' if page.is_color else 'black' }}">
<td>{{ page.page }}</td>
<td>{{ "%.3f"|format(page.width_m) }}</td>
<td>{{ "%.3f"|format(page.height_m) }}</td>
<td>{{ "%.4f"|format(page.area_m2) }}</td>
<td>{{ "%.1f"|format(page.ink_pct) if page.ink_pct is not none else '-' }}</td>
<td>{{ 'Color' if page.is_color else 'B&W' }}</td>
<td>{{ "%.2f"|format(page.cost) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals">
<td colspan="3">Total Black Pages</td>
<td>{{ "%.4f"|format(result.total_area_black) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_black) }}</td>
</tr>
<tr class="totals">
<td colspan="3">Total Color Pages</td>
<td>{{ "%.4f"|format(result.total_area_color) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_color) }}</td>
</tr>
<tr class="totals">
<td colspan="6">Grand Total</td>
<td>{{ "%.2f"|format(result.grand_total) }}</td>
</tr>
</tfoot>
</table>
<p style="text-align:center; margin-top:2em;">
<a href="/">Upload another PDF</a>
</p>
</div>
</body>
</html>

View file

@ -3,6 +3,7 @@ import os
from PyPDF2 import PdfReader
from pdf2image import convert_from_path
import numpy as np
from PIL import Image
UPLOAD_FOLDER = "data/uploads"
ALLOWED_EXTENSIONS = {"pdf"}
@ -13,6 +14,7 @@ def allowed_file(filename: str) -> bool:
def points_to_meters(points: float) -> float:
"""Convert PDF points to meters (1 point = 1/72 inch)"""
return (points / 72.0) * 0.0254
@ -24,6 +26,21 @@ def get_rate_color() -> float:
return float(os.environ.get("RATE_PER_M2_COLOR", "5.0"))
def get_page_size(page):
"""Returns width and height in meters, rotation-aware, prefers CropBox"""
box = getattr(page, "cropbox", None) or page.mediabox
width_pts = float(box.width)
height_pts = float(box.height)
rotation = page.get("/Rotate") or 0
if rotation in [90, 270]:
width_pts, height_pts = height_pts, width_pts
width_m = points_to_meters(width_pts)
height_m = points_to_meters(height_pts)
return width_m, height_m
def analyze_pdf(path, compute_ink=True):
reader = PdfReader(path)
pages_info = []
@ -31,9 +48,8 @@ def analyze_pdf(path, compute_ink=True):
total_cost_black = total_cost_color = 0.0
for i, page in enumerate(reader.pages):
box = page.mediabox
width_m = points_to_meters(float(box.width))
height_m = points_to_meters(float(box.height))
# Get page size robustly
width_m, height_m = get_page_size(page)
area = width_m * height_m
ink_pct = None
@ -45,14 +61,22 @@ def analyze_pdf(path, compute_ink=True):
img = images[0].convert("RGB")
arr = np.array(img)
# ink pixels: any channel < 240
ink_mask = np.any(arr < 240, axis=2)
ink_pct = float(np.count_nonzero(ink_mask)) / (arr.shape[0] * arr.shape[1]) * 100.0
# Detect ink pixels (anything not near-white)
ink_mask = np.any(arr < 250, axis=2)
num_ink_pixels = np.count_nonzero(ink_mask)
total_pixels = arr.shape[0] * arr.shape[1]
ink_pct = (num_ink_pixels / total_pixels) * 100.0
if num_ink_pixels > 0:
# Convert to HSV using Pillow
hsv_img = img.convert("HSV")
hsv_arr = np.array(hsv_img)
saturation = hsv_arr[:, :, 1][ink_mask]
# Color if even a tiny fraction of ink pixels have saturation > 10
color_ratio = np.count_nonzero(saturation > 10) / len(saturation)
is_color = color_ratio > 0.001 # 0.1% threshold
# simple color detection: if RGB channels differ significantly
avg_rgb = arr.mean(axis=(0, 1))
if np.ptp(avg_rgb) > 30:
is_color = True
except Exception as e:
print(f"Page {i+1} ink/color calc failed: {e}")
ink_pct = None

BIN
data/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

16
get_room_id.py Normal file
View file

@ -0,0 +1,16 @@
from nio import AsyncClient
import asyncio, os
from dotenv import load_dotenv
load_dotenv() # this must run **before** os.environ access
async def get_room_id():
client = AsyncClient("http://localhost:8008", "@einszwovier_bot:localhost")
await client.login(os.environ["MATRIX_PASS"])
await client.join("#2D-prints:localhost")
rooms = await client.joined_rooms()
print("Joined rooms:", rooms.rooms)
await client.close()
asyncio.run(get_room_id())

96
mailer.py Normal file
View file

@ -0,0 +1,96 @@
import os
import asyncio
from io import BytesIO
from nio import AsyncClient, UploadResponse, RoomSendResponse
async def send_order(pdf_path: str, analysis: dict, room_id: str, comment: str = ""):
matrix_user = os.environ.get("MATRIX_USER")
matrix_pass = os.environ.get("MATRIX_PASS")
homeserver = os.environ.get("MATRIX_HOMESERVER", "http://localhost:8008")
if not matrix_user or not matrix_pass:
raise RuntimeError("Missing MATRIX_USER or MATRIX_PASS in environment")
client = AsyncClient(homeserver, matrix_user)
login_resp = await client.login(matrix_pass)
if getattr(login_resp, "access_token", None) is None:
await client.close()
raise RuntimeError(f"Failed to login to Matrix: {login_resp}")
# Build summary text
summary_lines = [
f"File: {analysis['filename']}",
f"Grand total: {analysis['grand_total']}",
f" - Black/White: {analysis['total_area_black']:.2f} m² = {analysis['total_cost_black']}",
f" - Color: {analysis['total_area_color']:.2f} m² = {analysis['total_cost_color']}",
"",
"Page details:",
]
for page in analysis["pages"]:
summary_lines.append(
f"Page {page['page']}: {page['width_m']*1000:.0f}×{page['height_m']*1000:.0f} mm, "
f"{'Color' if page['is_color'] else 'B/W'}, Cost {page['cost']}"
)
if comment.strip():
summary_lines.append("\nUser comment:\n" + comment.strip())
summary_text = "\n".join(summary_lines)
# Send text summary
text_resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content={
"msgtype": "m.text",
"body": f"New print order submitted:\n\n{summary_text}",
},
)
if not (isinstance(text_resp, RoomSendResponse) and text_resp.event_id):
await client.logout()
await client.close()
raise RuntimeError(f"Failed to send order summary: {text_resp}")
# Upload PDF
with open(pdf_path, "rb") as f:
pdf_bytes = f.read()
upload_resp, upload_err = await client.upload(
data_provider=BytesIO(pdf_bytes),
content_type="application/pdf",
filename=os.path.basename(pdf_path),
filesize=len(pdf_bytes),
)
if upload_err:
await client.logout()
await client.close()
raise RuntimeError(f"Failed to upload PDF: {upload_err}")
if not (isinstance(upload_resp, UploadResponse) and upload_resp.content_uri):
await client.logout()
await client.close()
raise RuntimeError(f"Failed to upload PDF: {upload_resp}")
# Send PDF as separate file message
file_resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content={
"msgtype": "m.file",
"body": os.path.basename(pdf_path),
"url": upload_resp.content_uri,
},
)
if not (isinstance(file_resp, RoomSendResponse) and file_resp.event_id):
await client.logout()
await client.close()
raise RuntimeError(f"Failed to send PDF message: {file_resp}")
await client.logout()
await client.close()
print(f"✅ Text summary and PDF sent to room {room_id}")
def send_order_sync(pdf_path: str, analysis: dict, room_id: str, comment: str = ""):
asyncio.run(send_order(pdf_path, analysis, room_id, comment))

60
main.py
View file

@ -1,12 +1,16 @@
# main.py
import os
import shutil
from fastapi import FastAPI, UploadFile, File, Request
from fastapi.responses import HTMLResponse
from fastapi import FastAPI, UploadFile, File, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from cost_calculator import allowed_file, analyze_pdf, get_rate_black, get_rate_color, UPLOAD_FOLDER
from mailer import send_order_sync
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@ -32,8 +36,8 @@ async def cost_dashboard(request: Request):
{
"request": request,
"rate_black": get_rate_black(),
"rate_color": get_rate_color()
}
"rate_color": get_rate_color(),
},
)
@ -57,6 +61,50 @@ async def upload_file(request: Request, file: UploadFile = File(...)):
"request": request,
"result": result,
"rate_black": get_rate_black(),
"rate_color": get_rate_color()
"rate_color": get_rate_color(),
},
)
@app.post("/send-order")
def send_order_endpoint(
request: Request,
filename: str = Form(...),
comment: str = Form(""),
):
"""
Handles the 'Send Order' button. Sends PDF + analysis + user comment via Matrix.
"""
path = os.path.join(UPLOAD_FOLDER, filename)
if not os.path.exists(path):
return templates.TemplateResponse(
"cost-calculator.html",
{"request": request, "error": "File not found. Please upload again."},
)
analysis = analyze_pdf(path)
try:
send_order_sync(path, analysis, room_id="!YokZIMTVFEmSMRmmsb:localhost", comment=comment)
# Render same result.html but include a success banner
return templates.TemplateResponse(
"result.html",
{
"request": request,
"result": analysis,
"rate_black": get_rate_black(),
"rate_color": get_rate_color(),
"success": "✅ Your order has been sent!"
},
)
except Exception as e:
return templates.TemplateResponse(
"result.html",
{
"request": request,
"result": analysis,
"rate_black": get_rate_black(),
"rate_color": get_rate_color(),
"error": f"Failed to send order: {e}",
},
)

View file

@ -5,4 +5,7 @@ python-multipart
pypdf2
pdf2image
Pillow
numpy
numpy
opencv-python
python-dotenv
matrix-nio

15
run_app.py Normal file
View file

@ -0,0 +1,15 @@
# run_app.py
import os
import uvicorn
# Ensure upload folder exists
UPLOAD_FOLDER = "data/uploads"
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
if __name__ == "__main__":
uvicorn.run(
"main:app", # module_name:app_instance
host="0.0.0.0", # accessible on all interfaces
port=8000,
reload=True, # enables auto-reload on code changes
)

View file

@ -1,3 +1,4 @@
/* === Fonts === */
@font-face {
font-family: "SISTEMAS";
src: url("/static/fonts/SISTEMAS FONT BT.ttf") format("truetype");
@ -12,15 +13,28 @@
font-style: normal;
}
h1, h2, h3, h4, h5, h6 {
/* === Typography === */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "SISTEMAS", sans-serif;
color: #001F4D;
/* navy blue */
}
body, p, input, button, label, a {
body,
p,
input,
button,
label,
a {
font-family: "BauPro", sans-serif;
}
/* === Layout === */
body {
background-color: #F9F9F9;
color: #333333;
@ -28,58 +42,15 @@ body {
padding: 0;
}
header {
background-color: #001F4D; /* navy blue */
color: #FFD600; /* yellow text */
padding: 2em;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
h1, h2, h3, h4, h5, h6 {
font-family: "SISTEMAS", sans-serif;
color: #001F4D; /* navy blue */
}
a.link-card {
display: block;
padding: 1.2em 1em;
background-color: #E6007E; /* magenta */
color: #FFD600; /* yellow text */
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: all 0.2s ease-in-out;
}
a.link-card:hover {
background-color: #FFD600; /* yellow hover */
color: #001F4D; /* navy text on hover */
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
button {
padding: 0.7em;
background-color: #E6007E; /* magenta */
color: #FFD600; /* yellow text */
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
}
button:hover {
background-color: #FFD600; /* yellow hover */
color: #001F4D; /* navy text */
}
header {
display: flex;
align-items: center;
gap: 1em;
padding: 1em;
background-color: #001f3f; /* navy */
background-color: #001f3f;
/* navy */
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.logo {
@ -95,4 +66,106 @@ nav a {
nav a:hover {
text-decoration: underline;
}
/* === Links & Buttons === */
a.link-card {
display: block;
padding: 1.2em 1em;
background-color: #E6007E;
/* magenta */
color: #FFD600;
/* yellow text */
text-decoration: none;
border-radius: 10px;
text-align: center;
transition: all 0.2s ease-in-out;
}
a.link-card:hover {
background-color: #FFD600;
/* yellow hover */
color: #001F4D;
/* navy text on hover */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
button {
padding: 0.7em;
background-color: #E6007E;
/* magenta */
color: #FFD600;
/* yellow text */
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
}
button:hover {
background-color: #FFD600;
/* yellow hover */
color: #001F4D;
/* navy text */
}
/* === Result Page Table Styles === */
table {
border-collapse: collapse;
width: 100%;
margin-top: 1em;
}
th,
td {
border: 1px solid #ccc;
padding: 0.5em;
text-align: center;
}
th {
background-color: #f2f2f2;
}
.color {
background-color: #ffdede;
}
.black {
background-color: #e0e0ff;
}
.totals {
font-weight: bold;
}
.container {
max-width: 900px;
margin: auto;
}
h1,
h2 {
text-align: center;
}
.alert {
padding: 1em;
border-radius: 6px;
margin-bottom: 1em;
text-align: center;
font-weight: bold;
}
.alert.success {
background-color: #4CAF50;
/* green */
color: white;
}
.alert.error {
background-color: #E6007E;
/* magenta */
color: #FFD600;
/* yellow text */
}

View file

@ -1,39 +1,44 @@
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Print Cost Calculator</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-family: sans-serif;
margin: 2em;
background-color: #f9f9f9;
color: #333;
margin: 0;
padding: 0;
}
.container {
max-width: 700px;
margin: 3em auto;
max-width: 900px;
margin: auto;
background: #fff;
padding: 2em;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
h1 {
h1,
h2 {
text-align: center;
color: #444;
}
form {
display: flex;
flex-direction: column;
gap: 1em;
margin-top: 1em;
}
input[type="file"] {
padding: 0.5em;
}
button {
padding: 0.7em;
background-color: #007bff;
@ -43,27 +48,61 @@
font-size: 1em;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.rate-info {
text-align: center;
color: #555;
margin-top: 1em;
}
.error {
color: red;
font-weight: bold;
text-align: center;
margin-top: 1em;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 2em;
}
th,
td {
border: 1px solid #ccc;
padding: 0.5em;
text-align: center;
}
th {
background-color: #f2f2f2;
}
.color {
background-color: #ffdede;
}
.black {
background-color: #e0e0ff;
}
.totals {
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<h1>Print Cost Calculator</h1>
{% if error %}
<p class="error">{{ error }}</p>
<p class="error">{{ error }}</p>
{% endif %}
<form action="/upload" method="post" enctype="multipart/form-data">
@ -73,9 +112,59 @@
<p class="rate-info">
Rates are fixed via environment variables:<br>
B&W: {{ rate_black if rate_black else 'RATE_PER_M2_BLACK' }} € / m²,
B&W: {{ rate_black if rate_black else 'RATE_PER_M2_BLACK' }} € / m²,
Color: {{ rate_color if rate_color else 'RATE_PER_M2_COLOR' }} € / m²
</p>
{% if result %}
<h2>Results for {{ result.filename }}</h2>
<table>
<thead>
<tr>
<th>Page</th>
<th>Width (m)</th>
<th>Height (m)</th>
<th>Area (m²)</th>
<th>Ink %</th>
<th>Type</th>
<th>Cost (€)</th>
</tr>
</thead>
<tbody>
{% for page in result.pages %}
<tr class="{{ 'color' if page.is_color else 'black' }}">
<td>{{ page.page }}</td>
<td>{{ "%.3f"|format(page.width_m) }}</td>
<td>{{ "%.3f"|format(page.height_m) }}</td>
<td>{{ "%.4f"|format(page.area_m2) }}</td>
<td>{{ "%.1f"|format(page.ink_pct) if page.ink_pct is not none else '-' }}</td>
<td>{{ 'Color' if page.is_color else 'B&W' }}</td>
<td>{{ "%.2f"|format(page.cost) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals">
<td colspan="3">Total Black Pages</td>
<td>{{ "%.4f"|format(result.total_area_black) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_black) }}</td>
</tr>
<tr class="totals">
<td colspan="3">Total Color Pages</td>
<td>{{ "%.4f"|format(result.total_area_color) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_color) }}</td>
</tr>
<tr class="totals">
<td colspan="6">Grand Total</td>
<td>{{ "%.2f"|format(result.grand_total) }}</td>
</tr>
</tfoot>
</table>
{% endif %}
</div>
</body>
</html>
</html>

View file

@ -1,74 +1,82 @@
<!-- templates/result.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Print Cost Result</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
body { font-family: sans-serif; margin: 2em; }
table { border-collapse: collapse; width: 100%; margin-top: 1em; }
th, td { border: 1px solid #ccc; padding: 0.5em; text-align: center; }
th { background-color: #f2f2f2; }
.color { background-color: #ffdede; }
.black { background-color: #e0e0ff; }
.totals { font-weight: bold; }
.container { max-width: 900px; margin: auto; }
h1, h2 { text-align: center; }
</style>
</head>
<body>
<div class="container">
<h1>Print Cost for {{ result.filename }}</h1>
<div class="container">
<h1>Print Cost for {{ result.filename }}</h1>
<table>
<thead>
<tr>
<th>Page</th>
<th>Width (m)</th>
<th>Height (m)</th>
<th>Area (m²)</th>
<th>Ink %</th>
<th>Type</th>
<th>Cost (€)</th>
</tr>
</thead>
<tbody>
{% for page in result.pages %}
<tr class="{{ 'color' if page.is_color else 'black' }}">
<td>{{ page.page }}</td>
<td>{{ "%.3f"|format(page.width_m) }}</td>
<td>{{ "%.3f"|format(page.height_m) }}</td>
<td>{{ "%.4f"|format(page.area_m2) }}</td>
<td>{{ "%.1f"|format(page.ink_pct) if page.ink_pct is not none else '-' }}</td>
<td>{{ 'Color' if page.is_color else 'B&W' }}</td>
<td>{{ "%.2f"|format(page.cost) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals">
<td colspan="3">Total Black Pages</td>
<td>{{ "%.4f"|format(result.total_area_black) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_black) }}</td>
</tr>
<tr class="totals">
<td colspan="3">Total Color Pages</td>
<td>{{ "%.4f"|format(result.total_area_color) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_color) }}</td>
</tr>
<tr class="totals">
<td colspan="6">Grand Total</td>
<td>{{ "%.2f"|format(result.grand_total) }}</td>
</tr>
</tfoot>
</table>
<!-- Success / Error Banner -->
{% if success %}
<div class="alert success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert error">{{ error }}</div>
{% endif %}
<p style="text-align:center; margin-top:2em;">
<a href="/">Upload another PDF</a>
</p>
</div>
<table>
<thead>
<tr>
<th>Page</th>
<th>Width (m)</th>
<th>Height (m)</th>
<th>Area (m²)</th>
<th>Ink %</th>
<th>Type</th>
<th>Cost (€)</th>
</tr>
</thead>
<tbody>
{% for page in result.pages %}
<tr class="{{ 'color' if page.is_color else 'black' }}">
<td>{{ page.page }}</td>
<td>{{ "%.3f"|format(page.width_m) }}</td>
<td>{{ "%.3f"|format(page.height_m) }}</td>
<td>{{ "%.4f"|format(page.area_m2) }}</td>
<td>{{ "%.1f"|format(page.ink_pct) if page.ink_pct is not none else '-' }}</td>
<td>{{ 'Color' if page.is_color else 'B&W' }}</td>
<td>{{ "%.2f"|format(page.cost) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="totals">
<td colspan="3">Total Black Pages</td>
<td>{{ "%.4f"|format(result.total_area_black) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_black) }}</td>
</tr>
<tr class="totals">
<td colspan="3">Total Color Pages</td>
<td>{{ "%.4f"|format(result.total_area_color) }}</td>
<td colspan="2"></td>
<td>{{ "%.2f"|format(result.total_cost_color) }}</td>
</tr>
<tr class="totals">
<td colspan="6">Grand Total</td>
<td>{{ "%.2f"|format(result.grand_total) }}</td>
</tr>
</tfoot>
</table>
<!-- Send order form -->
<form action="/send-order" method="post">
<input type="hidden" name="filename" value="{{ result.filename }}">
<label for="comment"><strong>Additional Instructions:</strong></label>
<textarea id="comment" name="comment"
placeholder="e.g. Please print double-sided, staple in top left corner..."></textarea>
<button type="submit">Send Order</button>
</form>
<div class="link">
<a href="/">Upload another PDF</a>
</div>
</div>
</body>
</html>
</html>

72
test_send_pdf.py Normal file
View file

@ -0,0 +1,72 @@
import os
import asyncio
from io import BytesIO
from nio import AsyncClient, UploadResponse, RoomSendResponse
from dotenv import load_dotenv
load_dotenv()
async def main():
# Get credentials from environment (adjust if needed)
matrix_user = os.environ.get("MATRIX_USER", "@einszwovier_bot:localhost")
matrix_pass = os.environ.get("MATRIX_PASS")
homeserver = os.environ.get("MATRIX_HOMESERVER", "http://localhost:8008")
room_id = os.environ.get("MATRIX_ROOM") # e.g. "!abc123:localhost"
if not all([matrix_user, matrix_pass, room_id]):
raise RuntimeError("Missing MATRIX_USER, MATRIX_PASS or MATRIX_ROOM")
client = AsyncClient(homeserver, matrix_user)
login_resp = await client.login(matrix_pass)
if getattr(login_resp, "access_token", None) is None:
print("❌ Login failed:", login_resp)
return
print("✅ Logged in as", matrix_user)
pdf_path = "data/uploads/einszwovier infographics 2.pdf" # <-- put any small PDF here
with open(pdf_path, "rb") as f:
pdf_bytes = f.read()
# ✅ Upload PDF (nio returns (resp, err))
upload_resp, upload_err = await client.upload(
data_provider=BytesIO(pdf_bytes),
content_type="application/pdf",
filename=os.path.basename(pdf_path),
filesize=len(pdf_bytes),
)
if upload_err:
print("❌ Upload error:", upload_err)
await client.close()
return
if isinstance(upload_resp, UploadResponse) and upload_resp.content_uri:
print("✅ Upload succeeded:", upload_resp.content_uri)
else:
print("❌ Upload failed:", upload_resp)
await client.close()
return
# Send file message to room
file_resp = await client.room_send(
room_id=room_id,
message_type="m.room.message",
content={
"msgtype": "m.file",
"body": os.path.basename(pdf_path),
"url": upload_resp.content_uri,
},
)
if isinstance(file_resp, RoomSendResponse) and file_resp.event_id:
print("✅ PDF sent to room", room_id)
else:
print("❌ Failed to send PDF:", file_resp)
await client.logout()
await client.close()
if __name__ == "__main__":
asyncio.run(main())