Compare commits

...

2 Commits

  1. 36
      PROJECT_STATE.md
  2. 26
      README.md
  3. 2
      VERSION
  4. 233
      app/main/routes.py
  5. BIN
      app/static/otb_pdf_logo.png
  6. 142
      app/templates/cloud/pdf_report_workshop.html
  7. 6
      app/templates/cloud/zip_workspace.html

36
PROJECT_STATE.md

@ -1,5 +1,41 @@
# OTB Cloud Project State
## v2.2.0 - 20260502-180828
Major release: PDF Report Workshop fully branded and production-ready.
- Added PDF Report Workshop (interactive report builder).
- Image compression integrated for email-friendly PDFs.
- Per-image caption, notes, rotation, and removal controls.
- Multi-page Contents system with continuation support.
- Cover page redesigned with structured metadata layout.
- Images resized for balanced layout (reduced whitespace).
- Footer branding added:
- OTB logo (static asset)
- 'OTB-Cloud PDF generator - generated for {footer_id}'
- Page numbering
- Archive Workspace improvements:
- Flush workspace button
- Fixed Download + Remove behavior
- Added per-export delete controls
Status: Production-ready PDF reporting system.
---
## v2.1.1 - 20260502-153244
Patch release: Archive Workspace export cleanup improvements.
- Added Flush Workspace button for staged Archive Workspace files.
- Fixed Download + Remove so exports are deleted after download response completes.
- Added individual Delete button for processed export files.
- Confirmed PDF Report Workshop image compression works after ImageOps import fix.
---
## v2.1.1 - 20260502-051352
Patch release: Archive Workspace export cleanup improvements.

26
README.md

@ -1,5 +1,31 @@
# OTB Cloud
## v2.2.0 - 20260502-180828
PDF Report Workshop is now fully production-ready.
- Build structured, branded job reports from staged images.
- Add captions, notes, and rotate/remove images interactively.
- Automatic image compression for email-friendly output.
- Multi-page contents with continuation handling.
- Professional PDF layout with cover page and large images.
- Branded footer with logo, generator ID, and page numbers.
- Full archive workspace lifecycle controls.
This release marks the transition from prototype to usable service.
---
## v2.1.1 - 20260502-153244
- Archive Workspace now supports flushing staged files.
- Processed exports can be downloaded, downloaded-and-removed, moved to LTS, or deleted individually.
- PDF Report Workshop compression/image handling confirmed working.
---
## v2.1.1 - 20260502-051352
- Archive Workspace now supports flushing staged files.

2
VERSION

@ -1 +1 @@
v2.1.1
v2.2.0

233
app/main/routes.py

