awacke1 commited on
Commit
688eb59
·
verified ·
1 Parent(s): a7d856a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +156 -369
app.py CHANGED
@@ -5,7 +5,7 @@ import re
5
  import os
6
  import shutil
7
  import fitz # PyMuPDF
8
- from PIL import Image
9
  from collections import defaultdict
10
  import io
11
  from pypdf import PdfWriter
@@ -26,6 +26,7 @@ from functools import partial
26
 
27
  # --- Configuration & Setup ---
28
  CWD = Path.cwd()
 
29
  LAYOUTS = {
30
  "A4 Portrait": {"size": A4},
31
  "A4 Landscape": {"size": landscape(A4)},
@@ -41,192 +42,148 @@ FONT_DIR = CWD # Assumes fonts are in the same directory as the script
41
  # Create necessary directories
42
  OUTPUT_DIR.mkdir(exist_ok=True)
43
  PREVIEW_DIR.mkdir(exist_ok=True)
 
44
 
45
 
46
  # --- Font & Emoji Handling ---
47
-
48
- # Global cache for rendered emoji images to avoid re-rendering the same emoji
49
- EMOJI_IMAGE_CACHE = {}
50
- EMOJI_SVG_CACHE = {} # New cache for the SVG engine
51
- EMOJI_FONT_PATH = None # Will be set in register_local_fonts
52
 
53
  def register_local_fonts():
54
  """Finds and registers all .ttf files from the application's base directory."""
55
- global EMOJI_FONT_PATH
56
  print("--- Font Registration Process Starting ---")
57
  text_font_names = []
58
- emoji_font_name = None
59
 
60
  noto_emoji_path = FONT_DIR / "NotoColorEmoji-Regular.ttf"
61
- if not noto_emoji_path.exists():
62
- print(f"CRITICAL: Color Emoji font not found at {noto_emoji_path}.")
63
- print("Please download 'NotoColorEmoji-Regular.ttf' and place it in the application directory for color emojis to work.")
64
- else:
65
  EMOJI_FONT_PATH = str(noto_emoji_path)
66
-
67
-
 
68
  print(f"Scanning for fonts in: {FONT_DIR.absolute()}")
69
  font_files = list(FONT_DIR.glob("*.ttf"))
70
- print(f"Found {len(font_files)} .ttf files: {[f.name for f in font_files]}")
71
-
72
  for font_path in font_files:
73
  try:
74
  font_name = font_path.stem
 
 
75
  pdfmetrics.registerFont(TTFont(font_name, str(font_path)))
76
- pdfmetrics.registerFont(TTFont(f"{font_name}-Bold", str(font_path)))
77
- pdfmetrics.registerFont(TTFont(f"{font_name}-Italic", str(font_path)))
78
- pdfmetrics.registerFont(TTFont(f"{font_name}-BoldItalic", str(font_path)))
79
- pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=f"{font_name}-Bold", italic=f"{font_name}-Italic", boldItalic=f"{font_name}-BoldItalic")
80
-
81
- if "notocoloremoji-regular" in font_name.lower():
82
- emoji_font_name = font_name
83
- elif "notoemoji" not in font_name.lower():
84
  text_font_names.append(font_name)
85
  except Exception as e:
86
  print(f"Could not register font {font_path.name}: {e}")
87
 
88
  if not text_font_names:
89
- print("WARNING: No text fonts found. Adding 'Helvetica' as a default.")
90
  text_font_names.append('Helvetica')
91
-
92
  print(f"Successfully registered user-selectable fonts: {text_font_names}")
93
- print(f"Emoji font set to: {emoji_font_name}")
94
- print("--- Font Registration Process Finished ---")
95
- return sorted(text_font_names), emoji_font_name
96
 
97
  def render_emoji_as_image(emoji_char, size_pt):
98
- """
99
- Renders a single emoji character as a transparent PNG image in memory using PyMuPDF.
100
- This is the original raster image engine.
101
- """
102
  if not EMOJI_FONT_PATH: return None
103
  if (emoji_char, size_pt) in EMOJI_IMAGE_CACHE: return EMOJI_IMAGE_CACHE[(emoji_char, size_pt)]
104
-
105
  try:
106
- rect = fitz.Rect(0, 0, size_pt * 1.5, size_pt * 1.5)
107
- doc = fitz.open()
108
  page = doc.new_page(width=rect.width, height=rect.height)
109
  page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH)
110
  page.insert_text(fitz.Point(0, size_pt * 1.1), emoji_char, fontname="emoji", fontsize=size_pt)
111
- pix = page.get_pixmap(alpha=True, dpi=300)
112
- doc.close()
113
- img_buffer = io.BytesIO(pix.tobytes("png"))
114
- img_buffer.seek(0)
115
  EMOJI_IMAGE_CACHE[(emoji_char, size_pt)] = img_buffer
116
  return img_buffer
117
- except Exception as e:
118
- print(f"Could not render emoji {emoji_char} as image: {e}")
119
- return None
120
 
121
  def render_emoji_as_svg(emoji_char, size_pt):
122
- """
123
- Renders a single emoji character as an SVG drawing object.
124
- This is the new vector graphics engine.
125
- """
126
  if not EMOJI_FONT_PATH: return None
127
  if (emoji_char, size_pt) in EMOJI_SVG_CACHE: return EMOJI_SVG_CACHE[(emoji_char, size_pt)]
128
-
129
  try:
130
- doc = fitz.open()
131
- page = doc.new_page(width=1000, height=1000) # Large canvas
132
  page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH)
133
-
134
- # Get the SVG image for the character
135
  svg_image = page.get_svg_image(page.rect, text=emoji_char, fontname="emoji")
136
  doc.close()
137
-
138
- # Convert the SVG XML string to a ReportLab Graphic
139
  drawing = svg2rlg(io.BytesIO(svg_image.encode('utf-8')))
140
-
141
- # Scale the drawing to match the font size
142
  scale_factor = (size_pt * 1.2) / drawing.height
143
- drawing.width *= scale_factor
144
- drawing.height *= scale_factor
145
  drawing.scale(scale_factor, scale_factor)
146
-
147
  EMOJI_SVG_CACHE[(emoji_char, size_pt)] = drawing
148
  return drawing
