awacke1 commited on
Commit
04a1711
ยท
verified ยท
1 Parent(s): 96b5748

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +278 -165
app.py CHANGED
@@ -9,6 +9,7 @@ from PIL import Image
9
  from collections import defaultdict
10
  import io
11
  from pypdf import PdfWriter
 
12
 
13
  from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, BaseDocTemplate, Frame, PageTemplate, Image as ReportLabImage
14
  from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
@@ -17,6 +18,7 @@ from reportlab.lib.units import inch
17
  from reportlab.lib import colors
18
  from reportlab.pdfbase import pdfmetrics
19
  from reportlab.pdfbase.ttfonts import TTFont
 
20
 
21
  # --- Configuration & Setup ---
22
  CWD = Path.cwd()
@@ -30,7 +32,7 @@ LAYOUTS = {
30
  }
31
  OUTPUT_DIR = CWD / "generated_pdfs"
32
  PREVIEW_DIR = CWD / "previews"
33
- FONT_DIR = CWD
34
 
35
  # Create necessary directories
36
  OUTPUT_DIR.mkdir(exist_ok=True)
@@ -45,6 +47,11 @@ def register_local_fonts():
45
  text_font_names = []
46
  emoji_font_name = None
47
 
 
 
 
 
 
48
  print(f"Scanning for fonts in: {FONT_DIR.absolute()}")
49
  font_files = list(FONT_DIR.glob("*.ttf"))
50
  print(f"Found {len(font_files)} .ttf files: {[f.name for f in font_files]}")
@@ -60,7 +67,7 @@ def register_local_fonts():
60
 
61
  if "notocoloremoji-regular" in font_name.lower():
62
  emoji_font_name = font_name
63
- elif "notoemoji" not in font_name.lower(): # Exclude other symbol fonts from text selection
64
  text_font_names.append(font_name)
65
  except Exception as e:
66
  print(f"Could not register font {font_path.name}: {e}")
@@ -75,71 +82,119 @@ def register_local_fonts():
75
  return sorted(text_font_names), emoji_font_name
76
 
77
  def apply_emoji_font(text: str, emoji_font_name: str) -> str:
78
- """
79
- Intelligently wraps emoji characters in a <font> tag, preserving existing HTML-like tags.
80
- This prevents invalid nested tags like <b><font>...</font></b> which ReportLab handles poorly.
81
- """
82
  if not emoji_font_name:
83
  return text
84
-
85
- # Regex to find emojis
86
  emoji_pattern = re.compile(f"([{re.escape(''.join(map(chr, range(0x1f600, 0x1f650))))}"
87
  f"{re.escape(''.join(map(chr, range(0x1f300, 0x1f5ff))))}"
88
  f"{re.escape(''.join(map(chr, range(0x1f900, 0x1f9ff))))}"
89
  f"{re.escape(''.join(map(chr, range(0x2600, 0x26ff))))}"
90
  f"{re.escape(''.join(map(chr, range(0x2700, 0x27bf))))}]+)")
 
91
 
92
- # Regex to split the string by existing tags (<b>, <i>)
93
- tag_pattern = re.compile(r"(<[^>]+>)")
94
- parts = tag_pattern.split(text)
95
-
96
- result = []
97
- for part in parts:
98
- if tag_pattern.match(part):
99
- # It's a tag, append it as is
100
- result.append(part)
101
- else:
102
- # It's text, apply emoji font to any emojis within this segment
103
- result.append(emoji_pattern.sub(fr'<font name="{emoji_font_name}">\1</font>', part))
104
-
105
- return "".join(result)
106
 
 
107
 
