262 lines
11 KiB
Python
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 |