149
- except Exception as e:
150
- print(f"Could not render emoji {emoji_char} as SVG: {e}")
151
- return None
152
-
153
-
154
- # --- AI Content Generation (Simulation) ---
155
-
156
- def generate_ai_content_api(prompt):
157
- """
158
- Simulates a call to an LLM to generate markdown content.
159
- """
160
- if not prompt:
161
- return "# The Golem awaits your command!\n\nPlease enter a prompt in the box above and click '🧠 Animate Golem!' to get started. I can help you write reports, stories, poems, and more! ✨"
162
-
163
- import time
164
- time.sleep(1.5)
165
-
166
- sample_story = f"""
167
- # The Quest for the Sunstone ☀️
168
-
169
- Long ago, in the mystical land of Aerthos, a creeping shadow began to fall across the valleys. The once-vibrant flowers 🌸 drooped, and the rivers ran slow. The elders knew the cause: the light of the Sunstone, hidden deep within Mount Cinder, was fading.
170
-
171
- ## The Prophecy 📜
172
-
173
- An ancient prophecy spoke of a hero who would rekindle the stone. It read:
174
- > When darkness drapes the verdant ground,
175
- > A soul of courage shall be found.
176
- > Through trials of fire 🔥, wit, and might,
177
- > They'll bring once more the sacred light.
178
-
179
- ## The Chosen One 🦸‍♀️
180
-
181
- A young woman named Elara, known for her kindness and bravery, was chosen. She accepted the quest, her only companions a loyal wolf 🐺 and a map gifted by the village shaman.
182
-
183
- ### The Journey Begins
184
- Elara's journey was fraught with peril. She navigated enchanted forests and crossed treacherous chasms.
185
-
186
- | Trial | Location | Challenge |
187
- |------------------|-------------------|----------------------------------------|
188
- | The Whispering | The Gloomwood | Resist maddening whispers of despair |
189
- | The Riddle of | Sphinx Gate | Answer three impossible questions |
190
- | The Fiery Path | The Magma Caverns | Walk barefoot across burning embers |
191
-
192
- Finally, she reached the heart of the mountain. There, resting on a pedestal, was the dim Sunstone. Pouring her own hope and courage into it, the stone blazed with renewed life, banishing the shadows from Aerthos forever. The people rejoiced, and Elara became a legend.
193
- """
194
- return f"# Golem's Vision for: '{prompt}'\n\n{sample_story}"
195
-
196
-
197
- # --- PDF Generation & Handling ---
198
-
199
- def _draw_header_footer(canvas, doc, header_text, footer_text, title):
200
- """Draws the header and footer on each page."""
201
- canvas.saveState()
202
- page_num = canvas.getPageNumber()
203
 
204
- final_footer_text = footer_text.replace("[Page #]", str(page_num)).replace("[Total Pages]", str(doc.page))
205
- final_header_text = header_text.replace("[Page #]", str(page_num)).replace("[Title]", title)
206
-
207
- if final_header_text:
208
- canvas.setFont('Helvetica', 9)
209
- canvas.setFillColor(colors.grey)
210
- canvas.drawRightString(doc.width + doc.leftMargin, doc.height + doc.topMargin + 0.25*inch, final_header_text)
211
-
212
- if final_footer_text:
213
- canvas.setFont('Helvetica', 9)
214
- canvas.setFillColor(colors.grey)
215
- canvas.drawString(doc.leftMargin, doc.bottomMargin - 0.25*inch, final_footer_text)
216
 
217
- canvas.restoreState()
218
-
219
- def markdown_to_story(markdown_text: str, font_name: str, emoji_font: str, font_size_body: int, font_size_h1: int, font_size_h2: int, font_size_h3: int, use_svg_engine: bool):
220
- """Converts markdown to a ReportLab story, with a switch for emoji rendering engines."""
221
- styles = getSampleStyleSheet()
 
222
 
223
- leading_body = font_size_body * 1.4
224
- style_normal = ParagraphStyle('BodyText', fontName=font_name, fontSize=font_size_body, leading=leading_body, spaceAfter=6)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  style_h1 = ParagraphStyle('h1', parent=styles['h1'], fontName=font_name, fontSize=font_size_h1, leading=font_size_h1*1.2, spaceBefore=12, textColor=colors.HexColor("#a855f7"))
226
  style_h2 = ParagraphStyle('h2', parent=styles['h2'], fontName=font_name, fontSize=font_size_h2, leading=font_size_h2*1.2, spaceBefore=10, textColor=colors.HexColor("#6366f1"))
227
  style_h3 = ParagraphStyle('h3', parent=styles['h3'], fontName=font_name, fontSize=font_size_h3, leading=font_size_h3*1.2, spaceBefore=8, textColor=colors.HexColor("#3b82f6"))
228
  style_code = ParagraphStyle('Code', fontName='Courier', backColor=colors.HexColor("#333333"), textColor=colors.HexColor("#f472b6"), borderWidth=1, borderColor=colors.HexColor("#444444"), padding=8, leading=12, fontSize=9)
229
- style_table_header = ParagraphStyle('TableHeader', parent=style_normal, fontName=f"{font_name}-Bold" if font_name != 'Helvetica' else 'Helvetica-Bold')
230
 
231
  emoji_pattern = re.compile(f"([{re.escape(''.join(map(chr, range(0x1f600, 0x1f650))))}"
232
  f"{re.escape(''.join(map(chr, range(0x1f300, 0x1f5ff))))}"
@@ -235,303 +192,133 @@ def markdown_to_story(markdown_text: str, font_name: str, emoji_font: str, font_
235
  f"{re.escape(''.join(map(chr, range(0x2700, 0x27bf))))}]+)")
236
 
237
  def create_flowables_for_line(text, style):
238
- """Splits a line of text into text and emoji flowables, returning a list."""
239
- parts = emoji_pattern.split(text)
240
- flowables = []
241
  for part in parts:
242
  if not part: continue
243
  if emoji_pattern.match(part):
244
  for emoji_char in part:
245
- emoji_flowable = None
246
- if use_svg_engine:
247
- emoji_flowable = render_emoji_as_svg(emoji_char, style.fontSize)
248
- else:
249
- img_buffer = render_emoji_as_image(emoji_char, style.fontSize)
250
- if img_buffer:
251
- emoji_flowable = ReportLabImage(img_buffer, height=style.fontSize * 1.2, width=style.fontSize * 1.2)
252
-
253
- if emoji_flowable:
254
- flowables.append(emoji_flowable)
255
  else:
256
  formatted_part = re.sub(r'_(.*?)_', r'<i>\1</i>', re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', part))
257
  flowables.append(Paragraph(formatted_part, style))
258
-
259
  if not flowables: return [Spacer(0, 0)]
260
  table = Table([flowables], colWidths=[None]*len(flowables))
261
  table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)]))
