Preeeeet commited on
Commit
db6af84
·
verified ·
1 Parent(s): 30c36fe

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +211 -94
app.py CHANGED
@@ -1,7 +1,7 @@
1
  import streamlit as st
2
  from docx import Document
3
  from docx.shared import Inches, Pt
4
- from docx.enum.table import WD_TABLE_ALIGNMENT, WD_ALIGN_VERTICAL
5
  from docx.enum.text import WD_ALIGN_PARAGRAPH
6
  from PIL import Image
7
  import pillow_heif
@@ -10,48 +10,81 @@ import io
10
  import subprocess
11
  from docx.oxml import OxmlElement
12
  from docx.oxml.ns import qn
 
13
 
14
  # Register the HEIC opener
15
  pillow_heif.register_heif_opener()
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  # --- IMAGE PROCESSING ---
18
- def process_image(uploaded_file, compress=False, quality=60):
19
- """Opens, processes, and optionally compresses an image. Returns a BytesIO stream."""
20
  try:
21
  uploaded_file.seek(0)
22
  img = Image.open(uploaded_file)
 
 
 
 
 
 
23
  if img.mode == 'RGBA':
24
  background = Image.new('RGB', img.size, (255, 255, 255))
25
  background.paste(img, mask=img.split()[3])
26
  img = background
27
  elif img.mode != 'RGB':
28
  img = img.convert('RGB')
29
-
30
  img_io = io.BytesIO()
31
  save_format = 'JPEG' if compress else 'PNG'
32
  if compress:
33
- img.save(img_io, format=save_format, optimize=True, quality=quality)
34
  else:
35
- img.save(img_io, format=save_format)
36
  img_io.seek(0)
37
  return img_io
38
  except Exception as e:
