v4.3 ui: 3D wooden bookshelf redesign, fix player cover aspect ratio, and smooth subtitle scroll
This commit is contained in:
262
thumbnail_generator.py
Normal file
262
thumbnail_generator.py
Normal file
@@ -0,0 +1,262 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user