262
  return [table]
263
 
264
- story = []
265
- lines = markdown_text.split('\n')
266
-
267
- in_code_block, in_table = False, False
268
  code_block_text, table_data = "", []
269
- first_heading = True
270
- document_title = "Untitled Document"
271
 
272
  for line in lines:
273
  stripped_line = line.strip()
274
-
275
  if stripped_line.startswith("```"):
276
  if in_code_block:
277
  escaped_code = code_block_text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
278
- story.append(Paragraph(escaped_code.replace('\n', '<br/>'), style_code))
279
- story.append(Spacer(1, 0.1 * inch))
280
  in_code_block, code_block_text = False, ""
281
  else: in_code_block = True
282
  continue
283
- if in_code_block:
284
- code_block_text += line + '\n'; continue
285
-
286
  if stripped_line.startswith('|'):
287
  if not in_table: in_table = True
288
  if all(c in '-|: ' for c in stripped_line): continue
289
- cells = [cell.strip() for cell in stripped_line.strip('|').split('|')]
290
- table_data.append(cells)
291
  continue
292
  if in_table:
293
  in_table = False
294
  if table_data:
295
- processed_table_data = [[create_flowables_for_line(cell, style_normal)[0] for cell in row] for row in table_data]
296
- header_row = [create_flowables_for_line(cell, style_table_header)[0] for cell in table_data[0]]
297
- final_table_data = [header_row] + processed_table_data[1:]
298
-
299
- table = Table(final_table_data, hAlign='LEFT', repeatRows=1)
300
- table.setStyle(TableStyle([
301
- ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#4a044e")),
302
- ('GRID', (0, 0), (-1, -1), 1, colors.HexColor("#6b21a8")),
303
- ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
304
- ('TOPPADDING', (0,0), (-1,-1), 6), ('BOTTOMPADDING', (0,0), (-1,-1), 6),
305
- ]))
306
- story.append(table); story.append(Spacer(1, 0.2 * inch))
307
  table_data = []
308
  continue
309
 
310
  if not stripped_line: continue
311
-
312
  content, style, bullet_text = stripped_line, style_normal, None
313
-
314
  if stripped_line.startswith("# "):
315
  if not first_heading: story.append(PageBreak())
316
- content, style = stripped_line.lstrip('# '), style_h1
317
- if first_heading: document_title = content
318
- first_heading = False
319
  elif stripped_line.startswith("## "): content, style = stripped_line.lstrip('## '), style_h2
320
  elif stripped_line.startswith("### "): content, style = stripped_line.lstrip('### '), style_h3
321
- elif stripped_line.startswith(("- ", "* ")):
322
- content = stripped_line[2:]
323
- bullet_text = '• '
324
 
325
  line_flowables = create_flowables_for_line(content, style)
326
-
327
  if bullet_text:
328
- bullet_p = Paragraph(bullet_text, style)
329
- # Adjust colWidths to give bullet a fixed space
330
- list_item_table = Table([[bullet_p] + line_flowables], colWidths=[style.fontSize*1.5] + [None]*len(line_flowables))
331
  list_item_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'TOP'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)]))
332
  story.append(list_item_table)
333
- else:
334
- story.extend(line_flowables)
335
-
336
  return story, document_title
337
 
338
- def create_pdf_preview(pdf_path: Path):
339
- """Generates a PNG preview of the first page of a PDF."""
340
- preview_path = PREVIEW_DIR / f"{pdf_path.stem}.png"
341
- try:
342
- doc = fitz.open(pdf_path)
343
- page = doc.load_page(0)
344
- pix = page.get_pixmap(dpi=150)
345
- pix.save(str(preview_path))
346
- doc.close()
347
- return str(preview_path)
348
- except Exception as e:
349
- print(f"Could not create preview for {pdf_path.name}: {e}")
350
- try:
351
- img = Image.new('RGB', (400, 500), color = '#111827')
352
- img.save(str(preview_path))
353
- return str(preview_path)
354
- except: return None
355
-
356
- # --- Main API Function ---
357
- def generate_pdfs_api(files, ai_content, layouts, fonts, num_columns, header_text, footer_text, font_size_body, font_size_h1, font_size_h2, font_size_h3, margin_top, margin_bottom, margin_left, margin_right, use_svg_engine, progress=gr.Progress(track_tqdm=True)):
358
  if not files and (not ai_content or "Golem awaits" in ai_content): raise gr.Error("Please conjure some content or upload a file before alchemizing!")
359
  if not layouts: raise gr.Error("You must select a scroll (page layout)!")
360
  if not fonts: raise gr.Error("A scribe needs a font! Please choose one.")
361
- if not EMOJI_FONT_PATH: raise gr.Error("CRITICAL: Cannot generate PDFs. 'NotoColorEmoji-Regular.ttf' not found. Please add it to the app directory.")
362
-
363
- shutil.rmtree(OUTPUT_DIR, ignore_errors=True); shutil.rmtree(PREVIEW_DIR, ignore_errors=True)
364
- OUTPUT_DIR.mkdir(); PREVIEW_DIR.mkdir()
365
 
 
 
 
366
  image_files, pdf_files, txt_files = [], [], []
367
  if files:
368
  for f in files:
369
- file_path = Path(f.name)
370
- ext = file_path.suffix.lower()
371
- if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff']: image_files.append(file_path)
372
- elif ext == '.pdf': pdf_files.append(file_path)
373
- elif ext == '.txt': txt_files.append(file_path)
374
-
375
- log_updates = ""
376
- all_text_content = []
377
- if ai_content and "Golem awaits" not in ai_content: all_text_content.append(ai_content)
378
-
379
- for txt_path in txt_files:
380
- try:
381
- all_text_content.append(txt_path.read_text(encoding='utf-8'))
382
- except Exception as e:
383
- log_updates += f"⚠️ Failed to read text file {txt_path.name}: {e}\n"
384
-
385
- md_content = "\n\n---\n\n".join(all_text_content)
386
-
387
- generated_pdf_paths = []
388
 
 
389
  EMOJI_IMAGE_CACHE.clear(); EMOJI_SVG_CACHE.clear()
