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. + + + Back to Archive Workspace + + -Report Info - - - - - - + + + + + Report Info + These fields appear at the top of the PDF. + + -PDF Size / Quality - - Standard - email friendly - Compressed - smallest file - High - larger file - + + + + + + + + + + Standard - email friendly + Compressed - smallest file + High - larger file + + + - + + + + 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 }} + - Caption: - + + - Notes: - - -{% endfor %} + Caption + + + Notes + + + Rotation + + No rotation + Rotate 90° clockwise + Rotate 180° + Rotate 270° clockwise + -Generate PDF Report + + Remove from this report + + + + {% endfor %} + Generate PDF Report + {% 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 @@ - - Delete - - - {% for item in staged_files %} @@ -132,7 +127,6 @@ Delete - {% endfor %}
Build a client-ready report from staged Archive Workspace images.
These fields appear at the top of the PDF.
Remove unneeded photos and rotate any sideways images before generating the PDF.
No staged image files found.