39
- st.error(f"Error processing image '{uploaded_file.name}': {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  return None
41
 
42
  def add_page_number(paragraph):
43
  """Adds Page X of Y page numbering to a footer paragraph."""
44
  paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
45
-
46
- # Add "Page " run
47
  page_run = paragraph.add_run("Page ")
48
-
49
- # Add page number field
50
  page_num_run = paragraph.add_run()
51
  fldChar1 = OxmlElement('w:fldChar')
52
  fldChar1.set(qn('w:fldCharType'), 'begin')
53
  page_num_run._r.append(fldChar1)
54
-
55
  instr = OxmlElement('w:instrText')
56
  instr.set(qn('xml:space'), 'preserve')
57
  instr.text = "PAGE"
@@ -61,10 +94,10 @@ def add_page_number(paragraph):
61
  fldChar2.set(qn('w:fldCharType'), 'end')
62
  page_num_run._r.append(fldChar2)
63
 
64
- # Add " of " run
65
  of_run = paragraph.add_run(" of ")
66
 
67
- # Add total pages field
68
  num_pages_run = paragraph.add_run()
69
  fldChar3 = OxmlElement('w:fldChar')
70
  fldChar3.set(qn('w:fldCharType'), 'begin')
@@ -81,90 +114,143 @@ def add_page_number(paragraph):
81
 
82
 
83
  # --- CORE REPORT GENERATION LOGIC ---
84
- def generate_report(image_items, job_details, compress_images, progress_bar):
85
- """Generates the DOCX and PDF report with the corrected layout."""
86
  doc = Document()
87
  section = doc.sections[0]
88
  section.left_margin = section.right_margin = Inches(0.5)
89
- section.top_margin = section.bottom_margin = Inches(0.75) # Extra space for footer
90
 
91
  # --- Header Setup ---
92
  header = section.header
93
- header_table = header.add_table(rows=1, cols=2, width=Inches(7.5))
94
  header_table.autofit = False
95
  header_table.columns[0].width = Inches(2.5)
96
- header_table.columns[1].width = Inches(5.0)
97
-
98
  logo_cell = header_table.cell(0, 0)
99
  if os.path.exists("logo.png"):
100
  p_logo = logo_cell.paragraphs[0]
101
  p_logo.add_run().add_picture("logo.png", width=Inches(1.5))
102
  p_logo.add_run("\nHKC Construction").font.size = Pt(8)
103
-
104
  info_cell = header_table.cell(0, 1)
105
  p_info = info_cell.paragraphs[0]
106
  p_info.text = job_details
107
  p_info.alignment = WD_ALIGN_PARAGRAPH.RIGHT
108
  info_cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
109
-
110
  # --- Footer Setup ---
111
  footer = section.footer
112
  footer_para = footer.paragraphs[0]
113
  add_page_number(footer_para)
114
 
115
  num_images = len(image_items)
116
-
117
- # --- Corrected Layout: Two Items per Page ---
118
- for i in range(0, num_images):
119
- item = image_items[i]
120
-
121
- # Add a page break before the 3rd, 5th, 7th, etc. item
122
- if i > 0 and i % 2 == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  doc.add_page_break()
124
 
125
- # Main table for this item: Description on left, Photo on right
126
- item_table = doc.add_table(rows=1, cols=2)
127
- item_table.autofit = False
128
- item_table.columns[0].width = Inches(3.0)
129
- item_table.columns[1].width = Inches(4.5)
130
-
131
- # Description Cell (Left)
132
- desc_cell = item_table.cell(0, 0)
133
- desc_cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP # Align to top
134
- # Clear default paragraph and add our formatted one
135
- desc_cell._element.clear_content()
136
- p_desc = desc_cell.add_paragraph()
137
- run = p_desc.add_run('Description:\n')
138
- run.bold = True
139
- p_desc.add_run(item['description'])
140
-
141
- # Photo Cell (Right)
142
- img_stream = process_image(item['file'], compress=compress_images)
143
- if img_stream:
144
- photo_cell = item_table.cell(0, 1)
145
- photo_cell._element.clear_content()
146
- p_photo = photo_cell.add_paragraph()
147
- # Add picture, keeping aspect ratio by only setting width
148
- p_photo.add_run().add_picture(img_stream, width=Inches(4.4))
149
- p_photo.alignment = WD_ALIGN_PARAGRAPH.CENTER
150
-
151
- # Add some vertical space before the next item on the same page
152
- doc.add_paragraph()
153
-
154
- progress_bar.progress((i + 1) / num_images)
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  # --- File Saving and Conversion ---
158
  doc_io = io.BytesIO()
159
  doc.save(doc_io)
160
  doc_io.seek(0)
161
-
162
  pdf_io = None
163
  try:
164
  with open("temp_report.docx", "wb") as f:
165
  f.write(doc_io.getvalue())
166
  subprocess.run(
167
- ['libreoffice', '--headless', '--convert-to', 'pdf', '--outdir', '.', 'temp_report.docx'],
168
  check=True, timeout=120
169
  )
170
  pdf_path = 'temp_report.pdf'
@@ -178,6 +264,7 @@ def generate_report(image_items, job_details, compress_images, progress_bar):
178
 
179
  return doc_io, pdf_io
180
 
 
181
  # --- STREAMLIT UI ---
182
 
183
  st.set_page_config(layout="centered", page_title="HKC Report Generator")
@@ -200,85 +287,115 @@ st.markdown("---")
200
  # --- Sidebar for Settings ---
201
  with st.sidebar:
202
  st.header("1. Job Information")
203
- job_details = st.text_area(
204
- "Job Details",
205
- "T250014 Walmart 3155 Sault Ste. Marie FY26 Parking Lot Refresh\n446 Great Northern Rd.\nSault Ste. Marie, Ontario, P6B 4Z9",
206
- height=125,
207
- help="Enter all job information here. It will appear in the header."
208
  )
209
- compress_images = st.checkbox("Compress Images", value=True, help="Greatly reduces final file size.")
210
-
211
- st.header("2. Actions")
 
 
 
 
 
 
 
 
212
  generate_button = st.button("Generate Report", type="primary", use_container_width=True)
213
  if st.button("Reset All", use_container_width=True):
214
  reset_app()
215
  st.rerun()
216
 
217
  # --- Main Area ---
218
- st.header("3. Upload, Arrange, and Describe Images")
219
  uploaded_files = st.file_uploader(
220
  "Upload all your photos here",
221
  accept_multiple_files=True,
222
- type=["jpg", "jpeg", "png", "heic","jfif"],
223
  key="file_uploader"
224
  )
225
 
 
226
  if uploaded_files:
227
  for file in uploaded_files:
228
- if file.file_id not in st.session_state.image_dict:
229
  current_max_order = max([d.get('order', 0) for d in st.session_state.image_dict.values()], default=0)
230
- st.session_state.image_dict[file.file_id] = {
231
  'file': file,
232
  'description': '',
233
- 'order': current_max_order + 1
 
234
  }
235
-
236
- st.info("Set the report order using numbers, then add descriptions for each photo.")
237
-
 
238
  sorted_display_items = sorted(st.session_state.image_dict.values(), key=lambda x: x.get('order', 0))
239
 
240
  for item_data in sorted_display_items:
241
- file_id = item_data['file'].file_id
242
  st.markdown("---")
243
  cols = st.columns([2, 3])
244
-
245
  with cols[0]:
246
- st.image(item_data['file'], use_column_width=True)
 
 
 
 
 
 
 
 
 
 
247
 
248
  with cols[1]:
249
- item_data['order'] = st.number_input("Order", min_value=1, value=item_data.get('order', 1), key=f"order_{file_id}")
250
- item_data['description'] = st.text_area("Description", value=item_data.get('description', ''), key=f"desc_{file_id}", height=120)
 
 
 
 
251
 
252
  # --- Generation Logic ---
253
  if generate_button:
254
  if not job_details:
255
- st.error("Please fill in the Job Details.")
256
  elif not st.session_state.image_dict:
257
  st.error("Please upload at least one image.")
258
  else:
259
  items_to_sort = list(st.session_state.image_dict.values())
260
  sorted_image_items = sorted(items_to_sort, key=lambda x: x.get('order', 0))
261
-
262
  progress_bar = st.progress(0, text="Starting report generation...")
263
  try:
264
- with st.spinner('Generating your report... This may take a moment.'):
265
  doc_io, pdf_io = generate_report(
266
- sorted_image_items,
267
- job_details,
268
- compress_images, progress_bar
 
 
269
  )
270
  progress_bar.success("Report generated successfully!")
271
-
272
- st.header("4. Download Your Report")
 
 
 
 
273
  dl_col1, dl_col2 = st.columns(2)
274
  dl_col1.download_button(
275
- "⬇️ Download Word (.docx)", doc_io, f"HKC_Report.docx", use_container_width=True
276
  )
277
  if pdf_io:
278
  dl_col2.download_button(
279
- "⬇️ Download PDF (.pdf)", pdf_io, f"HKC_Report.pdf", use_container_width=True
280
  )
281
  except Exception as e:
282
  st.error(f"A critical error occurred: {e}")
283
- progress_bar.empty()
284
-
 
1
  import streamlit as st
2
  from docx import Document
3
  from docx.shared import Inches, Pt
4
+ from docx.enum.table import WD_TABLE_ALIGNMENT, WD_ALIGN_VERTICAL, WD_ROW_HEIGHT_RULE
5
  from docx.enum.text import WD_ALIGN_PARAGRAPH
6
  from PIL import Image
7
  import pillow_heif
 
10
  import subprocess
11
  from docx.oxml import OxmlElement
12
  from docx.oxml.ns import qn
13
+ from datetime import datetime
14
 
15
  # Register the HEIC opener
16
  pillow_heif.register_heif_opener()
17
 
18
+ # --- CONSTANTS ---
19
+ CONTENT_WIDTH_IN = 7.5 # Letter width (8.5") - left/right margins (0.5" each)
20
+ DESC_COL_IN = CONTENT_WIDTH_IN * (1 / 5.0) # 1:4 ratio -> 1/5 of total
21
+ PHOTO_COL_IN = CONTENT_WIDTH_IN * (4 / 5.0) # 1:4 ratio -> 4/5 of total
22
+ ROW_HEIGHT_IN = 4.6 # Exact row height; two rows per page
23
+ PHOTO_MAX_W_IN = PHOTO_COL_IN - 0.2 # Slight padding
24
+ PHOTO_MAX_H_IN = ROW_HEIGHT_IN - 0.2
25
+
26
+ JOB_MAP = {
27
+ "WM 3155 SSM Parking": "T250014 Walmart 3155 Sault Ste. Marie FY26 Parking Lot Refresh\n446 Great Northern Rd.\nSault Ste. Marie, Ontario, P6B 4Z9",
28
+ "GSDR GSN CC": "T250030 GSDR - Guru Nanak Sewa Community Centre\n1410 Stevenson Rd N\nOshawa, Ontario, L1L 0N6"
29
+ }
30
+
31
  # --- IMAGE PROCESSING ---
32
+ def process_image(uploaded_file, compress=False, quality=70, rotation=0):
33
+ """Opens, rotates, processes, and optionally compresses an image. Returns a BytesIO stream."""
34
  try:
35
  uploaded_file.seek(0)
36
  img = Image.open(uploaded_file)
37
+
38
+ # Apply rotation (Pillow rotates counterclockwise)
39
+ if rotation:
40
+ img = img.rotate(rotation, expand=True)
41
+
42
+ # Normalize mode
43
  if img.mode == 'RGBA':
44
  background = Image.new('RGB', img.size, (255, 255, 255))
45
  background.paste(img, mask=img.split()[3])
46
  img = background
47
  elif img.mode != 'RGB':
48
  img = img.convert('RGB')
49
+
50
  img_io = io.BytesIO()
51
  save_format = 'JPEG' if compress else 'PNG'
52
  if compress:
53
+ img.save(img_io, format=save_format, optimize=True, quality=int(quality))
54
  else:
55
+ img.save(img_io, format=save_format)
56
  img_io.seek(0)
57
  return img_io
58
  except Exception as e:
59
+ st.error(f"Error processing image '{getattr(uploaded_file, 'name', 'Unknown')}': {e}")
60
+ return None
61
+
62
+ def get_rotated_preview(uploaded_file, rotation=0):
63
+ """Returns a PIL Image for preview with rotation applied."""
64
+ try:
65
+ uploaded_file.seek(0)
66
+ img = Image.open(uploaded_file)
67
+ if rotation:
68
+ img = img.rotate(rotation, expand=True)
69
+ # Convert for display if needed
70
+ if img.mode not in ('RGB', 'RGBA'):
71
+ img = img.convert('RGB')
72
+ return img
73
+ except Exception as e:
74
+ st.error(f"Preview error: {e}")
75
  return None
76
 
77
  def add_page_number(paragraph):
78
  """Adds Page X of Y page numbering to a footer paragraph."""
79
  paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
80
+ # Add "Page "
 
81
  page_run = paragraph.add_run("Page ")
82
+ # Current page
 
83
  page_num_run = paragraph.add_run()
84
  fldChar1 = OxmlElement('w:fldChar')
85
  fldChar1.set(qn('w:fldCharType'), 'begin')
86
  page_num_run._r.append(fldChar1)
87
+
88
  instr = OxmlElement('w:instrText')
89
  instr.set(qn('xml:space'), 'preserve')
90
  instr.text = "PAGE"
 
94
  fldChar2.set(qn('w:fldCharType'), 'end')
95
  page_num_run._r.append(fldChar2)
96
 
97
+ # " of "
98
  of_run = paragraph.add_run(" of ")
99
 
100
+ # Total pages
101
  num_pages_run = paragraph.add_run()
102
  fldChar3 = OxmlElement('w:fldChar')
103
  fldChar3.set(qn('w:fldCharType'), 'begin')
 
114
 
115
 
116
  # --- CORE REPORT GENERATION LOGIC ---
117
+ def generate_report(image_items, job_details, compress_images, quality, progress_bar):
118
+ """Generates the DOCX and PDF report with a locked 2x2 per-page layout."""
119
  doc = Document()
120
  section = doc.sections[0]
121
  section.left_margin = section.right_margin = Inches(0.5)
122
+ section.top_margin = section.bottom_margin = Inches(0.75) # extra space for footer
123
 
124
  # --- Header Setup ---
125
  header = section.header
126
+ header_table = header.add_table(rows=1, cols=2, width=Inches(CONTENT_WIDTH_IN))
127
  header_table.autofit = False
128
  header_table.columns[0].width = Inches(2.5)
129
+ header_table.columns[1].width = Inches(CONTENT_WIDTH_IN - 2.5)
130
+
131
  logo_cell = header_table.cell(0, 0)
132
  if os.path.exists("logo.png"):
133
  p_logo = logo_cell.paragraphs[0]
134
  p_logo.add_run().add_picture("logo.png", width=Inches(1.5))
135
  p_logo.add_run("\nHKC Construction").font.size = Pt(8)
136
+
137
  info_cell = header_table.cell(0, 1)
138
  p_info = info_cell.paragraphs[0]
139
  p_info.text = job_details
140
  p_info.alignment = WD_ALIGN_PARAGRAPH.RIGHT
141
  info_cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
142
+
143
  # --- Footer Setup ---
144
  footer = section.footer
145
  footer_para = footer.paragraphs[0]
146
  add_page_number(footer_para)
147
 
148
  num_images = len(image_items)
149
+ processed = 0
150
+
151
+ def add_locked_page_table():
152
+ """Create a new 2x2 table with locked column widths and row heights."""
153
+ tbl = doc.add_table(rows=2, cols=2)
154
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
155
+ tbl.autofit = False
156
+
157
+ # Set locked widths
158
+ tbl.columns[0].width = Inches(DESC_COL_IN)
159
+ tbl.columns[1].width = Inches(PHOTO_COL_IN)
160
+ for r in tbl.rows:
161
+ r.height = Inches(ROW_HEIGHT_IN)
162
+ r.height_rule = WD_ROW_HEIGHT_RULE.EXACTLY
163
+ # Enforce widths at cell level too (helps "lock" in some Word versions)
164
+ for r in tbl.rows:
165
+ r.cells[0].width = Inches(DESC_COL_IN)
166
+ r.cells[1].width = Inches(PHOTO_COL_IN)
167
+
168
+ return tbl
169
+
170
+ # Build pages: exactly 2 per page, except last page can have 1
171
+ for start in range(0, num_images, 2):
172
+ if start > 0:
173
  doc.add_page_break()
174
 
175
+ page_items = image_items[start:start+2]
176
+ page_table = add_locked_page_table()
177
+
178
+ for row_idx in range(2):
179
+ desc_cell = page_table.cell(row_idx, 0)
180
+ photo_cell = page_table.cell(row_idx, 1)
181
+
182
+ # Clear default content
183
+ try:
184
+ desc_cell._element.clear_content()
185
+ photo_cell._element.clear_content()
186
+ except Exception:
187
+ # Fallback: remove paragraphs
188
+ for p in list(desc_cell.paragraphs):
189
+ p._p.getparent().remove(p._p)
190
+ for p in list(photo_cell.paragraphs):
191
+ p._p.getparent().remove(p._p)
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
+ if row_idx < len(page_items):
194
+ item = page_items[row_idx]
195
+ # Description
196
+ desc_cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP
197
+ p_desc = desc_cell.add_paragraph()
198
+ run = p_desc.add_run('Description:\n')
199
+ run.bold = True
200
+ p_desc.add_run(item.get('description', '') or '')
201
+
202
+ # Photo
203
+ rotation = item.get('rotation', 0) or 0
204
+
205
+ # Determine fit rule based on aspect ratio after rotation
206
+ try:
207
+ item['file'].seek(0)
208
+ im = Image.open(item['file'])
209
+ if rotation:
210
+ im = im.rotate(rotation, expand=True)
211
+ w_px, h_px = im.size
212
+ except Exception:
213
+ # fallback if any error reading preview dims
214
+ w_px, h_px = (1000, 1000)
215
+
216
+ img_ratio = w_px / h_px if h_px else 1
217
+ allowed_ratio = PHOTO_MAX_W_IN / PHOTO_MAX_H_IN
218
+
219
+ img_stream = process_image(
220
+ item['file'],
221
+ compress=compress_images,
222
+ quality=quality,
223
+ rotation=rotation
224
+ )
225
+
226
+ if img_stream:
227
+ p_photo = photo_cell.add_paragraph()
228
+ # Fit inside the photo cell by width or height
229
+ if img_ratio >= allowed_ratio:
230
+ p_photo.add_run().add_picture(img_stream, width=Inches(PHOTO_MAX_W_IN))
231
+ else:
232
+ p_photo.add_run().add_picture(img_stream, height=Inches(PHOTO_MAX_H_IN))
233
+ p_photo.alignment = WD_ALIGN_PARAGRAPH.CENTER
234
+
235
+ processed += 1
236
+ if num_images:
237
+ progress_bar.progress(processed / num_images)
238
+
239
+ else:
240
+ # No item for this row (last page single photo case) — keep cells empty
241
+ pass
242
 
243
  # --- File Saving and Conversion ---
244
  doc_io = io.BytesIO()
245
  doc.save(doc_io)
246
  doc_io.seek(0)
247
+
248
  pdf_io = None
249
  try:
250
  with open("temp_report.docx", "wb") as f:
251
  f.write(doc_io.getvalue())
252
  subprocess.run(
253
+ ['libreoffice', '--headless', '--convert-to', 'pdf', '--outdir', '.', 'temp_report.docx'],
254
  check=True, timeout=120
255
  )
256
  pdf_path = 'temp_report.pdf'
 
264
 
265
  return doc_io, pdf_io
266
 
267
+
268
  # --- STREAMLIT UI ---
269
 
270
  st.set_page_config(layout="centered", page_title="HKC Report Generator")
 
287
  # --- Sidebar for Settings ---
288
  with st.sidebar:
289
  st.header("1. Job Information")
290
+ job_key = st.selectbox(
291
+ "Select Job",
292
+ options=list(JOB_MAP.keys()),
293
+ index=0,
294
+ help="This sets the header and the download file name."
295
  )
296
+ job_details = JOB_MAP[job_key]
297
+ st.text_area("Job Details", job_details, height=125, disabled=True)
298
+
299
+ st.header("2. Image Settings")
300
+ compress_images = st.checkbox("Compress Images", value=True, help="Reduce final file size.")
301
+ quality = st.slider(
302
+ "Compression intensity (higher = better quality, larger file)",
303
+ min_value=10, max_value=95, value=70, step=5
304
+ )
305
+
306
+ st.header("3. Actions")
307
  generate_button = st.button("Generate Report", type="primary", use_container_width=True)
308
  if st.button("Reset All", use_container_width=True):
309
  reset_app()
310
  st.rerun()
311
 
312
  # --- Main Area ---
313
+ st.header("Upload, Arrange, Rotate, and Describe Images")
314
  uploaded_files = st.file_uploader(
315
  "Upload all your photos here",
316
  accept_multiple_files=True,
317
+ type=["jpg", "jpeg", "png", "heic", "jfif"],
318
  key="file_uploader"
319
  )
320
 
321
+ # Add new files into session state
322
  if uploaded_files:
323
  for file in uploaded_files:
324
+ if getattr(file, "file_id", None) not in st.session_state.image_dict:
325
  current_max_order = max([d.get('order', 0) for d in st.session_state.image_dict.values()], default=0)
326
+ st.session_state.image_dict[getattr(file, "file_id", file.name + str(id(file)))] = {
327
  'file': file,
328
  'description': '',
329
+ 'order': current_max_order + 1,
330
+ 'rotation': 0 # degrees (0, 90, 180, 270)
331
  }
332
+
333
+ if st.session_state.image_dict:
334
+ st.info("Set the report order, rotate if needed, then add descriptions. Exactly 2 photos per page (last page can have 1).")
335
+ # Sort by order for display
336
  sorted_display_items = sorted(st.session_state.image_dict.values(), key=lambda x: x.get('order', 0))
337
 
338
  for item_data in sorted_display_items:
339
+ file_id = getattr(item_data['file'], "file_id", item_data['file'].name + str(id(item_data['file'])))
340
  st.markdown("---")
341
  cols = st.columns([2, 3])
342
+
343
  with cols[0]:
344
+ preview = get_rotated_preview(item_data['file'], item_data.get('rotation', 0))
345
+ if preview is not None:
346
+ st.image(preview, use_column_width=True)
347
+
348
+ rcols = st.columns(2)
349
+ if rcols[0].button("↺ Rotate Left", key=f"rotl_{file_id}"):
350
+ item_data['rotation'] = (item_data.get('rotation', 0) + 90) % 360
351
+ st.rerun()
352
+ if rcols[1].button("↻ Rotate Right", key=f"rotr_{file_id}"):
353
+ item_data['rotation'] = (item_data.get('rotation', 0) - 90) % 360
354
+ st.rerun()
355
 
356
  with cols[1]:
357
+ item_data['order'] = st.number_input(
358
+ "Order", min_value=1, value=item_data.get('order', 1), key=f"order_{file_id}"
359
+ )
360
+ item_data['description'] = st.text_area(
361
+ "Description", value=item_data.get('description', ''), key=f"desc_{file_id}", height=120
362
+ )
363
 
364
  # --- Generation Logic ---
365
  if generate_button:
366
  if not job_details:
367
+ st.error("Please select a job.")
368
  elif not st.session_state.image_dict:
369
  st.error("Please upload at least one image.")
370
  else:
371
  items_to_sort = list(st.session_state.image_dict.values())
372
  sorted_image_items = sorted(items_to_sort, key=lambda x: x.get('order', 0))
373
+
374
  progress_bar = st.progress(0, text="Starting report generation...")
375
  try:
376
+ with st.spinner('Generating your report...'):
377
  doc_io, pdf_io = generate_report(
378
+ sorted_image_items,
379
+ job_details,
380
+ compress_images,
381
+ quality,
382
+ progress_bar
383
  )
384
  progress_bar.success("Report generated successfully!")
385
+
386
+ # File naming per dropdown + yymmdd
387
+ date_str = datetime.now().strftime("%y%m%d")
388
+ file_base = f"{job_key} - {date_str}"
389
+
390
+ st.header("Download Your Report")
391
  dl_col1, dl_col2 = st.columns(2)
392
  dl_col1.download_button(
393
+ "⬇️ Download Word (.docx)", doc_io, f"{file_base}.docx", use_container_width=True
394
  )
395
  if pdf_io:
396
  dl_col2.download_button(
397
+ "⬇️ Download PDF (.pdf)", pdf_io, f"{file_base}.pdf", use_container_width=True
398
  )
399
  except Exception as e:
400
  st.error(f"A critical error occurred: {e}")
401
+ progress_bar.empty()