390
 
391
- for layout_name in progress.tqdm(layouts, desc=" brewing potions..."):
392
- for font_name in progress.tqdm(fonts, desc=f" enchanting scrolls with {layout_name}..."):
393
  merger = PdfWriter()
394
-
395
  if md_content:
396
- md_buffer = io.BytesIO()
397
- story, title = markdown_to_story(md_content, font_name, EMOJI_FONT_NAME, font_size_body, font_size_h1, font_size_h2, font_size_h3, use_svg_engine)
398
- pagesize = LAYOUTS[layout_name]["size"]
399
-
400
- doc = BaseDocTemplate(md_buffer, pagesize=pagesize, leftMargin=margin_left*inch, rightMargin=margin_right*inch, topMargin=margin_top*inch, bottomMargin=margin_bottom*inch)
401
-
402
- frame_width = (doc.width / num_columns) - (num_columns - 1) * 0.1*inch
403
- frames = [Frame(doc.leftMargin + i * (frame_width + 0.2*inch), doc.bottomMargin, frame_width, doc.height, id=f'col_{i}') for i in range(num_columns)]
404
-
405
- header_footer_callback = partial(_draw_header_footer, header_text=header_text, footer_text=footer_text, title=title)
406
- page_template = PageTemplate(id='main_template', frames=frames, onPage=header_footer_callback)
407
- doc.addPageTemplates([page_template])
408
-
409
- doc.build(story)
410
- md_buffer.seek(0)
411
- merger.append(fileobj=md_buffer)
412
-
413
- for img_path in image_files:
414
- try:
415
- with Image.open(img_path) as img: img_width_px, img_height_px = img.size
416
- img_width_pt, img_height_pt = img_width_px * (inch / 72), img_height_px * (inch / 72)
417
- img_buffer = io.BytesIO()
418
- img_doc = SimpleDocTemplate(img_buffer, pagesize=(img_width_pt, img_height_pt), leftMargin=0, rightMargin=0, topMargin=0, bottomMargin=0)
419
- img_doc.build([ReportLabImage(img_path, width=img_width_pt, height=img_height_pt)])
420
- img_buffer.seek(0)
421
- merger.append(fileobj=img_buffer)
422
- except Exception as e: log_updates += f"⚠️ Failed to process image {img_path.name}: {e}\n"
423
-
424
- for pdf_path in pdf_files:
425
  try:
426
- merger.append(str(pdf_path))
427
- except Exception as e: log_updates += f"⚠️ Failed to merge PDF {pdf_path.name}: {e}\n"
428
-
 
 
 
 
 
 
 
429
  if len(merger.pages) > 0:
430
- time_str = datetime.datetime.now().strftime('%H%M%S')
431
- clean_layout = layout_name.replace(' ', '')
432
- filename = f"Scroll_{clean_layout}_{font_name}_x{num_columns}_{time_str}.pdf"
433
- output_path = OUTPUT_DIR / filename
434
- with open(output_path, "wb") as f: merger.write(f)
435
- generated_pdf_paths.append(output_path)
436
- log_updates += f"✅ Successfully alchemized: {filename}\n"
437
-
438
- gallery_previews = [create_pdf_preview(p) for p in generated_pdf_paths]
439
- final_gallery = [g for g in gallery_previews if g is not None]
440
 
441
- return final_gallery, log_updates if log_updates else "✨ All scrolls alchemized successfully! ✨", [str(p) for p in generated_pdf_paths]
 
442
 
443
  # --- Gradio UI Definition ---
444
- AVAILABLE_FONTS, EMOJI_FONT_NAME = register_local_fonts()
445
 
446
  def get_theme():
