diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index e5d678b..b62ab78 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,5 +1,29 @@ # 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. diff --git a/README.md b/README.md index 4b0b181..9ecc3c9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,22 @@ # 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. diff --git a/VERSION b/VERSION index 826e142..a4b6ac3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.1.1 +v2.2.0 diff --git a/app/main/routes.py b/app/main/routes.py index 82c9801..f16958f 100644 --- a/app/main/routes.py +++ b/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"Customer: {xml_escape(customer)}", meta_style)) + elements.append(Paragraph(f"Address: {xml_escape(address)}", meta_style)) + elements.append(Paragraph(f"Technician: {xml_escape(technician)}", meta_style)) + elements.append(Paragraph(f"Date: {xml_escape(report_date)}", meta_style)) + elements.append(Paragraph(f"PDF quality: {xml_escape(quality_mode)}", meta_style)) + elements.append(Paragraph(f"Images included: {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"Caption: {caption}", styles["Normal"])) + elements.append(Paragraph(f"Caption: {xml_escape(caption)}", styles["Normal"])) if notes: - elements.append(Paragraph(f"Notes: {notes}", styles["Normal"])) + elements.append(Paragraph(f"Notes: {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//delete", methods=["POST"]) @portal_session_required def delete_export(filename: str): diff --git a/app/static/otb_pdf_logo.png b/app/static/otb_pdf_logo.png new file mode 100755 index 0000000..dd9f3fa Binary files /dev/null and b/app/static/otb_pdf_logo.png differ diff --git a/app/templates/cloud/pdf_report_workshop.html b/app/templates/cloud/pdf_report_workshop.html index cb1778f..ddf5dae 100644 --- a/app/templates/cloud/pdf_report_workshop.html +++ b/app/templates/cloud/pdf_report_workshop.html @@ -3,44 +3,130 @@ {% block title %}PDF Report Workshop{% endblock %} {% block portal_content %} -

PDF Report Workshop

+ -
+
+
+

PDF Report Workshop

+

Build a client-ready report from staged Archive Workspace images.

+
+ +
-

Report Info

-
-
-
-
-
-
+ +
+
+
+

Report Info

+

These fields appear at the top of the PDF.

+
+
-
-
+
+ + + + + + + + +
+
-
+
+
+
+

Images

+

Remove unneeded photos and rotate any sideways images before generating the PDF.

+
+
-

Images

+
+ -{% for file in staged_files %} -
-
- {{ file.name }}
+ {% if staged_files %} + {% for file in staged_files %} +
+
+ {{ file.name }} +
{{ file.name }}
+
- Caption:
-
+
+ - Notes:
- -
-{% endfor %} + + + + + + + + - + +
+
+ {% endfor %} + + {% else %} +

No staged image files found.

+ {% endif %} +
+ + {% endblock %} diff --git a/app/templates/cloud/zip_workspace.html b/app/templates/cloud/zip_workspace.html index f9dd15f..e0b73ec 100644 --- a/app/templates/cloud/zip_workspace.html +++ b/app/templates/cloud/zip_workspace.html @@ -82,11 +82,6 @@ -
- -
- -