|
|
|
|
@ -6,11 +6,9 @@ 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, ParagraphStyle |
|
|
|
|
from reportlab.lib.styles import getSampleStyleSheet |
|
|
|
|
from PIL import Image as PILImage, ImageOps |
|
|
|
|
from PIL import Image |
|
|
|
|
import re |
|
|
|
|
@ -2519,45 +2517,6 @@ 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(): |
|
|
|
|
@ -2579,37 +2538,6 @@ 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(): |
|
|
|
|
@ -2630,159 +2558,61 @@ 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" |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
doc = SimpleDocTemplate(str(pdf_path), pagesize=letter) |
|
|
|
|
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 = [] |
|
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
job_title = request.form.get("job_title") or "Job Report" |
|
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
|
listed += continued_contents_capacity |
|
|
|
|
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"] |
|
|
|
|
] |
|
|
|
|
|
|
|
|
|
elements.append(PageBreak()) |
|
|
|
|
if not image_files: |
|
|
|
|
flash("PDF Report Workshop needs image files. No supported images were staged.", "warning") |
|
|
|
|
return redirect(url_for("main.zip_workspace")) |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
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 |
|
|
|
|
for idx, p in enumerate(image_files): |
|
|
|
|
display_name = p.name.split("__", 1)[1] if "__" in p.name else p.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(xml_escape(display_name), styles["Heading3"])) |
|
|
|
|
elements.append(Spacer(1, 8)) |
|
|
|
|
elements.append(Paragraph(display_name, styles["Heading3"])) |
|
|
|
|
elements.append(Spacer(1, 6)) |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
tmp_img, w, h = _prepare_report_image_for_pdf_v2(img_path, quality_mode, rotation) |
|
|
|
|
tmp_img, w, h = _prepare_report_image_for_pdf(p, quality_mode) |
|
|
|
|
pdf_temp_files.append(tmp_img) |
|
|
|
|
draw_w, draw_h = _pdf_scaled_dimensions(w, h, 430, 455) |
|
|
|
|
draw_w, draw_h = _pdf_scaled_dimensions(w, h, 500, 600) |
|
|
|
|
elements.append(ReportLabImage(tmp_img, width=draw_w, height=draw_h)) |
|
|
|
|
except Exception as e: |
|
|
|
|
elements.append(Paragraph(f"Image failed to load: {xml_escape(str(e))}", styles["Normal"])) |
|
|
|
|
elements.append(Paragraph(f"Image failed to load: {e}", styles["Normal"])) |
|
|
|
|
|
|
|
|
|
elements.append(Spacer(1, 10)) |
|
|
|
|
elements.append(Spacer(1, 8)) |
|
|
|
|
|
|
|
|
|
if caption: |
|
|
|
|
elements.append(Paragraph(f"<b>Caption:</b> {xml_escape(caption)}", styles["Normal"])) |
|
|
|
|
elements.append(Paragraph(f"<b>Caption:</b> {caption}", styles["Normal"])) |
|
|
|
|
if notes: |
|
|
|
|
elements.append(Paragraph(f"<b>Notes:</b> {xml_escape(notes)}", styles["Normal"])) |
|
|
|
|
elements.append(Paragraph(f"<b>Notes:</b> {notes}", styles["Normal"])) |
|
|
|
|
|
|
|
|
|
if pos != len(selected): |
|
|
|
|
if idx != len(image_files) - 1: |
|
|
|
|
elements.append(PageBreak()) |
|
|
|
|
|
|
|
|
|
doc.build(elements, onFirstPage=_otb_pdf_footer, onLaterPages=_otb_pdf_footer) |
|
|
|
|
doc.build(elements) |
|
|
|
|
|
|
|
|
|
finally: |
|
|
|
|
for tmp_path in pdf_temp_files: |
|
|
|
|
@ -2795,6 +2625,7 @@ 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): |
|
|
|
|
|