447
- """Dynamically selects a font for the theme to avoid warnings for missing fonts."""
448
- desired_font = "MedievalSharp"
449
- font_family = None
450
- if any(desired_font in s for s in AVAILABLE_FONTS):
451
- font_family = (gr.themes.GoogleFont(desired_font), "ui-sans-serif", "system-ui", "sans-serif")
452
- elif AVAILABLE_FONTS:
453
- first_font = AVAILABLE_FONTS[0]
454
- print(f"WARNING: '{desired_font}' font not found. Using '{first_font}' for UI theme instead.")
455
- font_family = (gr.themes.GoogleFont(first_font), "ui-sans-serif", "system-ui", "sans-serif")
456
- else:
457
- print(f"WARNING: '{desired_font}' font not found and no other fonts available. Using system default.")
458
- font_family = ("ui-sans-serif", "system-ui", "sans-serif")
459
-
460
- return gr.themes.Base(primary_hue=gr.themes.colors.purple, secondary_hue=gr.themes.colors.indigo, neutral_hue=gr.themes.colors.gray, font=font_family).set(
461
- body_background_fill="#111827", body_text_color="#d1d5db", button_primary_background_fill="#a855f7", button_primary_text_color="#ffffff",
462
- button_secondary_background_fill="#6366f1", button_secondary_text_color="#ffffff", block_background_fill="#1f2937",
463
- block_label_background_fill="#1f2937", block_title_text_color="#a855f7", input_background_fill="#374151"
464
- )
465
-
466
- with gr.Blocks(theme=get_theme(), title="The PDF Alchemist") as demo:
467
- gr.Markdown("# ✨ The PDF Alchemist ✨")
468
- gr.Markdown("A single-page grimoire to turn your ideas into beautifully crafted PDF scrolls. Use the power of AI or upload your own treasures.")
469
-
470
- with gr.Row(equal_height=False):
471
- with gr.Column(scale=2):
472
- # --- Accordion Groups for Inputs ---
473
- with gr.Accordion("📜 Content Crucible (Your Ingredients)", open=True):
474
- gr.Markdown("### 🤖 Command Your Idea Golem")
475
- ai_prompt = gr.Textbox(label="Incantation (Prompt)", placeholder="e.g., 'A recipe for a dragon's breath chili...'")
476
- generate_ai_btn = gr.Button("🧠 Animate Golem!")
477
- ai_content_output = gr.Textbox(label="Golem's Manuscript (Editable)", lines=10, interactive=True, value="# The Golem awaits your command!\n\n")
478
- gr.Markdown("<hr style='border-color: #374151; margin-top: 20px; margin-bottom: 20px;'>")
479
- gr.Markdown("### 📤 Add Your Physical Treasures")
480
- uploaded_files = gr.File(label="Upload Files (Images, PDFs, TXT)", file_count="multiple", file_types=['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.pdf', '.txt'])
481
-
482
- with gr.Accordion("📐 Arcane Blueprints (Layout & Structure)", open=True):
483
- gr.Markdown("### Page & Column Spells")
484
- selected_layouts = gr.CheckboxGroup(choices=list(LAYOUTS.keys()), label="Page Layouts", value=["A4 Portrait"])
485
- num_columns_slider = gr.Slider(label="Number of Text Columns", minimum=1, maximum=4, step=1, value=1)
486
- gr.Markdown("<hr style='border-color: #374151; margin-top: 20px; margin-bottom: 20px;'>")
487
- gr.Markdown("### Header & Footer Runes")
488
- header_input = gr.Textbox(label="Header Inscription", value="[Title]", placeholder="e.g., Arcane Folio - [Page #]")
489
- footer_input = gr.Textbox(label="Footer Inscription", value="Page [Page #] of [Total Pages]", placeholder="e.g., Top Secret - Page [Page #]")
490
-
491
- with gr.Accordion("💅 Stylist's Sanctum (Fonts & Margins)", open=True):
492
- gr.Markdown("### ⚙️ Alternative Emoji Engine")
493
- use_svg_engine_toggle = gr.Checkbox(label="Use SVG Emoji Engine (Vector Quality)", value=True)
494
- gr.Markdown("*<p style='font-size:0.8rem; color: #9ca3af;'>Toggle for higher quality, resolution-independent emojis. May be slower.</p>*")
495
- gr.Markdown("<hr style='border-color: #374151; margin-top: 20px; margin-bottom: 20px;'>")
496
- gr.Markdown("### 🔤 Master the Glyphs")
497
- selected_fonts = gr.CheckboxGroup(choices=AVAILABLE_FONTS, label="Fonts", value=[AVAILABLE_FONTS[0]] if AVAILABLE_FONTS else [])
498
- with gr.Row():
499
- font_size_body_slider = gr.Slider(label="Body (pt)", minimum=8, maximum=16, step=1, value=10)
500
- font_size_h1_slider = gr.Slider(label="H1 (pt)", minimum=16, maximum=32, step=1, value=24)
501
- with gr.Row():
502
- font_size_h2_slider = gr.Slider(label="H2 (pt)", minimum=14, maximum=28, step=1, value=18)
503
- font_size_h3_slider = gr.Slider(label="H3 (pt)", minimum=12, maximum=24, step=1, value=14)
504
- gr.Markdown("<hr style='border-color: #374151; margin-top: 20px; margin-bottom: 20px;'>")
505
- gr.Markdown("### 📏 Set Your Boundaries (inches)")
506
- with gr.Row():
507
- margin_top_slider = gr.Slider(label="Top", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
508
- margin_bottom_slider = gr.Slider(label="Bottom", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
509
- with gr.Row():
510
- margin_left_slider = gr.Slider(label="Left", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
511
- margin_right_slider = gr.Slider(label="Right", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
512
-
513
- generate_pdfs_btn = gr.Button("🔮 Alchemize PDF!", variant="primary", size="lg")
514
-
515
- with gr.Column(scale=3):
516
- gr.Markdown("### 🖼️ The Scrying Pool (Previews)")
517
- gallery_output = gr.Gallery(label="Generated PDF Previews", show_label=False, elem_id="gallery", columns=2, height=700, object_fit="contain")
518
- log_output = gr.Markdown(label="Alchemist's Log", value="Your log of successful transmutations will appear here...")
519
- downloadable_files_output = gr.Files(label="Collect Your Scrolls")
520
-
521
- # --- API Calls & Examples ---
522
- generate_ai_btn.click(fn=generate_ai_content_api, inputs=[ai_prompt], outputs=[ai_content_output])
523
-
524
- inputs_list = [uploaded_files, ai_content_output, selected_layouts, selected_fonts, num_columns_slider, header_input, footer_input, font_size_body_slider, font_size_h1_slider, font_size_h2_slider, font_size_h3_slider, margin_top_slider, margin_bottom_slider, margin_left_slider, margin_right_slider, use_svg_engine_toggle]
525
- outputs_list = [gallery_output, log_output, downloadable_files_output]
526
- generate_pdfs_btn.click(fn=generate_pdfs_api, inputs=inputs_list, outputs=outputs_list)
527
-
528
- gr.Examples(examples=[["A technical summary of how alchemy works"], ["A short poem about a grumpy gnome"],["A sample agenda for a wizard's council meeting"]], inputs=[ai_prompt], outputs=[ai_content_output], fn=generate_ai_content_api, cache_examples=False)
529
-
530
- if __name__ == "__main__":
531
- if not (FONT_DIR / "NotoColorEmoji-Regular.ttf").exists():
532
- print("\n" + "="*80)
533
- print("CRITICAL WARNING: 'NotoColorEmoji-Regular.ttf' not found.")
534
- print("The application will fail to generate PDFs without it.")
535
- print("="*80 + "\n")
536
-
537
- demo.launch(debug=True)
 
5
  import os
6
  import shutil
7
  import fitz # PyMuPDF
8
+ from PIL import Image, ImageDraw, ImageFont
9
  from collections import defaultdict
10
  import io
11
  from pypdf import PdfWriter
 
26
 
27
  # --- Configuration & Setup ---
28
  CWD = Path.cwd()
29
+ TEMP_DIR = CWD / "temp_icons"
30
  LAYOUTS = {
31
  "A4 Portrait": {"size": A4},
32
  "A4 Landscape": {"size": landscape(A4)},
 
42
  # Create necessary directories
43
  OUTPUT_DIR.mkdir(exist_ok=True)
44
  PREVIEW_DIR.mkdir(exist_ok=True)
45
+ TEMP_DIR.mkdir(exist_ok=True)
46
 
47
 
48
  # --- Font & Emoji Handling ---
49
+ EMOJI_IMAGE_CACHE, EMOJI_SVG_CACHE = {}, {}
50
+ EMOJI_FONT_PATH, UI_FONT_PATH = None, None
 
 
 
51
 
52
  def register_local_fonts():
53
  """Finds and registers all .ttf files from the application's base directory."""
54
+ global EMOJI_FONT_PATH, UI_FONT_PATH
55
  print("--- Font Registration Process Starting ---")
56
  text_font_names = []
 
57
 
58
  noto_emoji_path = FONT_DIR / "NotoColorEmoji-Regular.ttf"
59
+ if noto_emoji_path.exists():
 
 
 
60
  EMOJI_FONT_PATH = str(noto_emoji_path)
61
+ else:
62
+ print("CRITICAL: 'NotoColorEmoji-Regular.ttf' not found. Color emojis will not work.")
63
+
64
  print(f"Scanning for fonts in: {FONT_DIR.absolute()}")
65
  font_files = list(FONT_DIR.glob("*.ttf"))
66
+
 
67
  for font_path in font_files:
68
  try:
69
  font_name = font_path.stem
70
+ if not UI_FONT_PATH: UI_FONT_PATH = str(font_path) # Grab first available font for UI icons
71
+
72
  pdfmetrics.registerFont(TTFont(font_name, str(font_path)))
73
+ pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=font_name, italic=font_name, boldItalic=font_name)
74
+
75
+ if "notocoloremoji-regular" not in font_name.lower() and "notoemoji" not in font_name.lower():
 
 
 
 
 
76
  text_font_names.append(font_name)
77
  except Exception as e:
78
  print(f"Could not register font {font_path.name}: {e}")
79
 
80
  if not text_font_names:
 
81
  text_font_names.append('Helvetica')
 
82
  print(f"Successfully registered user-selectable fonts: {text_font_names}")
83
+ return sorted(text_font_names)
 
 
84
 
85
  def render_emoji_as_image(emoji_char, size_pt):
 
 
 
 
86
  if not EMOJI_FONT_PATH: return None
87
  if (emoji_char, size_pt) in EMOJI_IMAGE_CACHE: return EMOJI_IMAGE_CACHE[(emoji_char, size_pt)]
 
88
  try:
89
+ rect, doc = fitz.Rect(0, 0, size_pt*1.5, size_pt*1.5), fitz.open()
 
90
  page = doc.new_page(width=rect.width, height=rect.height)
91
  page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH)
92
  page.insert_text(fitz.Point(0, size_pt * 1.1), emoji_char, fontname="emoji", fontsize=size_pt)
93
+ pix, img_buffer = page.get_pixmap(alpha=True, dpi=300), io.BytesIO(pix.tobytes("png"))
94
+ doc.close(); img_buffer.seek(0)
 
 
95
  EMOJI_IMAGE_CACHE[(emoji_char, size_pt)] = img_buffer
96
  return img_buffer
97
+ except Exception as e: print(f"Could not render emoji {emoji_char} as image: {e}"); return None
 
 
98
 
99
  def render_emoji_as_svg(emoji_char, size_pt):
 
 
 
 
100
  if not EMOJI_FONT_PATH: return None
101
  if (emoji_char, size_pt) in EMOJI_SVG_CACHE: return EMOJI_SVG_CACHE[(emoji_char, size_pt)]
 
102
  try:
103
+ doc = fitz.open(); page = doc.new_page(width=1000, height=1000)
 
104
  page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH)
 
 
105
  svg_image = page.get_svg_image(page.rect, text=emoji_char, fontname="emoji")
106
  doc.close()
 
 
107
  drawing = svg2rlg(io.BytesIO(svg_image.encode('utf-8')))
 
 
108
  scale_factor = (size_pt * 1.2) / drawing.height
109
+ drawing.width, drawing.height = drawing.width*scale_factor, drawing.height*scale_factor
 
110
  drawing.scale(scale_factor, scale_factor)
 
111
  EMOJI_SVG_CACHE[(emoji_char, size_pt)] = drawing
112
  return drawing
113
+ except Exception as e: print(f"Could not render emoji {emoji_char} as SVG: {e}"); return None
114
+
115
+ # --- UI & File Handling ---
116
+ FILE_ICONS = defaultdict(lambda: '❓', {
117
+ '.md': '📜', '.txt': '📄', '.py': '🐍', '.js': '⚡', '.html': '🌐', '.css': '🎨', '.json': '🔮',
118
+ '.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.gif': '🖼️', '.bmp': '🖼️', '.tiff': '🖼️',
119
+ '.pdf': '📚'
120
+ })
121
+
122
+ def create_file_icon_image(file_path: Path):
123
+ """Creates a preview image for a file with an icon and its name."""
124
+ icon = FILE_ICONS[file_path.suffix.lower()]
125
+ img = Image.new('RGB', (150, 100), color='#1f2937')
126
+ draw = ImageDraw.Draw(img)
127
+ try:
128
+ icon_font = ImageFont.truetype(EMOJI_FONT_PATH, 40) if EMOJI_FONT_PATH else ImageFont.load_default()
129
+ text_font = ImageFont.truetype(UI_FONT_PATH, 10) if UI_FONT_PATH else ImageFont.load_default()
130
+ except:
131
+ icon_font, text_font = ImageFont.load_default(), ImageFont.load_default()
132
+
133
+ draw.text((75, 40), icon, font=icon_font, anchor="mm", fill="#a855f7")
134
+ draw.text((75, 80), file_path.name, font=text_font, anchor="mm", fill="#d1d5db")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
+ out_path = TEMP_DIR / f"{file_path.stem}_{file_path.suffix[1:]}.png"
137
+ img.save(out_path)
138
+ return out_path
 
 
 
 
 
 
 
 
 
139
 
140
+ def update_staging_and_manuscript(files, current_manuscript):
141
+ """Processes uploaded files, updates staging galleries, and consolidates text content."""
142
+ shutil.rmtree(TEMP_DIR, ignore_errors=True); TEMP_DIR.mkdir(exist_ok=True)
143
+
144
+ text_gallery, image_gallery, pdf_gallery = [], [], []
145
+ text_content_to_add = []
146
 
147
+ sorted_files = sorted(files, key=lambda x: Path(x.name).suffix)
148
+
149
+ for file_obj in sorted_files:
150
+ file_path = Path(file_obj.name)
151
+ ext = file_path.suffix.lower()
152
+
153
+ if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff']:
154
+ image_gallery.append((file_path, file_path.name))
155
+ elif ext == '.pdf':
156
+ pdf_gallery.append((create_file_icon_image(file_path), file_path.name))
157
+ else: # Treat everything else as a text file
158
+ text_gallery.append((create_file_icon_image(file_path), file_path.name))
159
+ try:
160
+ with open(file_path, 'r', encoding='utf-8') as f:
161
+ text_content_to_add.append(f.read())
162
+ except Exception as e:
163
+ print(f"Could not read {file_path.name}: {e}")
164
+
165
+ # Update manuscript
166
+ updated_manuscript = current_manuscript
167
+ if "Golem awaits" in updated_manuscript:
168
+ updated_manuscript = "" # Clear placeholder if it exists
169
+
170
+ if text_content_to_add:
171
+ separator = "\n\n---\n\n"
172
+ if not updated_manuscript.strip(): separator = "" # Don't add separator if manuscript is empty
173
+ updated_manuscript += separator + "\n\n".join(text_content_to_add)
174
+
175
+ return text_gallery, image_gallery, pdf_gallery, updated_manuscript
176
+
177
+ # --- Main PDF Generation Logic (heavily adapted from before) ---
178
+ def markdown_to_story(markdown_text, font_name, font_sizes, use_svg_engine):
179
+ styles = getSampleStyleSheet()
180
+ font_size_body, font_size_h1, font_size_h2, font_size_h3 = font_sizes
181
+ style_normal = ParagraphStyle('BodyText', fontName=font_name, fontSize=font_size_body, leading=font_size_body*1.4, spaceAfter=6)
182
  style_h1 = ParagraphStyle('h1', parent=styles['h1'], fontName=font_name, fontSize=font_size_h1, leading=font_size_h1*1.2, spaceBefore=12, textColor=colors.HexColor("#a855f7"))
183
  style_h2 = ParagraphStyle('h2', parent=styles['h2'], fontName=font_name, fontSize=font_size_h2, leading=font_size_h2*1.2, spaceBefore=10, textColor=colors.HexColor("#6366f1"))
184
  style_h3 = ParagraphStyle('h3', parent=styles['h3'], fontName=font_name, fontSize=font_size_h3, leading=font_size_h3*1.2, spaceBefore=8, textColor=colors.HexColor("#3b82f6"))
185
  style_code = ParagraphStyle('Code', fontName='Courier', backColor=colors.HexColor("#333333"), textColor=colors.HexColor("#f472b6"), borderWidth=1, borderColor=colors.HexColor("#444444"), padding=8, leading=12, fontSize=9)
186
+ style_table_header = ParagraphStyle('TableHeader', parent=style_normal, fontName=font_name) # Bold is handled in text
187
 
188
  emoji_pattern = re.compile(f"([{re.escape(''.join(map(chr, range(0x1f600, 0x1f650))))}"
189
  f"{re.escape(''.join(map(chr, range(0x1f300, 0x1f5ff))))}"
 
192
  f"{re.escape(''.join(map(chr, range(0x2700, 0x27bf))))}]+)")
193
 
194
  def create_flowables_for_line(text, style):
195
+ parts, flowables = emoji_pattern.split(text), []
 
 
196
  for part in parts:
197
  if not part: continue
198
  if emoji_pattern.match(part):
199
  for emoji_char in part:
200
+ emoji_flowable = render_emoji_as_svg(emoji_char, style.fontSize) if use_svg_engine else (lambda b: ReportLabImage(b, height=style.fontSize*1.2, width=style.fontSize*1.2) if b else None)(render_emoji_as_image(emoji_char, style.fontSize))
201
+ if emoji_flowable: flowables.append(emoji_flowable)
 
 
 
 
 
 
 
 
202
  else:
203
  formatted_part = re.sub(r'_(.*?)_', r'<i>\1</i>', re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', part))
204
  flowables.append(Paragraph(formatted_part, style))
 
205
  if not flowables: return [Spacer(0, 0)]
206
  table = Table([flowables], colWidths=[None]*len(flowables))
207
  table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)]))
