Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| from docx import Document | |
| from docx.shared import Inches, Pt | |
| from docx.enum.table import WD_TABLE_ALIGNMENT, WD_ALIGN_VERTICAL, WD_ROW_HEIGHT_RULE | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| from PIL import Image | |
| import pillow_heif | |
| import os | |
| import io | |
| import subprocess | |
| from docx.oxml import OxmlElement | |
| from docx.oxml.ns import qn | |
| from datetime import datetime | |
| # Register HEIC opener | |
| pillow_heif.register_heif_opener() | |
| # --- CONSTANTS & LAYOUT --- | |
| CONTENT_WIDTH_IN = 8.5 - (0.5 + 0.5) # 7.5" | |
| # --- STRICT 1:4 RATIO --- | |
| DESC_COL_IN = CONTENT_WIDTH_IN * (1 / 5.0) # 1 part = 1.5" | |
| PHOTO_COL_IN = CONTENT_WIDTH_IN * (4 / 5.0) # 4 parts = 6.0" | |
| ROW_HEIGHT_IN = 4.5 # Two rows per page | |
| PHOTO_MAX_W_IN = PHOTO_COL_IN - 0.20 # Padding | |
| PHOTO_MAX_H_IN = ROW_HEIGHT_IN - 0.20 | |
| JOB_MAP = { | |
| "WM 3155 SSM Parking": "T250014 Walmart 3155 Sault Ste. Marie FY26 Parking Lot Refresh\n446 Great Northern Rd.\nSault Ste. Marie, Ontario, P6B 4Z9", | |
| "GSDR GSN CC": "T250030 GSDR - Guru Nanak Sewa Community Centre\n1410 Stevenson Rd N\nOshawa, Ontario, L1L 0N6", | |
| } | |
| # --- IMAGE PROCESSING --- | |
| def process_image(uploaded_file, compress=False, quality=70, rotation=0): | |
| """Open, rotate, and optionally compress an image. Returns a BytesIO stream.""" | |
| try: | |
| uploaded_file.seek(0) | |
| img = Image.open(uploaded_file) | |
| if rotation: | |
| img = img.rotate(rotation, expand=True) | |
| if img.mode == 'RGBA': | |
| bg = Image.new('RGB', img.size, (255, 255, 255)) | |
| bg.paste(img, mask=img.split()[3]) | |
| img = bg | |
| elif img.mode != 'RGB': | |
| img = img.convert('RGB') | |
| img_io = io.BytesIO() | |
| if compress: | |
| img.save(img_io, format='JPEG', optimize=True, quality=int(quality)) | |
| else: | |
| img.save(img_io, format='PNG') | |
| img_io.seek(0) | |
| return img_io | |
| except Exception as e: | |
| st.error(f"Error processing image '{getattr(uploaded_file, 'name', 'Unknown')}': {e}") | |
| return None | |
| def get_rotated_preview(uploaded_file, rotation=0): | |
| """Return a PIL image rotated for preview.""" | |
| try: | |
| uploaded_file.seek(0) | |
| img = Image.open(uploaded_file) | |
| if rotation: | |
| img = img.rotate(rotation, expand=True) | |
| if img.mode not in ('RGB', 'RGBA'): | |
| img = img.convert('RGB') | |
| return img | |
| except Exception as e: | |
| st.error(f"Preview error: {e}") | |
| return None | |
| # --- DOC HELPERS --- | |
| def add_page_number(paragraph): | |
| """Adds Page X of Y in footer.""" | |
| paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT | |
| paragraph.add_run("Page ") | |
| r1 = paragraph.add_run() | |
| fldChar1 = OxmlElement('w:fldChar') | |
| fldChar1.set(qn('w:fldCharType'), 'begin') | |
| r1._r.append(fldChar1) | |
| instr = OxmlElement('w:instrText') | |
| instr.set(qn('xml:space'), 'preserve') | |
| instr.text = "PAGE" | |
| r1._r.append(instr) | |
| fldChar2 = OxmlElement('w:fldChar') | |
| fldChar2.set(qn('w:fldCharType'), 'end') | |
| r1._r.append(fldChar2) | |
| paragraph.add_run(" of ") | |
| r2 = paragraph.add_run() | |
| fldChar3 = OxmlElement('w:fldChar') | |
| fldChar3.set(qn('w:fldCharType'), 'begin') | |
| r2._r.append(fldChar3) | |
| instr2 = OxmlElement('w:instrText') | |
| instr2.set(qn('xml:space'), 'preserve') | |
| instr2.text = "NUMPAGES" | |
| r2._r.append(instr2) | |
| fldChar4 = OxmlElement('w:fldChar') | |
| fldChar4.set(qn('w:fldCharType'), 'end') | |
| r2._r.append(fldChar4) | |
| def ensure_cell_has_one_empty_paragraph(cell): | |
| """ | |
| Safely reset a cell without corrupting the DOCX. | |
| Ensures the cell has exactly one empty paragraph. | |
| """ | |
| while len(cell.paragraphs) > 1: | |
| p = cell.paragraphs[-1] | |
| p._element.getparent().remove(p._element) | |
| if not cell.paragraphs: | |
| cell.add_paragraph("") | |
| else: | |
| p = cell.paragraphs[0] | |
| p.text = "" | |
| p.paragraph_format.space_before = Pt(0) | |
| p.paragraph_format.space_after = Pt(0) | |
| def set_table_fixed_layout(table): | |
| """ | |
| Locks table layout so Word doesn't auto-resize columns. | |
| """ | |
| tbl = table._element | |
| tblPr = tbl.tblPr | |
| if tblPr is None: | |
| tblPr = OxmlElement('w:tblPr') | |
| tbl.insert(0, tblPr) | |
| tblLayout = OxmlElement('w:tblLayout') | |
| tblLayout.set(qn('w:type'), 'fixed') | |
| tblPr.append(tblLayout) | |
| # --- CORE REPORT GENERATION --- | |
| def generate_report(image_items, job_details, compress_images, quality, progress_bar): | |
| doc = Document() | |
| section = doc.sections[0] | |
| section.left_margin = section.right_margin = Inches(0.5) | |
| section.top_margin = section.bottom_margin = Inches(0.75) | |
| # Header | |
| header = section.header | |
| header_table = header.add_table(rows=1, cols=2, width=Inches(CONTENT_WIDTH_IN)) | |
| set_table_fixed_layout(header_table) | |
| header_table.columns[0].width = Inches(2.5) | |
| header_table.columns[1].width = Inches(CONTENT_WIDTH_IN - 2.5) | |
| logo_cell = header_table.cell(0, 0) | |
| ensure_cell_has_one_empty_paragraph(logo_cell) | |
| if os.path.exists("logo.png"): | |
| p_logo = logo_cell.paragraphs[0] | |
| p_logo.add_run().add_picture("logo.png", width=Inches(1.5)) | |
| text_run = p_logo.add_run("\nHKC Construction") | |
| text_run.font.size = Pt(8) | |
| info_cell = header_table.cell(0, 1) | |
| ensure_cell_has_one_empty_paragraph(info_cell) | |
| p_info = info_cell.paragraphs[0] | |
| p_info.text = job_details | |
| p_info.alignment = WD_ALIGN_PARAGRAPH.RIGHT | |
| info_cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER | |
| # Footer | |
| footer = section.footer | |
| add_page_number(footer.paragraphs[0]) | |
| # --- LAYOUT FIX: Reverted to the original 2-photo-per-page layout --- | |
| num_images = len(image_items) | |
| processed = 0 | |
| for start in range(0, num_images, 2): # Process 2 images per page | |
| if start > 0: | |
| doc.add_page_break() | |
| page_items = image_items[start:start + 2] | |
| # A 2x2 table: Row 1 is for Photo 1, Row 2 is for Photo 2 | |
| page_table = doc.add_table(rows=2, cols=2) | |
| page_table.width = Inches(CONTENT_WIDTH_IN) | |
| set_table_fixed_layout(page_table) | |
| page_table.style = 'Table Grid' | |
| page_table.alignment = WD_TABLE_ALIGNMENT.CENTER | |
| # Set column widths to the strict 1:4 ratio | |
| page_table.columns[0].width = Inches(DESC_COL_IN) | |
| page_table.columns[1].width = Inches(PHOTO_COL_IN) | |
| for r in page_table.rows: | |
| r.height = Inches(ROW_HEIGHT_IN) | |
| r.height_rule = WD_ROW_HEIGHT_RULE.EXACTLY | |
| # Explicitly set cell widths as well for compatibility | |
| r.cells[0].width = Inches(DESC_COL_IN) | |
| r.cells[1].width = Inches(PHOTO_COL_IN) | |
| # Fill the two rows | |
| for row_idx in range(2): | |
| # Only proceed if there is an item for this row | |
| if row_idx < len(page_items): | |
| item = page_items[row_idx] | |
| desc_cell = page_table.cell(row_idx, 0) | |
| photo_cell = page_table.cell(row_idx, 1) | |
| ensure_cell_has_one_empty_paragraph(desc_cell) | |
| ensure_cell_has_one_empty_paragraph(photo_cell) | |
| desc_cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP | |
| photo_cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER | |
| # Description | |
| p_desc = desc_cell.paragraphs[0] | |
| run_label = p_desc.add_run("Description:\n") | |
| run_label.bold = True | |
| p_desc.add_run(item.get('description', '') or '') | |
| # Image processing and placement | |
| rotation = item.get('rotation', 0) or 0 | |
| try: | |
| item['file'].seek(0) | |
| im = Image.open(item['file']) | |
| if rotation: | |
| im = im.rotate(rotation, expand=True) | |
| w_px, h_px = im.size | |
| except Exception: | |
| w_px, h_px = (1000, 1000) | |
| img_ratio = (w_px / h_px) if h_px else 1.0 | |
| box_ratio = PHOTO_MAX_W_IN / PHOTO_MAX_H_IN | |
| img_stream = process_image( | |
| item['file'], compress=compress_images, quality=quality, rotation=rotation | |
| ) | |
| if img_stream: | |
| p_photo = photo_cell.paragraphs[0] | |
| p_photo.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| if img_ratio >= box_ratio: | |
| p_photo.add_run().add_picture(img_stream, width=Inches(PHOTO_MAX_W_IN)) | |
| else: | |
| p_photo.add_run().add_picture(img_stream, height=Inches(PHOTO_MAX_H_IN)) | |
| processed += 1 | |
| if num_images: | |
| progress_bar.progress(processed / num_images) | |
| # Save DOCX | |
| doc_io = io.BytesIO() | |
| doc.save(doc_io) | |
| doc_io.seek(0) | |
| # Optional PDF conversion | |
| pdf_io = None | |
| try: | |
| with open("temp_report.docx", "wb") as f: | |
| f.write(doc_io.getvalue()) | |
| subprocess.run( | |
| ['libreoffice', '--headless', '--convert-to', 'pdf', '--outdir', '.', 'temp_report.docx'], | |
| check=True, timeout=120 | |
| ) | |
| pdf_path = 'temp_report.pdf' | |
| if os.path.exists(pdf_path): | |
| with open(pdf_path, "rb") as f: | |
| pdf_io = io.BytesIO(f.read()) | |
| os.remove("temp_report.docx") | |
| os.remove(pdf_path) | |
| except Exception as e: | |
| st.warning(f"PDF conversion failed: {e}. You can still download the Word document.") | |
| return doc_io, pdf_io | |
| # --- STREAMLIT UI --- | |
| st.set_page_config(layout="centered", page_title="HKC Report Generator") | |
| if 'image_dict' not in st.session_state: | |
| st.session_state.image_dict = {} | |
| def reset_app(): | |
| st.session_state.clear() | |
| if os.path.exists("logo.png"): | |
| st.image("logo.png", width=200) | |
| st.title("Photo Report Generator") | |
| st.markdown("---") | |
| with st.sidebar: | |
| st.header("1. Job Information") | |
| job_key = st.selectbox( | |
| "Select Job", | |
| options=list(JOB_MAP.keys()), | |
| index=0, | |
| help="This sets the header and the download file name." | |
| ) | |
| job_details = JOB_MAP[job_key] | |
| st.text_area("Job Details", job_details, height=125, disabled=True) | |
| st.header("2. Image Settings") | |
| compress_images = st.checkbox("Compress Images", value=True) | |
| quality = st.slider("Compression intensity", min_value=10, max_value=95, value=70, step=5) | |
| st.header("3. Actions") | |
| generate_button = st.button("Generate Report", type="primary", use_container_width=True) | |
| if st.button("Reset All", use_container_width=True): | |
| reset_app() | |
| st.rerun() | |
| st.header("Upload, Arrange, Rotate, and Describe Images") | |
| st.info("Arrange photos in the desired order. The report will have 2 photos per page.") | |
| uploaded_files = st.file_uploader( | |
| "Upload all your photos here", | |
| accept_multiple_files=True, | |
| type=["jpg", "jpeg", "png", "heic", "jfif"], | |
| key="file_uploader" | |
| ) | |
| if uploaded_files: | |
| for file in uploaded_files: | |
| fid = getattr(file, "file_id", None) or f"{file.name}-{id(file)}" | |
| if fid not in st.session_state.image_dict: | |
| current_max_order = max((d.get('order', 0) for d in st.session_state.image_dict.values()), default=0) | |
| st.session_state.image_dict[fid] = { | |
| 'file': file, | |
| 'description': '', | |
| 'order': current_max_order + 1, | |
| 'rotation': 0 | |
| } | |
| if st.session_state.image_dict: | |
| items = list(st.session_state.image_dict.items()) | |
| items.sort(key=lambda kv: kv[1].get('order', 0)) | |
| for fid, item_data in items: | |
| st.markdown("---") | |
| cols = st.columns([2, 3]) | |
| with cols[0]: | |
| preview = get_rotated_preview(item_data['file'], item_data.get('rotation', 0)) | |
| if preview is not None: | |
| st.image(preview, use_column_width=True) | |
| rcols = st.columns(2) | |
| if rcols[0].button("↺ Rotate Left", key=f"rotl_{fid}"): | |
| item_data['rotation'] = (item_data.get('rotation', 0) + 90) % 360 | |
| st.rerun() | |
| if rcols[1].button("↻ Rotate Right", key=f"rotr_{fid}"): | |
| item_data['rotation'] = (item_data.get('rotation', 0) - 90) % 360 | |
| st.rerun() | |
| with cols[1]: | |
| item_data['order'] = st.number_input( | |
| "Order", min_value=1, value=item_data.get('order', 1), key=f"order_{fid}" | |
| ) | |
| item_data['description'] = st.text_area( | |
| "Description", value=item_data.get('description', ''), key=f"desc_{fid}", height=120 | |
| ) | |
| if generate_button: | |
| if not job_details: | |
| st.error("Please select a job.") | |
| elif not st.session_state.image_dict: | |
| st.error("Please upload at least one image.") | |
| else: | |
| sorted_image_items = [v for _, v in sorted(st.session_state.image_dict.items(), key=lambda kv: kv[1].get('order', 0))] | |
| progress_bar = st.progress(0.0, text="Starting report generation...") | |
| try: | |
| with st.spinner("Generating your report..."): | |
| doc_io, pdf_io = generate_report( | |
| sorted_image_items, job_details, compress_images, quality, progress_bar | |
| ) | |
| progress_bar.success("Report generated successfully!") | |
| date_str = datetime.now().strftime("%y%m%d") | |
| file_base = f"{job_key} - {date_str}" | |
| st.header("Download Your Report") | |
| c1, c2 = st.columns(2) | |
| c1.download_button("⬇️ Download Word (.docx)", doc_io, f"{file_base}.docx", use_container_width=True) | |
| if pdf_io: | |
| c2.download_button("⬇️ Download PDF (.pdf)", pdf_io, f"{file_base}.pdf", use_container_width=True) | |
| except Exception as e: | |
| st.error(f"A critical error occurred: {e}") | |
| progress_bar.empty() |