@ -6,9 +6,11 @@ import zipfile
import tarfile
import tempfile
import os
from xml.sax.saxutils import escape as xml_escape
from reportlab.lib.pagesizes import letter
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Image as ReportLabImage, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from PIL import Image as PILImage, ImageOps
from PIL import Image
import re
@ -2517,6 +2519,45 @@ def pdf_report_preview(filename: str):
except Exception:
abort(404)
def _prepare_report_image_for_pdf_v2(source_path, quality_mode="standard", rotation_degrees=0):
modes = {
"high": {"max_px": 2400, "quality": 82},
"standard": {"max_px": 1600, "quality": 70},
"compressed": {"max_px": 1200, "quality": 55},
}
cfg = modes.get(quality_mode, modes["standard"])
with PILImage.open(source_path) as im:
im = ImageOps.exif_transpose(im)
try:
rotation_degrees = int(rotation_degrees or 0)
except ValueError:
rotation_degrees = 0
if rotation_degrees in (90, 180, 270):
im = im.rotate(-rotation_degrees, expand=True)
if im.mode in ("RGBA", "LA", "P"):
bg = PILImage.new("RGB", im.size, (255, 255, 255))
if im.mode == "P":
im = im.convert("RGBA")
bg.paste(im, mask=im.split()[-1] if im.mode in ("RGBA", "LA") else None)
im = bg
else:
im = im.convert("RGB")
max_px = cfg["max_px"]
if max(im.size) > max_px:
ratio = max_px / float(max(im.size))
im = im.resize((max(1, int(im.width * ratio)), max(1, int(im.height * ratio))), PILImage.LANCZOS)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg")
tmp.close()
im.save(tmp.name, "JPEG", quality=cfg["quality"], optimize=True, progressive=True)
return tmp.name, im.width, im.height
@bp.route("/workspace/pdf-report")
@portal_session_required
def pdf_report_workshop():
@ -2538,6 +2579,37 @@ def pdf_report_workshop():
)
def _otb_pdf_footer(canvas, doc):
footer_id = getattr(doc, "footer_id", "") or ""
logo_path = Path(__file__).resolve().parents[1] / "static" / "otb_pdf_logo.png"
canvas.saveState()
y = 24
x = 42
if logo_path.exists():
try:
canvas.drawImage(str(logo_path), x, y - 6, width=42, height=24, preserveAspectRatio=True, mask="auto")
x += 48
except Exception:
pass
canvas.setFont("Helvetica", 8)
footer_text = "OTB-Cloud PDF generator"
if footer_id:
footer_text += f" - generated for {footer_id}"
canvas.drawString(x, y, footer_text)
canvas.drawRightString(570, y, f"Page {doc.page}")
canvas.setStrokeColor(colors.lightgrey)
canvas.setLineWidth(0.25)
canvas.line(42, 38, 570, 38)
canvas.restoreState()
@bp.route("/workspace/pdf-report/create", methods=["POST"])
@portal_session_required
def create_pdf_report():
@ -2558,61 +2630,159 @@ def create_pdf_report():
if quality_mode not in ["high", "standard", "compressed"]:
quality_mode = "standard"
footer_id = (request.form.get("footer_id") or "").strip()
excluded = set(request.form.getlist("excluded_files"))
total_files = int(request.form.get("total_files") or 0)
selected = []
for idx in range(total_files):
filename = request.form.get(f"file_{idx}", "").strip()
if not filename or filename in excluded:
continue
candidate = (staging_dir / filename).resolve()
try:
staging_resolved = staging_dir.resolve()
if staging_resolved not in candidate.parents:
continue
if not candidate.exists() or not candidate.is_file():
continue
if candidate.suffix.lower() not in [".jpg", ".jpeg", ".png", ".webp"]:
continue
except Exception:
continue
selected.append((idx, candidate))
if not selected:
flash("No images selected for PDF report.", "warning")
return redirect(url_for("main.pdf_report_workshop"))
pdf_path = exports_dir / f"{report_name}.pdf"
doc = SimpleDocTemplate(str(pdf_path), pagesize=letter)
job_title = request.form.get("job_title") or "Job Report"
customer = request.form.get("customer", "")
address = request.form.get("address", "")
technician = request.form.get("technician", "")
report_date = request.form.get("date", "")
doc = SimpleDocTemplate(
str(pdf_path),
pagesize=letter,
topMargin=54,
bottomMargin=54,
leftMargin=42,
rightMargin=42,
)
doc.title = job_title
doc.author = "OTB Cloud"
doc.creator = "OTB Cloud PDF Report Workshop"
doc.subject = "Job Report"
doc.footer_id = footer_id
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"OTBTitle",
parent=styles["Title"],
fontSize=20,
leading=24,
spaceAfter=16,
)
meta_style = ParagraphStyle(
"OTBMeta",
parent=styles["Normal"],
fontSize=10.5,
leading=13,
)
contents_style = ParagraphStyle(
"OTBContents",
parent=styles["Normal"],
fontSize=8.5,
leading=10,
)
elements = []
pdf_temp_files = []
job_title = request.form.get("job_title") or "Job Report"
first_contents_capacity = 20
continued_contents_capacity = 32
remaining_after_first = max(0, len(selected) - first_contents_capacity)
continued_pages = 0
if remaining_after_first:
continued_pages = (remaining_after_first + continued_contents_capacity - 1) // continued_contents_capacity
contents_pages = 1 + continued_pages
elements.append(Paragraph(xml_escape(job_title), title_style))
elements.append(Spacer(1, 14))
elements.append(Paragraph(f"<b>Customer:</b> {xml_escape(customer)}", meta_style))
elements.append(Paragraph(f"<b>Address:</b> {xml_escape(address)}", meta_style))
elements.append(Paragraph(f"<b>Technician:</b> {xml_escape(technician)}", meta_style))
elements.append(Paragraph(f"<b>Date:</b> {xml_escape(report_date)}", meta_style))
elements.append(Paragraph(f"<b>PDF quality:</b> {xml_escape(quality_mode)}", meta_style))
elements.append(Paragraph(f"<b>Images included:</b> {len(selected)}", meta_style))
elements.append(Spacer(1, 105))
elements.append(Paragraph("Contents", styles["Heading2"]))
elements.append(Spacer(1, 6))
for pos, (idx, img_path) in enumerate(selected[:first_contents_capacity], start=1):
display_name = img_path.name.split("__", 1)[1] if "__" in img_path.name else img_path.name
caption = request.form.get(f"caption_{idx}", "").strip()
label = caption if caption else display_name
image_page = contents_pages + pos
elements.append(Paragraph(f"{pos}. {xml_escape(label)} — page {image_page}", contents_style))
listed = first_contents_capacity
while listed < len(selected):
elements.append(PageBreak())
elements.append(Paragraph("Contents continued", styles["Heading2"]))
elements.append(Spacer(1, 8))
chunk = selected[listed:listed + continued_contents_capacity]
for offset, (idx, img_path) in enumerate(chunk, start=1):
pos = listed + offset
display_name = img_path.name.split("__", 1)[1] if "__" in img_path.name else img_path.name
caption = request.form.get(f"caption_{idx}", "").strip()
label = caption if caption else display_name
image_page = contents_pages + pos
elements.append(Paragraph(f"{pos}. {xml_escape(label)} — page {image_page}", contents_style))
elements.append(Paragraph(job_title, styles["Title"]))
elements.append(Spacer(1, 12))
elements.append(Paragraph(f"Customer: {request.form.get('customer', '')}", styles["Normal"]))
elements.append(Paragraph(f"Address: {request.form.get('address', '')}", styles["Normal"]))
elements.append(Paragraph(f"Technician: {request.form.get('technician', '')}", styles["Normal"]))
elements.append(Paragraph(f"Date: {request.form.get('date', '')}", styles["Normal"]))
elements.append(Paragraph(f"PDF quality: {quality_mode}", styles["Normal"]))
elements.append(Spacer(1, 24))
image_files = [
p for p in sorted(staging_dir.iterdir(), key=lambda x: x.name.lower())
if p.is_file() and p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
]
listed += continued_contents_capacity
if not image_files:
flash("PDF Report Workshop needs image files. No supported images were staged.", "warning")
return redirect(url_for("main.zip_workspace"))
elements.append(PageBreak())
try:
for idx, p in enumerate(image_files):
display_name = p.name.split("__", 1)[1] if "__" in p.name else p.name
for pos, (idx, img_path) in enumerate(selected, start=1):
display_name = img_path.name.split("__", 1)[1] if "__" in img_path.name else img_path.name
caption = request.form.get(f"caption_{idx}", "").strip()
notes = request.form.get(f"notes_{idx}", "").strip()
rotation = request.form.get(f"rotation_{idx}", "0")
elements.append(Paragraph(display_name, styles["Heading3"]))
elements.append(Spacer(1, 6))
elements.append(Paragraph(xml_escape(display_name), styles["Heading3"]))
elements.append(Spacer(1, 8))
try:
tmp_img, w, h = _prepare_report_image_for_pdf(p, quality_mode)
tmp_img, w, h = _prepare_report_image_for_pdf_v2(img_path, quality_mode, rotation)
pdf_temp_files.append(tmp_img)
draw_w, draw_h = _pdf_scaled_dimensions(w, h, 500, 600)
draw_w, draw_h = _pdf_scaled_dimensions(w, h, 430, 455)
elements.append(ReportLabImage(tmp_img, width=draw_w, height=draw_h))
except Exception as e:
elements.append(Paragraph(f"Image failed to load: {e}", styles["Normal"]))
elements.append(Paragraph(f"Image failed to load: {xml_escape(str(e))}", styles["Normal"]))
elements.append(Spacer(1, 8))
elements.append(Spacer(1, 10))
if caption:
elements.append(Paragraph(f"<b>Caption:</b> {caption}", styles["Normal"]))
elements.append(Paragraph(f"<b>Caption:</b> {xml_escape(caption)}", styles["Normal"]))
if notes:
elements.append(Paragraph(f"<b>Notes:</b> {notes}", styles["Normal"]))
elements.append(Paragraph(f"<b>Notes:</b> {xml_escape(notes)}", styles["Normal"]))
if idx != len(image_files) - 1:
if pos != len(selected):
elements.append(PageBreak())
doc.build(elements)
doc.build(elements, onFirstPage=_otb_pdf_footer, onLaterPages=_otb_pdf_footer)
finally:
for tmp_path in pdf_temp_files:
@ -2625,7 +2795,6 @@ def create_pdf_report():
return redirect(url_for("main.zip_workspace"))
@bp.route("/workspace/exports/<path:filename>/delete", methods=["POST"])
@portal_session_required
def delete_export(filename: str):

