# thumbnail_generator.py - Auto thumbnail generation from document first page (v4.4) # # PDF → PyMuPDF (fitz) দিয়ে প্রথম পেজ রেন্ডার করে PNG থাম্বনেইল # DOCX → docProps/thumbnail.* (embedded preview) অথবা প্রথম embedded image # # আউটপুট সবসময় optimize করা bytes (PNG/JPEG), যা media_storage.save_thumbnail() এ যাবে। import io import zipfile # থাম্বনেইলের টার্গেট সাইজ (বইয়ের কভার — portrait 2:3 ratio) THUMB_MAX_WIDTH = 600 THUMB_MAX_HEIGHT = 900 JPEG_QUALITY = 82 def _optimize_image_bytes(img_bytes, source_format='png'): """ Pillow থাকলে রিসাইজ + কম্প্রেস করে। না থাকলে raw bytes-ই ফেরত দেয়। রিটার্ন: (optimized_bytes, format_str) """ try: from PIL import Image except ImportError: return img_bytes, source_format try: img = Image.open(io.BytesIO(img_bytes)) # RGBA/palette → RGB (JPEG এর জন্য) if img.mode in ('RGBA', 'LA', 'P'): background = Image.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') if img.mode in ('RGBA', 'LA'): background.paste(img, mask=img.split()[-1]) img = background else: img = img.convert('RGB') elif img.mode != 'RGB': img = img.convert('RGB') # অনুপাত ধরে রেখে থাম্বনেইল সাইজে নামানো img.thumbnail((THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), Image.LANCZOS) out = io.BytesIO() img.save(out, format='JPEG', quality=JPEG_QUALITY, optimize=True) out.seek(0) return out.read(), 'jpeg' except Exception as e: print(f" ⚠️ Thumbnail optimize failed: {e}") return img_bytes, source_format def generate_pdf_thumbnail(pdf_bytes): """ PDF-এর প্রথম পেজ রেন্ডার করে optimize করা থাম্বনেইল bytes ফেরত দেয়। রিটার্ন: (bytes, format) অথবা (None, None) """ try: import fitz # PyMuPDF except ImportError: print(" ⚠️ PyMuPDF not available for thumbnail") return None, None try: doc = fitz.open(stream=pdf_bytes, filetype="pdf") if doc.page_count == 0: doc.close() return None, None page = doc.load_page(0) # রেন্ডার রেজোলিউশন — টার্গেট উচ্চতার উপর ভিত্তি করে zoom নির্ধারণ page_height = page.rect.height or 792 zoom = max(1.0, min(3.0, (THUMB_MAX_HEIGHT * 1.3) / page_height)) matrix = fitz.Matrix(zoom, zoom) pix = page.get_pixmap(matrix=matrix, alpha=False) png_bytes = pix.tobytes("png") doc.close() return _optimize_image_bytes(png_bytes, 'png') except Exception as e: print(f" ⚠️ PDF thumbnail generation failed: {e}") return None, None def generate_docx_thumbnail(docx_bytes, extracted_blocks=None): """ DOCX থেকে থাম্বনেইল বানানোর চেষ্টা করে (রেন্ডারিং লাইব্রেরি ছাড়া)। কৌশল: 1. docProps/thumbnail.* (Word এ Save করার সময় "Save Thumbnail" অন থাকলে) 2. প্রথম embedded image (word/media/) — যদি ছবিটি যথেষ্ট বড় হয় 3. extracted_blocks থেকে প্রথম image block-এর base64 data রিটার্ন: (bytes, format) অথবা (None, None) """ # কৌশল ১ + ২: zip আর্কাইভ থেকে try: with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf: names = zf.namelist() # ১. embedded thumbnail for name in names: lower = name.lower() if lower.startswith('docprops/thumbnail'): data = zf.read(name) if data and len(data) > 500: fmt = lower.rsplit('.', 1)[-1] if '.' in lower else 'png' return _optimize_image_bytes(data, fmt) # ২. word/media/ থেকে প্রথম বড় ইমেজ media = sorted([n for n in names if n.lower().startswith('word/media/')]) for name in media: lower = name.lower() if not any(lower.endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')): continue data = zf.read(name) # ছোট আইকন/লোগো এড়াতে ন্যূনতম সাইজ চেক if data and len(data) > 8000: fmt = lower.rsplit('.', 1)[-1] return _optimize_image_bytes(data, fmt) except Exception as e: print(f" ⚠️ DOCX zip thumbnail failed: {e}") # কৌশল ৩: প্রসেস করা blocks থেকে প্রথম ইমেজ if extracted_blocks: import base64 for block in extracted_blocks: if block.get('type') == 'image' and block.get('data'): try: raw = base64.b64decode(block['data']) if len(raw) > 4000: return _optimize_image_bytes(raw, block.get('format', 'png')) except Exception: continue return None, None # টাইটেলের হ্যাশ থেকে ধারাবাহিক রঙ বাছাই করার জন্য কভার প্যালেট _COVER_PALETTES = [ ((37, 52, 74), (62, 84, 120)), # নীল ((58, 42, 74), (92, 66, 120)), # বেগুনি ((44, 62, 55), (66, 98, 82)), # সবুজ ((74, 44, 42), (120, 72, 66)), # লালচে বাদামি ((44, 54, 74), (70, 88, 120)), # স্টিল ব্লু ((60, 50, 40), (110, 90, 66)), # সেপিয়া ] def generate_text_cover(title, author='', subtitle=''): """ Pillow দিয়ে একটা পরিপাটি টেক্সট-ভিত্তিক বইয়ের কভার তৈরি করে। কোনো ইমেজ না থাকলে fallback হিসেবে ব্যবহৃত হয়। রিটার্ন: (bytes, 'jpeg') অথবা (None, None) """ try: from PIL import Image, ImageDraw, ImageFont except ImportError: return None, None try: W, H = THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT # টাইটেল অনুযায়ী ধারাবাহিক রঙ (একই বই সবসময় একই রঙ পাবে) clean_title = (title or 'Untitled').strip() palette_idx = sum(ord(c) for c in clean_title) % len(_COVER_PALETTES) top_color, bottom_color = _COVER_PALETTES[palette_idx] img = Image.new('RGB', (W, H), top_color) draw = ImageDraw.Draw(img) for y in range(H): ratio = y / H r = int(top_color[0] + (bottom_color[0] - top_color[0]) * ratio) g = int(top_color[1] + (bottom_color[1] - top_color[1]) * ratio) b = int(top_color[2] + (bottom_color[2] - top_color[2]) * ratio) draw.line([(0, y), (W, y)], fill=(r, g, b)) # ডাবল বর্ডার ফ্রেম draw.rectangle([26, 26, W - 26, H - 26], outline=(255, 255, 255), width=2) draw.rectangle([36, 36, W - 36, H - 36], outline=(255, 255, 255), width=1) # ফন্ট লোড def _font(size): for name in ("DejaVuSans-Bold.ttf", "arial.ttf", "Arial Bold.ttf"): try: return ImageFont.truetype(name, size) except Exception: continue return ImageFont.load_default() def _font_light(size): for name in ("DejaVuSans.ttf", "arial.ttf"): try: return ImageFont.truetype(name, size) except Exception: continue return ImageFont.load_default() title_font = _font(48) author_font = _font_light(26) def _text_w(text, font): bbox = draw.textbbox((0, 0), text, font=font) return bbox[2] - bbox[0] # উপরে ডেকোরেটিভ ডাবল-লাইন সেপারেটর deco_y = 130 draw.line([(W // 2 - 60, deco_y), (W // 2 + 60, deco_y)], fill=(255, 255, 255), width=2) draw.line([(W // 2 - 40, deco_y + 10), (W // 2 + 40, deco_y + 10)], fill=(255, 255, 255), width=1) # টাইটেল word-wrap (কেন্দ্রে) def _wrap(text, font, max_width): words = text.split() lines, cur = [], '' for w in words: test = (cur + ' ' + w).strip() if _text_w(test, font) <= max_width: cur = test else: if cur: lines.append(cur) cur = w if cur: lines.append(cur) return lines[:6] title_lines = _wrap(clean_title, title_font, W - 110) line_h = 60 total_h = len(title_lines) * line_h y = (H - total_h) // 2 - 20 for line in title_lines: lw = _text_w(line, title_font) # হালকা shadow (গভীরতার জন্য) draw.text(((W - lw) // 2 + 2, y + 2), line, font=title_font, fill=(0, 0, 0)) draw.text(((W - lw) // 2, y), line, font=title_font, fill=(255, 255, 255)) y += line_h # নিচে সেপারেটর + author bottom_y = H - 130 draw.line([(W // 2 - 50, bottom_y), (W // 2 + 50, bottom_y)], fill=(255, 255, 255), width=1) author_text = f"by {author}" if author else "Audiobook" aw = _text_w(author_text, author_font) draw.text(((W - aw) // 2, bottom_y + 20), author_text, font=author_font, fill=(215, 222, 230)) out = io.BytesIO() img.save(out, format='JPEG', quality=JPEG_QUALITY, optimize=True) out.seek(0) return out.read(), 'jpeg' except Exception as e: print(f" ⚠️ Text cover generation failed: {e}") return None, None