108
- # --- PDF Generation & Handling ---
109
-
110
- def markdown_to_story(markdown_text: str, font_name: str, emoji_font: str):
111
  """
112
- Converts markdown to a ReportLab story, with enhanced styling and page breaks.
113
- This version correctly separates structural parsing from content formatting.
114
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  styles = getSampleStyleSheet()
116
- # Define styles for various markdown elements
117
- style_normal = ParagraphStyle('BodyText', fontName=font_name, spaceAfter=6, leading=14, fontSize=10)
118
- style_h1 = ParagraphStyle('h1', parent=styles['h1'], fontName=font_name, spaceBefore=12, fontSize=24, leading=28, textColor=colors.darkblue)
119
- style_h2 = ParagraphStyle('h2', parent=styles['h2'], fontName=font_name, fontSize=18, leading=22, spaceBefore=10)
120
- style_h3 = ParagraphStyle('h3', parent=styles['h3'], fontName=font_name, fontSize=14, leading=18, spaceBefore=8)
121
- style_code = ParagraphStyle('Code', fontName='Courier', backColor=colors.whitesmoke, textColor=colors.darkred, borderWidth=1, borderColor=colors.lightgrey, padding=8, leading=12, fontSize=9)
122
- style_table_header = ParagraphStyle('TableHeader', parent=style_normal, fontName=font_name + "-Bold" if font_name != 'Helvetica' else 'Helvetica-Bold')
123
 
 
 
 
 
 
 
 
 
124
  story = []
125
  lines = markdown_text.split('\n')
126
 
127
  in_code_block, in_table = False, False
128
  code_block_text, table_data = "", []
129
  first_heading = True
 
130
 
131
  for line in lines:
132
  stripped_line = line.strip()
133
 
134
  if stripped_line.startswith("```"):
135
  if in_code_block:
136
- story.append(Paragraph(code_block_text.replace('\n', '<br/>'), style_code)); story.append(Spacer(1, 0.1 * inch))
137
- in_code_block = False; code_block_text = ""
 
 
138
  else: in_code_block = True
139
  continue
140
  if in_code_block:
141
- code_block_text += line.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;') + '\n'
142
- continue
143
 
144
  if stripped_line.startswith('|'):
145
  if not in_table: in_table = True
@@ -153,172 +208,230 @@ def markdown_to_story(markdown_text: str, font_name: str, emoji_font: str):
153
  header_content = [apply_emoji_font(re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', cell), emoji_font) for cell in table_data[0]]
154
  header = [Paragraph(cell, style_table_header) for cell in header_content]
155
 
156
- formatted_rows = []
157
- for row in table_data[1:]:
158
- formatted_cells = [apply_emoji_font(re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', cell), emoji_font) for cell in row]
159
- formatted_rows.append([Paragraph(cell, style_normal) for cell in formatted_cells])
160
 
161
  table = Table([header] + formatted_rows, hAlign='LEFT', repeatRows=1)
162
- table.setStyle(TableStyle([('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey), ('GRID', (0, 0), (-1, -1), 1, colors.darkgrey), ('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
 
 
 
 
 
 
 
163
  story.append(table); story.append(Spacer(1, 0.2 * inch))
164
  table_data = []
165
 
166
- if not stripped_line:
167
- story.append(Spacer(1, 0.1 * inch))
168
- continue
169
 
170
- # Default content is the whole stripped line
171
- content = stripped_line
172
- style = style_normal
173
- extra_args = {}
174
 
175
- # Detect structural elements and extract the raw content
176
  if stripped_line.startswith("# "):
177
  if not first_heading: story.append(PageBreak())
178
- content = stripped_line.lstrip('# '); style = style_h1; first_heading = False
179
- elif stripped_line.startswith("## "):
180
- content = stripped_line.lstrip('## '); style = style_h2
181
- elif stripped_line.startswith("### "):
182
- content = stripped_line.lstrip('### '); style = style_h3
183
- elif stripped_line.startswith(("- ", "* ")):
184
- content = stripped_line[2:]; extra_args['bulletText'] = 'โ€ข'
185
 
186
- # Now, format the extracted content
187
- # Apply markdown formatting for bold/italic
188
  formatted_content = re.sub(r'_(.*?)_', r'<i>\1</i>', re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', content))
189
- # Then apply emoji font to the already formatted line
190
  final_content = apply_emoji_font(formatted_content, emoji_font)
191
-
192
  story.append(Paragraph(final_content, style, **extra_args))
193
- return story
194
-
195
 
196
  def create_pdf_preview(pdf_path: Path):
 
197
  preview_path = PREVIEW_DIR / f"{pdf_path.stem}.png"
198
  try:
199
- doc = fitz.open(pdf_path); page = doc.load_page(0); pix = page.get_pixmap()
200
- pix.save(str(preview_path)); doc.close()
 
 
 
201
  return str(preview_path)
202
  except Exception as e:
203
- print(f"Could not create preview for {pdf_path.name}: {e}"); return None
 
 
 
 
 
204
 
205
  # --- Main API Function ---
206
- def generate_pdfs_api(files, layouts, fonts, num_columns, page_w_mult, page_h_mult, progress=gr.Progress(track_tqdm=True)):
207
- if not files: raise gr.Error("Please upload at least one Markdown or Image file.")
208
- if not layouts: raise gr.Error("Please select at least one page layout.")
209
- if not fonts: raise gr.Error("Please select at least one font.")
210
 
211
  shutil.rmtree(OUTPUT_DIR, ignore_errors=True); shutil.rmtree(PREVIEW_DIR, ignore_errors=True)
212
  OUTPUT_DIR.mkdir(); PREVIEW_DIR.mkdir()
213
 
214
- grouped_files = defaultdict(lambda: {'md': [], 'img': []})
215
- for f in files:
216
- file_path = Path(f.name)
217
- stem = file_path.stem.split('_')[0] if '_' in file_path.stem else file_path.stem
218
- if file_path.suffix.lower() == '.md': grouped_files[stem]['md'].append(file_path)
219
- elif file_path.suffix.lower() in ['.png', '.jpg', '.jpeg']: grouped_files[stem]['img'].append(file_path)
220
 
221
  log_updates, generated_pdf_paths = "", []
222
-
223
- for stem, assets in progress.tqdm(grouped_files.items(), desc="Processing File Groups"):
224
- for layout_name in layouts:
225
- for font_name in fonts:
226
- merger = PdfWriter()
 
 
 
 
227
 
228
- if assets['md']:
229
- md_content = "\n".join([p.read_text(encoding='utf-8') for p in assets['md']])
230
- md_buffer = io.BytesIO()
231
- story = markdown_to_story(md_content, font_name, EMOJI_FONT_NAME)
232
-
233
- base_w, base_h = LAYOUTS[layout_name]["size"]
234
- pagesize = (base_w * page_w_mult, base_h * page_h_mult)
235
-
236
- if num_columns > 1:
237
- doc = BaseDocTemplate(md_buffer, pagesize=pagesize, leftMargin=0.5*inch, rightMargin=0.5*inch, topMargin=0.5*inch, bottomMargin=0.5*inch)
238
- frame_width = (doc.width / num_columns) - (num_columns - 1) * 0.1*inch
239
- frames = [Frame(doc.leftMargin + i * (frame_width + 0.2*inch), doc.bottomMargin, frame_width, doc.height) for i in range(num_columns)]
240
- doc.addPageTemplates([PageTemplate(id='MultiCol', frames=frames)])
241
- else:
242
- doc = SimpleDocTemplate(md_buffer, pagesize=pagesize)
243
- doc.build(story)
244
- merger.append(fileobj=md_buffer)
245
-
246
- for img_path in assets['img']:
247
- with Image.open(img_path) as img: img_width, img_height = img.size
248
  img_buffer = io.BytesIO()
249
- doc = SimpleDocTemplate(img_buffer, pagesize=(img_width, img_height), leftMargin=0, rightMargin=0, topMargin=0, bottomMargin=0)
250
- doc.build([ReportLabImage(img_path, width=img_width, height=img_height)])
 
251
  merger.append(fileobj=img_buffer)
252
-
253
- if len(merger.pages) > 0:
254
- time_str = datetime.datetime.now().strftime('%m-%d-%a_%I%M%p').upper()
255
- filename = f"{stem}_{time_str}_{layout_name.replace(' ','-')}_{page_w_mult}x{page_h_mult}_{font_name}_Cols{num_columns}.pdf"
256
- output_path = OUTPUT_DIR / filename
257
- with open(output_path, "wb") as f: merger.write(f)
258
- generated_pdf_paths.append(output_path)
259
- log_updates += f"Generated: {filename}\n"
 
 
260
 
261
  gallery_previews = [create_pdf_preview(p) for p in generated_pdf_paths]
262
  final_gallery = [g for g in gallery_previews if g is not None]
263
 
264
- return final_gallery, log_updates, [str(p) for p in generated_pdf_paths]
265
 
266
  # --- Gradio UI Definition ---
267
  AVAILABLE_FONTS, EMOJI_FONT_NAME = register_local_fonts()
268
- SAMPLE_MARKDOWN = """# Deities Guide: Mythology and Moral Lessons
269
-
270
- 1. ๐Ÿ“œ **Introduction**
271
- - **Purpose**: Explore deities, spirits, saints, and beings with their epic stories and morals!
272
- - **Usage**: A guide for learning and storytelling across traditions. ๏ธ
273
- - **Themes**: Justice โš–๏ธ, faith ๐Ÿ™, hubris ๐Ÿ›๏ธ, redemption โœจ, cosmic order ๐ŸŒŒ.
274
-
275
- 2. ๐Ÿ› ๏ธ **Core Concepts of Divinity**
276
- - **Powers**: Creation ๐ŸŒ, omniscience ๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ, shapeshifting ๐Ÿฆ‹ across entities.
277
- - **Life Cycle**: Mortality โณ, immortality โ™พ๏ธ, transitions like saints and avatars ๐ŸŒŸ.
278
- - **Communication**: Omens ๐Ÿ•Š๏ธ, visions ๐Ÿ‘๏ธ, miracles โœจ from gods and spirits.
279
-
280
- # โš”๏ธ Arthurian Legends
281
- - **Merlin, Morgan le Fay, Arthur**: Mentor ๐Ÿง™, rival ๐Ÿง™โ€โ™€๏ธ, son ๐Ÿ‘‘.
282
- - **Relation**: Family tests loyalty ๐Ÿค.
283
- - **Lesson**: Honor ๐ŸŽ–๏ธ vs. betrayal ๐Ÿ—ก๏ธ.
284
-
285
- # ๐Ÿ›๏ธ Greek Mythology
286
- - **Zeus, Hera, Athena**: Father โšก, mother ๐Ÿ‘‘, daughter ๐Ÿฆ‰.
287
- - **Relation**: Family rules with tension ๐ŸŒฉ๏ธ.
288
- - **Lesson**: Hubris เค…เคนเค‚เค•เคพเคฐ meets wisdom ๐Ÿง .
289
-
290
- # ๐Ÿ•‰๏ธ Hindu Trimurti
291
- - **Brahma, Vishnu, Shiva**: Creator Brahma, preserver Vishnu, destroyer Shiva.
292
- - **Relation**: Divine trio cycles existence ๐Ÿ”„.
293
- - **Lesson**: Balance โš–๏ธ sustains life ๐Ÿ’–.
294
- """
295
- with open(CWD / "sample.md", "w", encoding="utf-8") as f: f.write(SAMPLE_MARKDOWN)
296
-
297
- with gr.Blocks(theme=gr.themes.Soft(), title="Advanced PDF Generator") as demo:
298
- gr.Markdown("# ๐Ÿ“„ Advanced PDF Layout Engine")
299
- gr.Markdown("Upload Markdown/Image files. The app finds local `.ttf` fonts. Group assets with a common name (e.g., `Doc_part1.md`, `Doc_img1.png`) to combine them. `# Headers` create automatic page breaks.")
300
 
301
- with gr.Row():
302
- with gr.Column(scale=1):
303
- gr.Markdown("### โš™๏ธ Generation Settings")
304
- uploaded_files = gr.File(label="Upload Markdown & Image Files", file_count="multiple", file_types=[".md", ".png", ".jpg", ".jpeg"])
305
-
306
- with gr.Row():
307
- page_w_mult_slider = gr.Slider(label="Page Width Multiplier", minimum=1, maximum=5, step=1, value=1)
308
- page_h_mult_slider = gr.Slider(label="Page Height Multiplier", minimum=1, maximum=2, step=1, value=1)
309
-
310
- num_columns_slider = gr.Slider(label="Number of Text Columns", minimum=1, maximum=4, step=1, value=1)
311
- selected_layouts = gr.CheckboxGroup(choices=list(LAYOUTS.keys()), label="Select Base Page Layout", value=["A4 Portrait"])
312
- selected_fonts = gr.CheckboxGroup(choices=AVAILABLE_FONTS, label="Select Text Font", value=[AVAILABLE_FONTS[0]] if AVAILABLE_FONTS else [])
313
- generate_btn = gr.Button("๐Ÿš€ Generate PDFs", variant="primary")
314
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  with gr.Column(scale=2):
316
- gr.Markdown("### ๐Ÿ–ผ๏ธ PDF Preview Gallery")
317
- gallery_output = gr.Gallery(label="Generated PDF Previews", show_label=False, elem_id="gallery", columns=3, height="auto", object_fit="contain")
318
- log_output = gr.Markdown(label="Generation Log", value="Logs will appear here...")
319
- downloadable_files_output = gr.Files(label="Download Generated PDFs")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
- generate_btn.click(fn=generate_pdfs_api, inputs=[uploaded_files, selected_layouts, selected_fonts, num_columns_slider, page_w_mult_slider, page_h_mult_slider], outputs=[gallery_output, log_output, downloadable_files_output])
 
 
 
 
 
 
 
 
 
 
322
 
323
  if __name__ == "__main__":
324
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from collections import defaultdict
10
  import io
11
  from pypdf import PdfWriter
12
+ import random
13
 
14
  from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, BaseDocTemplate, Frame, PageTemplate, Image as ReportLabImage
15
  from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
 
18
  from reportlab.lib import colors
19
  from reportlab.pdfbase import pdfmetrics
20
  from reportlab.pdfbase.ttfonts import TTFont
21
+ from functools import partial
22
 
23
  # --- Configuration & Setup ---
24
  CWD = Path.cwd()
 
32
  }
33
  OUTPUT_DIR = CWD / "generated_pdfs"
34
  PREVIEW_DIR = CWD / "previews"
35
+ FONT_DIR = CWD # Assumes fonts are in the same directory as the script
36
 
37
  # Create necessary directories
38
  OUTPUT_DIR.mkdir(exist_ok=True)
 
47
  text_font_names = []
48
  emoji_font_name = None
49
 
50
+ noto_emoji_path = FONT_DIR / "NotoColorEmoji-Regular.ttf"
51
+ if not noto_emoji_path.exists():
52
+ print(f"Warning: Emoji font not found at {noto_emoji_path}. Emojis may not render correctly.")
53
+ print("Please download 'NotoColorEmoji-Regular.ttf' and place it in the application directory.")
54
+
55
  print(f"Scanning for fonts in: {FONT_DIR.absolute()}")
56
  font_files = list(FONT_DIR.glob("*.ttf"))
57
  print(f"Found {len(font_files)} .ttf files: {[f.name for f in font_files]}")
 
67
 
68
  if "notocoloremoji-regular" in font_name.lower():
69
  emoji_font_name = font_name
70
+ elif "notoemoji" not in font_name.lower():
71
  text_font_names.append(font_name)
72
  except Exception as e:
73
  print(f"Could not register font {font_path.name}: {e}")
 
82
  return sorted(text_font_names), emoji_font_name
83
 
84
  def apply_emoji_font(text: str, emoji_font_name: str) -> str:
85
+ """Intelligently wraps emoji characters in a <font> tag to ensure they render correctly."""
 
 
 
86
  if not emoji_font_name:
87
  return text
 
 
88
  emoji_pattern = re.compile(f"([{re.escape(''.join(map(chr, range(0x1f600, 0x1f650))))}"
89
  f"{re.escape(''.join(map(chr, range(0x1f300, 0x1f5ff))))}"
90
  f"{re.escape(''.join(map(chr, range(0x1f900, 0x1f9ff))))}"
91
  f"{re.escape(''.join(map(chr, range(0x2600, 0x26ff))))}"
92
  f"{re.escape(''.join(map(chr, range(0x2700, 0x27bf))))}]+)")
93
+ return emoji_pattern.sub(fr'<font name="{emoji_font_name}">\1</font>', text)
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ # --- AI Content Generation (Simulation) ---
97
 
98
+ def generate_ai_content_api(prompt):
 
 
99
  """