BIN
app/static/otb_pdf_logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

142
app/templates/cloud/pdf_report_workshop.html

@ -3,44 +3,130 @@
{% block title %}PDF Report Workshop{% endblock %}
{% block portal_content %}
<h1>PDF Report Workshop</h1>
<style>
.report-card {
display:grid;
grid-template-columns: 260px minmax(260px, 1fr);
gap:16px;
margin-bottom:18px;
padding:14px;
border-radius:14px;
background:rgba(255,255,255,0.04);
}
.report-card img {
max-width:240px;
max-height:220px;
border-radius:10px;
}
.report-fields {
display:flex;
flex-direction:column;
gap:8px;
}
@media (max-width: 800px) {
.report-card { grid-template-columns:1fr; }
}
</style>
<form method="post" action="/workspace/pdf-report/create">
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">PDF Report Workshop</h1>
<p class="portal-page-subtitle">Build a client-ready report from staged Archive Workspace images.</p>
</div>
<div class="portal-toolbar">
<a class="portal-btn" href="{{ url_for('main.zip_workspace') }}">Back to Archive Workspace</a>
</div>
</div>
<h3>Report Info</h3>
<input name="report_name" placeholder="Report filename"><br>
<input name="job_title" placeholder="Job Title"><br>
<input name="customer" placeholder="Customer"><br>
<input name="address" placeholder="Address"><br>
<input name="technician" placeholder="Technician"><br>
<input name="date" placeholder="Date"><br>
<form method="post" action="{{ url_for('main.create_pdf_report') }}">
<section class="service-card" style="margin-bottom:18px;">
<div class="service-card-header">
<div>
<h2>Report Info</h2>
<p>These fields appear at the top of the PDF.</p>
</div>
</div>
<label>PDF Size / Quality</label><br>
<select name="quality_mode" class="portal-input" style="max-width:320px;">
<option value="standard" selected>Standard - email friendly</option>
<option value="compressed">Compressed - smallest file</option>
<option value="high">High - larger file</option>
</select><br>
<div class="service-card-actions" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;">
<input class="portal-input" name="report_name" placeholder="Report filename">
<input class="portal-input" name="job_title" placeholder="Job Title">
<input class="portal-input" name="customer" placeholder="Customer">
<input class="portal-input" name="address" placeholder="Address">
<input class="portal-input" name="technician" placeholder="Technician">
<input class="portal-input" name="footer_id" placeholder="Footer ID / generated for">
<input class="portal-input" name="date" placeholder="Date">
<select name="quality_mode" class="portal-input">
<option value="standard" selected>Standard - email friendly</option>
<option value="compressed">Compressed - smallest file</option>
<option value="high">High - larger file</option>
</select>
</div>
</section>
<hr>
<section class="service-card">
<div class="service-card-header">
<div>
<h2>Images</h2>
<p>Remove unneeded photos and rotate any sideways images before generating the PDF.</p>
</div>
</div>
<h3>Images</h3>
<div id="excluded-files"></div>
<input type="hidden" name="total_files" value="{{ staged_files|length }}">
{% for file in staged_files %}
<div style="margin-bottom:20px;">
<img src="{{ url_for('main.pdf_report_preview', filename=file.name) }}" style="max-width:300px;max-height:220px;border-radius:10px;"><br>
<strong>{{ file.name }}</strong><br>
{% if staged_files %}
{% for file in staged_files %}
<div class="report-card" data-filename="{{ file.name }}">
<div>
<img src="{{ url_for('main.pdf_report_preview', filename=file.name) }}" alt="{{ file.name }}">
<div style="margin-top:8px;font-weight:700;word-break:break-word;">{{ file.name }}</div>
</div>
Caption:<br>
<input name="caption_{{ loop.index0 }}" style="width:300px;"><br>
<div class="report-fields">
<input type="hidden" name="file_{{ loop.index0 }}" value="{{ file.name }}">
Notes:<br>
<textarea name="notes_{{ loop.index0 }}" style="width:300px;"></textarea>
</div>
{% endfor %}
<label>Caption</label>
<input class="portal-input" name="caption_{{ loop.index0 }}" placeholder="Caption">
<label>Notes</label>
<textarea class="portal-input" name="notes_{{ loop.index0 }}" rows="4" placeholder="Notes"></textarea>
<label>Rotation</label>
<select class="portal-input" name="rotation_{{ loop.index0 }}">
<option value="0" selected>No rotation</option>
<option value="90">Rotate 90° clockwise</option>
<option value="180">Rotate 180°</option>
<option value="270">Rotate 270° clockwise</option>
</select>
<button type="submit">Generate PDF Report</button>
<button class="portal-btn" type="button" onclick="removeReportImage(this)">
Remove from this report
</button>
</div>
</div>
{% endfor %}
<button class="portal-btn primary" type="submit">Generate PDF Report</button>
{% else %}
<p>No staged image files found.</p>
{% endif %}
</section>
</form>
<script>
function removeReportImage(btn){
const card = btn.closest(".report-card");
const filename = card.dataset.filename;
const holder = document.getElementById("excluded-files");
const input = document.createElement("input");
input.type = "hidden";
input.name = "excluded_files";
input.value = filename;
holder.appendChild(input);
card.remove();
}
</script>
{% endblock %}

6
app/templates/cloud/zip_workspace.html

@ -82,11 +82,6 @@
</div>
</form>
<form method="post" action="{{ url_for('main.delete_export', filename=item.name) }}" style="display:inline;">
<button class="portal-btn" type="submit" onclick="return confirm('Delete this export?');">Delete</button>
</form>
<ul style="padding-left:18px; margin:0;">
{% for item in staged_files %}
<li style="margin-bottom:8px;">
@ -132,7 +127,6 @@
<form method="post" action="{{ url_for('main.delete_export', filename=item.name) }}" style="display:inline;">
<button class="portal-btn" type="submit" onclick="return confirm('Delete this export?');">Delete</button>
</form>
</div>
</li>
{% endfor %}

Loading…
Cancel
Save