208
  return [table]
209
 
210
+ story, lines = [], markdown_text.split('\n')
211
+ in_code_block, in_table, first_heading, document_title = False, False, True, "Untitled Document"
 
 
212
  code_block_text, table_data = "", []
 
 
213
 
214
  for line in lines:
215
  stripped_line = line.strip()
 
216
  if stripped_line.startswith("```"):
217
  if in_code_block:
218
  escaped_code = code_block_text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
219
+ story.extend([Paragraph(escaped_code.replace('\n', '<br/>'), style_code), Spacer(1, 0.1*inch)])
 
220
  in_code_block, code_block_text = False, ""
221
  else: in_code_block = True
222
  continue
223
+ if in_code_block: code_block_text += line + '\n'; continue
 
 
224
  if stripped_line.startswith('|'):
225
  if not in_table: in_table = True
226
  if all(c in '-|: ' for c in stripped_line): continue
227
+ table_data.append([cell.strip() for cell in stripped_line.strip('|').split('|')])
 
228
  continue
229
  if in_table:
230
  in_table = False
231
  if table_data:
232
+ processed_table_data = [[create_flowables_for_line(cell, style_normal)[0] for cell in row] for row in table_data[1:]]
233
+ header_row = [create_flowables_for_line(f"<b>{cell}</b>", style_table_header)[0] for cell in table_data[0]]
234
+ table = Table([header_row] + processed_table_data, hAlign='LEFT', repeatRows=1)
235
+ table.setStyle(TableStyle([('BACKGROUND', (0,0), (-1,0), colors.HexColor("#4a044e")), ('GRID', (0,0), (-1,-1), 1, colors.HexColor("#6b21a8")), ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('TOPPADDING', (0,0), (-1,-1), 6), ('BOTTOMPADDING', (0,0), (-1,-1), 6)]))
236
+ story.extend([table, Spacer(1, 0.2*inch)])
 
 
 
 
 
 
 
