Files
audiobook-maker-pro-v4.2/thumbnail_generator.py

262 lines
11 KiB
Python

# 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