100
+ Simulates a call to an LLM to generate markdown content.
101
+ In a real application, this would contain `fetch` calls to a generative AI API.
102
  """
103
+ if not prompt:
104
+ 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! โœจ"
105
+
106
+ import time
107
+ time.sleep(1.5)
108
+
109
+ sample_story = f"""
110
+ # The Quest for the Sunstone โ˜€๏ธ
111
+
112
+ 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.
113
+
114
+ ## The Prophecy ๐Ÿ“œ
115
+
116
+ An ancient prophecy spoke of a hero who would rekindle the stone. It read:
117
+ > When darkness drapes the verdant ground,
118
+ > A soul of courage shall be found.
119
+ > Through trials of fire ๐Ÿ”ฅ, wit, and might,
120
+ > They'll bring once more the sacred light.
121
+
122
+ ## The Chosen One ๐Ÿฆธโ€โ™€๏ธ
123
+
124
+ 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.
125
+
126
+ ### The Journey Begins
127
+ Elara's journey was fraught with peril. She navigated enchanted forests and crossed treacherous chasms.
128
+
129
+ | Trial | Location | Challenge |
130
+ |------------------|-------------------|----------------------------------------|
131
+ | The Whispering | The Gloomwood | Resist maddening whispers of despair |
132
+ | The Riddle of | Sphinx Gate | Answer three impossible questions |
133
+ | The Fiery Path | The Magma Caverns | Walk barefoot across burning embers |
134
+
135
+ 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.
136
+ """
137
+ return f"# Golem's Vision for: '{prompt}'\n\n{sample_story}"
138
+
139
+
140
+ # --- PDF Generation & Handling ---
141
+
142
+ def _draw_header_footer(canvas, doc, header_text, footer_text, title):
143
+ """Draws the header and footer on each page."""
144
+ canvas.saveState()
145
+ page_num = canvas.getPageNumber()
146
+
147
+ # Replace variables for header and footer. Note: [Total Pages] is only accurate at the end of the build.
148
+ final_footer_text = footer_text.replace("[Page #]", str(page_num)).replace("[Total Pages]", str(doc.page))
149
+ final_header_text = header_text.replace("[Page #]", str(page_num)).replace("[Title]", title)
150
+
151
+ # Header
152
+ if final_header_text:
153
+ canvas.setFont('Helvetica', 9)
154
+ canvas.setFillColor(colors.grey)
155
+ canvas.drawRightString(doc.width + doc.leftMargin, doc.height + doc.topMargin + 0.25*inch, final_header_text)
156
+
157
+ # Footer
158
+ if final_footer_text:
159
+ canvas.setFont('Helvetica', 9)
160
+ canvas.setFillColor(colors.grey)
161
+ canvas.drawString(doc.leftMargin, doc.bottomMargin - 0.25*inch, final_footer_text)
162
+
163
+ canvas.restoreState()
164
+
165
+ 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):
166
+ """Converts markdown to a ReportLab story with enhanced, user-configurable styling."""
167
  styles = getSampleStyleSheet()
 
 
 
 
 
 
 
168
 
169
+ leading_body = font_size_body * 1.4
170
+ style_normal = ParagraphStyle('BodyText', fontName=font_name, fontSize=font_size_body, leading=leading_body, spaceAfter=6)
171
+ 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"))
172
+ 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"))
173
+ 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"))
174
+ 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)
175
+ style_table_header = ParagraphStyle('TableHeader', parent=style_normal, fontName=f"{font_name}-Bold" if font_name != 'Helvetica' else 'Helvetica-Bold')
176
+
177
  story = []
178
  lines = markdown_text.split('\n')
179
 
180
  in_code_block, in_table = False, False
181
  code_block_text, table_data = "", []
182
  first_heading = True
183
+ document_title = "Untitled Document"
184
 
185
  for line in lines:
186
  stripped_line = line.strip()
187
 
188
  if stripped_line.startswith("```"):
189
  if in_code_block:
190
+ escaped_code = code_block_text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
191
+ story.append(Paragraph(escaped_code.replace('\n', '<br/>'), style_code))
192
+ story.append(Spacer(1, 0.1 * inch))
193
+ in_code_block, code_block_text = False, ""
194
  else: in_code_block = True
195
  continue
196
  if in_code_block:
197
+ code_block_text += line + '\n'; continue
 
198
 
199
  if stripped_line.startswith('|'):
200
  if not in_table: in_table = True
 
208
  header_content = [apply_emoji_font(re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', cell), emoji_font) for cell in table_data[0]]
209
  header = [Paragraph(cell, style_table_header) for cell in header_content]
210
 
211
+ formatted_rows = [
212
+ [Paragraph(apply_emoji_font(re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', cell), emoji_font), style_normal) for cell in row]
213
+ for row in table_data[1:]
214
+ ]
215
 
216
  table = Table([header] + formatted_rows, hAlign='LEFT', repeatRows=1)
217
+ table.setStyle(TableStyle([
218
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#4a044e")),
219
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
220
+ ('GRID', (0, 0), (-1, -1), 1, colors.HexColor("#6b21a8")),
221
+ ('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
222
+ ('TOPPADDING', (0,0), (-1,-1), 6),
223
+ ('BOTTOMPADDING', (0,0), (-1,-1), 6),
224
+ ]))
225
  story.append(table); story.append(Spacer(1, 0.2 * inch))
226
  table_data = []
227
 
228
+ if not stripped_line: continue
 
 
229
 
230
+ content, style, extra_args = stripped_line, style_normal, {}
 
 
 
231
 
 
232
  if stripped_line.startswith("# "):
233
  if not first_heading: story.append(PageBreak())
234
+ content, style = stripped_line.lstrip('# '), style_h1
235
+ if first_heading: document_title = content
236
+ first_heading = False
237
+ elif stripped_line.startswith("## "): content, style = stripped_line.lstrip('## '), style_h2
238
+ elif stripped_line.startswith("### "): content, style = stripped_line.lstrip('### '), style_h3
239
+ elif stripped_line.startswith(("- ", "* ")): content, extra_args['bulletText'] = stripped_line[2:], 'โ€ข'
 
240
 
 
 
241
  formatted_content = re.sub(r'_(.*?)_', r'<i>\1</i>', re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', content))
 
242
  final_content = apply_emoji_font(formatted_content, emoji_font)
 
243
  story.append(Paragraph(final_content, style, **extra_args))
244
+
245
+ return story, document_title
246
 
247
  def create_pdf_preview(pdf_path: Path):
248
+ """Generates a PNG preview of the first page of a PDF."""
249
  preview_path = PREVIEW_DIR / f"{pdf_path.stem}.png"
250
  try:
251
+ doc = fitz.open(pdf_path)
252
+ page = doc.load_page(0)
253
+ pix = page.get_pixmap(dpi=150)
254
+ pix.save(str(preview_path))
255
+ doc.close()
256
  return str(preview_path)
257
  except Exception as e:
258
+ print(f"Could not create preview for {pdf_path.name}: {e}")
259
+ try:
260
+ img = Image.new('RGB', (400, 500), color = '#111827')
261
+ img.save(str(preview_path))
262
+ return str(preview_path)
263
+ except: return None
264
 
265
  # --- Main API Function ---
266
+ 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, progress=gr.Progress(track_tqdm=True)):
267
+ if not files and not ai_content.strip(): raise gr.Error("Please conjure some content or upload an image before alchemizing!")
268
+ if not layouts: raise gr.Error("You must select a scroll (page layout)!")
269
+ if not fonts: raise gr.Error("A scribe needs a font! Please choose one.")
270
 
271
  shutil.rmtree(OUTPUT_DIR, ignore_errors=True); shutil.rmtree(PREVIEW_DIR, ignore_errors=True)
272
  OUTPUT_DIR.mkdir(); PREVIEW_DIR.mkdir()
273
 
274
+ md_content = ai_content if ai_content and ai_content.strip() else ""
275
+ image_files = [Path(f.name) for f in files if Path(f.name).suffix.lower() in ['.png', '.jpg', '.jpeg']] if files else []
 
 
 
 
276
 
277
  log_updates, generated_pdf_paths = "", []
278
+
279
+ for layout_name in progress.tqdm(layouts, desc=" brewing potions..."):
280
+ for font_name in progress.tqdm(fonts, desc=f" enchanting scrolls with {layout_name}..."):
281
+ merger = PdfWriter()
282
+
283
+ if md_content:
284
+ md_buffer = io.BytesIO()
285
+ story, title = markdown_to_story(md_content, font_name, EMOJI_FONT_NAME, font_size_body, font_size_h1, font_size_h2, font_size_h3)
286
+ pagesize = LAYOUTS[layout_name]["size"]
287
 
288
+ doc = BaseDocTemplate(md_buffer, pagesize=pagesize,
289
+ leftMargin=margin_left*inch, rightMargin=margin_right*inch,
290
+ topMargin=margin_top*inch, bottomMargin=margin_bottom*inch)
291
+
292
+ frame_width = (doc.width / num_columns) - (num_columns - 1) * 0.1*inch
293
+ 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)]
294
+
295
+ header_footer_callback = partial(_draw_header_footer, header_text=header_text, footer_text=footer_text, title=title)
296
+ page_template = PageTemplate(id='main_template', frames=frames, onPage=header_footer_callback)
297
+ doc.addPageTemplates([page_template])
298
+
299
+ doc.build(story)
300
+ md_buffer.seek(0)
301
+ merger.append(fileobj=md_buffer)
302
+
303
+ for img_path in image_files:
304
+ try:
305
+ with Image.open(img_path) as img: img_width_px, img_height_px = img.size
306
+ img_width_pt, img_height_pt = img_width_px * (inch / 72), img_height_px * (inch / 72)
 
307
  img_buffer = io.BytesIO()
308
+ img_doc = SimpleDocTemplate(img_buffer, pagesize=(img_width_pt, img_height_pt), leftMargin=0, rightMargin=0, topMargin=0, bottomMargin=0)
309
+ img_doc.build([ReportLabImage(img_path, width=img_width_pt, height=img_height_pt)])
310
+ img_buffer.seek(0)
311
  merger.append(fileobj=img_buffer)
312
+ except Exception as e: log_updates += f"โš ๏ธ Failed to process image {img_path.name}: {e}\n"
313
+
314
+ if len(merger.pages) > 0:
315
+ time_str = datetime.datetime.now().strftime('%H%M%S')
316
+ clean_layout = layout_name.replace(' ', '')
317
+ filename = f"Scroll_{clean_layout}_{font_name}_x{num_columns}_{time_str}.pdf"
318
+ output_path = OUTPUT_DIR / filename
319
+ with open(output_path, "wb") as f: merger.write(f)
320
+ generated_pdf_paths.append(output_path)
321
+ log_updates += f"โœ… Successfully alchemized: {filename}\n"
322
 
323
  gallery_previews = [create_pdf_preview(p) for p in generated_pdf_paths]
324
  final_gallery = [g for g in gallery_previews if g is not None]
325
 
326
+ return final_gallery, log_updates if log_updates else "โœจ All scrolls alchemized successfully! โœจ", [str(p) for p in generated_pdf_paths]
327
 
328
  # --- Gradio UI Definition ---
329
  AVAILABLE_FONTS, EMOJI_FONT_NAME = register_local_fonts()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
+ # A custom, magical theme
332
+ theme = gr.themes.Base(
333
+ primary_hue=gr.themes.colors.purple,
334
+ secondary_hue=gr.themes.colors.indigo,
335
+ neutral_hue=gr.themes.colors.gray,
336
+ font=(gr.themes.GoogleFont("MedievalSharp"), "ui-sans-serif", "system-ui", "sans-serif"),
337
+ ).set(
338
+ body_background_fill="#111827",
339
+ body_background_fill_dark="#111827",
340
+ body_text_color="#d1d5db",
341
+ body_text_color_dark="#d1d5db",
342
+ button_primary_background_fill="#a855f7",
343
+ button_primary_background_fill_dark="#a855f7",
344
+ button_primary_text_color="#ffffff",
345
+ button_primary_text_color_dark="#ffffff",
346
+ button_secondary_background_fill="#6366f1",
347
+ button_secondary_background_fill_dark="#6366f1",
348
+ button_secondary_text_color="#ffffff",
349
+ button_secondary_text_color_dark="#ffffff",
350
+ block_background_fill="#1f2937",
351
+ block_background_fill_dark="#1f2937",
352
+ block_label_background_fill="#1f2937",
353
+ block_label_background_fill_dark="#1f2937",
354
+ block_title_text_color="#a855f7",
355
+ block_title_text_color_dark="#a855f7",
356
+ input_background_fill="#374151",
357
+ input_background_fill_dark="#374151",
358
+ )
359
+
360
+ with gr.Blocks(theme=theme, title="The PDF Alchemist") as demo:
361
+ gr.Markdown("# โœจ The PDF Alchemist โœจ")
362
+ gr.Markdown("Turn your ideas into beautifully crafted PDF scrolls. Use the power of AI or upload your own treasures.")
363
+
364
+ with gr.Row(equal_height=False):
365
  with gr.Column(scale=2):
366
+ with gr.Tabs():
367
+ with gr.TabItem("โ‘  Conjure Your Content ๐Ÿ“œ"):
368
+ gr.Markdown("### ๐Ÿค– The Idea Golem")
369
+ ai_prompt = gr.Textbox(label="Command the Golem", placeholder="e.g., 'A recipe for a dragon's breath chili...'")
370
+ generate_ai_btn = gr.Button("๐Ÿง  Animate Golem!")
371
+ ai_content_output = gr.Textbox(label="Golem's Manuscript (Editable)", lines=15, interactive=True)
372
+ gr.Markdown("<hr style='border-color: #374151;'>")
373
+ gr.Markdown("### ๐Ÿ“ค Or, Upload Your Treasures")
374
+ uploaded_files = gr.File(label="Upload Images (.png, .jpg)", file_count="multiple", file_types=[".png", ".jpg", ".jpeg"])
375
+ gr.Markdown("*<p style='font-size:0.8rem; color: #9ca3af;'>Note: The Golem's manuscript takes precedence. Images are always added.</p>*")
376
+
377
+ with gr.TabItem("โ‘ก Define Your Canvas ๐ŸŽจ"):
378
+ gr.Markdown("### ๐Ÿ“ Choose Your Scroll")
379
+ selected_layouts = gr.CheckboxGroup(choices=list(LAYOUTS.keys()), label="Page Layouts", value=["A4 Portrait"])
380
+ num_columns_slider = gr.Slider(label="Number of Text Columns", minimum=1, maximum=4, step=1, value=1)
381
+
382
+ gr.Markdown("### โœ๏ธ Scribe Your Margins")
383
+ gr.Markdown("<p style='font-size:0.8rem; color: #9ca3af;'>Use `[Page #]` and `[Title]`. `[Total Pages]` works best in the footer.</p>")
384
+ header_input = gr.Textbox(label="Header Inscription", value="[Title]", placeholder="e.g., Arcane Folio - [Page #]")
385
+ footer_input = gr.Textbox(label="Footer Inscription", value="Page [Page #] of [Total Pages]", placeholder="e.g., Top Secret - Page [Page #]")
386
+
387
+ with gr.TabItem("โ‘ข Perfect Your Style ๐Ÿ’…"):
388
+ gr.Markdown("### ๐Ÿ”ค Master the Glyphs")
389
+ selected_fonts = gr.CheckboxGroup(choices=AVAILABLE_FONTS, label="Fonts", value=[AVAILABLE_FONTS[0]] if AVAILABLE_FONTS else [])
390
+ font_size_body_slider = gr.Slider(label="Body Text Size (pt)", minimum=8, maximum=16, step=1, value=10)
391
+ with gr.Row():
392
+ font_size_h1_slider = gr.Slider(label="H1 Size", minimum=16, maximum=32, step=1, value=24)
393
+ font_size_h2_slider = gr.Slider(label="H2 Size", minimum=14, maximum=28, step=1, value=18)
394
+ font_size_h3_slider = gr.Slider(label="H3 Size", minimum=12, maximum=24, step=1, value=14)
395
+
396
+ gr.Markdown("### ๐Ÿ“ Set Your Boundaries (inches)")
397
+ with gr.Row():
398
+ margin_top_slider = gr.Slider(label="Top", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
399
+ margin_bottom_slider = gr.Slider(label="Bottom", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
400
+ with gr.Row():
401
+ margin_left_slider = gr.Slider(label="Left", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
402
+ margin_right_slider = gr.Slider(label="Right", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
403
+
404
+ generate_pdfs_btn = gr.Button("๐Ÿ”ฎ Alchemize PDF!", variant="primary", size="lg")
405
+
406
+ with gr.Column(scale=3):
407
+ gr.Markdown("### ๐Ÿ–ผ๏ธ The Scrying Pool (Previews)")
408
+ gallery_output = gr.Gallery(label="Generated PDF Previews", show_label=False, elem_id="gallery", columns=2, height=700, object_fit="contain")
409
+ log_output = gr.Markdown(label="Alchemist's Log", value="Your log of successful transmutations will appear here...")
410
+ downloadable_files_output = gr.Files(label="Collect Your Scrolls")
411
 
412
+ # --- API Calls & Examples ---
413
+ generate_ai_btn.click(fn=generate_ai_content_api, inputs=[ai_prompt], outputs=[ai_content_output])
414
+
415
+ 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]
416
+ outputs_list = [gallery_output, log_output, downloadable_files_output]
417
+ generate_pdfs_btn.click(fn=generate_pdfs_api, inputs=inputs_list, outputs=outputs_list)
418
+
419
+ gr.Examples(
420
+ examples=[["A technical summary of how alchemy works"], ["A short poem about a grumpy gnome"], ["A sample agenda for a wizard's council meeting"]],
421
+ inputs=[ai_prompt], outputs=[ai_content_output], fn=generate_ai_content_api, cache_examples=False
422
+ )
423
 
424
  if __name__ == "__main__":
425
+ if not (FONT_DIR / "NotoColorEmoji-Regular.ttf").exists():
426
+ print("\n" + "="*80)
427
+ print("WARNING: 'NotoColorEmoji-Regular.ttf' not found.")
428
+ print("Please download it from Google Fonts and place it in the script's directory for emojis to render correctly.")
429
+ print("="*80 + "\n")
430
+ if not any("MedievalSharp" in s for s in AVAILABLE_FONTS):
431
+ print("\n" + "="*80)
432
+ print("WARNING: 'MedievalSharp' font not found for the default theme.")
433
+ print("Please download it from Google Fonts to get the intended 'alchemist' look.")
434
+ print("The app will fall back to another font.")
435
+ print("="*80 + "\n")
436
+
437
+ demo.launch(debug=True)