237
  table_data = []
238
  continue
239
 
240
  if not stripped_line: continue
 
241
  content, style, bullet_text = stripped_line, style_normal, None
 
242
  if stripped_line.startswith("# "):
243
  if not first_heading: story.append(PageBreak())
244
+ content, style, first_heading = stripped_line.lstrip('# '), style_h1, False
245
+ if document_title == "Untitled Document": document_title = content
 
246
  elif stripped_line.startswith("## "): content, style = stripped_line.lstrip('## '), style_h2
247
  elif stripped_line.startswith("### "): content, style = stripped_line.lstrip('### '), style_h3
248
+ elif stripped_line.startswith(("- ", "* ")): content, bullet_text = stripped_line[2:], '• '
 
 
249
 
250
  line_flowables = create_flowables_for_line(content, style)
 
251
  if bullet_text:
252
+ list_item_table = Table([[Paragraph(bullet_text, style)] + line_flowables], colWidths=[style.fontSize*1.5] + [None]*len(line_flowables))
 
 
253
  list_item_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'TOP'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)]))
254
  story.append(list_item_table)
255
+ else: story.extend(line_flowables)
 
 
256
  return story, document_title
257
 
258
+ def generate_pdfs_api(files, ai_content, layouts, fonts, num_columns, header_text, footer_text, font_sizes, margins, use_svg_engine, progress=gr.Progress(track_tqdm=True)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  if not files and (not ai_content or "Golem awaits" in ai_content): raise gr.Error("Please conjure some content or upload a file before alchemizing!")
260
  if not layouts: raise gr.Error("You must select a scroll (page layout)!")
261
  if not fonts: raise gr.Error("A scribe needs a font! Please choose one.")
262
+ if not EMOJI_FONT_PATH: raise gr.Error("CRITICAL: Cannot generate PDFs. 'NotoColorEmoji-Regular.ttf' not found.")
 
 
 
263
 
264
+ shutil.rmtree(OUTPUT_DIR, ignore_errors=True); OUTPUT_DIR.mkdir()
265
+ shutil.rmtree(PREVIEW_DIR, ignore_errors=True); PREVIEW_DIR.mkdir()
266
+
267
  image_files, pdf_files, txt_files = [], [], []
268
  if files:
269
  for f in files:
270
+ p = Path(f.name); ext = p.suffix.lower()
271
+ if ext in ['.png','.jpg','.jpeg','.gif','.bmp','.tiff']: image_files.append(p)
272
+ elif ext == '.pdf': pdf_files.append(p)
273
+ else: txt_files.append(p)
274
+
275
+ log, all_text = "", []
276
+ if ai_content and "Golem awaits" not in ai_content: all_text.append(ai_content)
277
+ for p in txt_files:
278
+ try: all_text.append(p.read_text(encoding='utf-8'))
279
+ except Exception as e: log += f"⚠️ Failed to read {p.name}: {e}\n"
280
+ md_content = "\n\n---\n\n".join(all_text)
 
 
 
 
 
 
 
 
281
 
282
+ generated_pdfs = []
283
  EMOJI_IMAGE_CACHE.clear(); EMOJI_SVG_CACHE.clear()
284
 
285
+ for layout in progress.tqdm(layouts, desc="brewing potions..."):
286
+ for font in progress.tqdm(fonts, desc=f"enchanting scrolls with {layout}..."):
287
  merger = PdfWriter()
 
288
  if md_content:
289
+ buffer, (story, title) = io.BytesIO(), markdown_to_story(md_content, font, font_sizes, use_svg_engine)
290
+ doc = BaseDocTemplate(buffer, pagesize=LAYOUTS[layout]["size"], leftMargin=margins[2]*inch, rightMargin=margins[3]*inch, topMargin=margins[0]*inch, bottomMargin=margins[1]*inch)
291
+ frame_w = (doc.width / num_columns) - (num_columns - 1)*0.1*inch
292
+ frames = [Frame(doc.leftMargin + i*(frame_w+0.2*inch), doc.bottomMargin, frame_w, doc.height, id=f'col_{i}') for i in range(num_columns)]
293
+ doc.addPageTemplates([PageTemplate(id='main', frames=frames, onPage=partial(_draw_header_footer, header_text=header_text, footer_text=footer_text, title=title))])
294
+ doc.build(story); buffer.seek(0); merger.append(fileobj=buffer)
295
+
296
+ for p in image_files:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  try:
298
+ with Image.open(p) as img: w, h = img.size
299
+ w_pt, h_pt = w * (inch/72), h * (inch/72)
300
+ buffer = io.BytesIO()
301
+ SimpleDocTemplate(buffer, pagesize=(w_pt, h_pt), leftMargin=0,rightMargin=0,topMargin=0,bottomMargin=0).build([ReportLabImage(p, width=w_pt, height=h_pt)])
302
+ buffer.seek(0); merger.append(fileobj=buffer)
303
+ except Exception as e: log += f"⚠️ Failed to process image {p.name}: {e}\n"
304
+ for p in pdf_files:
305
+ try: merger.append(str(p))
306
+ except Exception as e: log += f"⚠️ Failed to merge PDF {p.name}: {e}\n"
307
+
308
  if len(merger.pages) > 0:
309
+ filename = f"Scroll_{layout.replace(' ','')}_{font}_x{num_columns}_{datetime.datetime.now().strftime('%H%M%S')}.pdf"
310
+ out_path = OUTPUT_DIR / filename
311
+ with open(out_path, "wb") as f: merger.write(f)
312
+ generated_pdfs.append(out_path); log += f"✅ Alchemized: {filename}\n"
 
 
 
 
 
 
313
 
314
+ previews = [p for p in [create_pdf_preview(pdf) for pdf in generated_pdfs] if p]
315
+ return previews, log if log else "✨ All scrolls alchemized successfully! ✨", [str(p) for p in generated_pdfs]
316
 
317
  # --- Gradio UI Definition ---
318
+ AVAILABLE_FONTS = register_local_fonts()
319
 
320
  def get_theme():
321
+ desired, fallback = "MedievalSharp", ("ui-sans-serif", "system-ui", "sans-serif")
322
+ font_family = fallback
323
+ if any(desired in s for s in AVAILABLE_FONTS):
324
+ font_family = (gr.themes.GoogleFont(desired