forked from pkihslo9j/psrGROUP
Compare commits
29 Commits
psr_branch
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
ddfb1f6e83 | 2 months ago |
|
|
a3b72238a1 | 3 months ago |
|
|
a71cfd113a | 3 months ago |
|
|
d28e53897f | 3 months ago |
|
|
b95a509488 | 3 months ago |
|
|
bc729a032d | 3 months ago |
|
|
249d6712ed | 3 months ago |
|
|
d6da912377 | 3 months ago |
|
|
d7da121011 | 3 months ago |
|
|
f37e11cd3e | 3 months ago |
|
|
fa398d24b7 | 3 months ago |
|
|
9988116d87 | 3 months ago |
|
|
e11b380b55 | 3 months ago |
|
|
d42b36829d | 3 months ago |
|
|
79735ba0ab | 3 months ago |
|
|
b54c27d5cb | 3 months ago |
|
|
3661fa48d2 | 3 months ago |
|
|
5c1b3219ad | 3 months ago |
|
|
11d2bd5f8c | 4 months ago |
|
|
31b3a7b605 | 4 months ago |
|
|
a6290b5949 | 4 months ago |
|
|
d9a08ef647 | 4 months ago |
|
|
e121423bca | 4 months ago |
|
|
d111c7a056 | 4 months ago |
|
|
d5e7854e9d | 4 months ago |
|
|
3f4ed1b2e4 | 4 months ago |
|
|
2d7b22ab59 | 4 months ago |
|
|
0b2ffabb26 | 4 months ago |
|
|
227b4efc03 | 4 months ago |
@ -1,59 +0,0 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.forms import UserChangeForm
|
||||
from django.contrib.auth.forms import UsernameField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Register your models here.
|
||||
from .models import BlogUser
|
||||
|
||||
|
||||
class BlogUserCreationForm(forms.ModelForm):
|
||||
password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
|
||||
password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
|
||||
|
||||
class Meta:
|
||||
model = BlogUser
|
||||
fields = ('email',)
|
||||
|
||||
def clean_password2(self):
|
||||
# Check that the two password entries match
|
||||
password1 = self.cleaned_data.get("password1")
|
||||
password2 = self.cleaned_data.get("password2")
|
||||
if password1 and password2 and password1 != password2:
|
||||
raise forms.ValidationError(_("passwords do not match"))
|
||||
return password2
|
||||
|
||||
def save(self, commit=True):
|
||||
# Save the provided password in hashed format
|
||||
user = super().save(commit=False)
|
||||
user.set_password(self.cleaned_data["password1"])
|
||||
if commit:
|
||||
user.source = 'adminsite'
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class BlogUserChangeForm(UserChangeForm):
|
||||
class Meta:
|
||||
model = BlogUser
|
||||
fields = '__all__'
|
||||
field_classes = {'username': UsernameField}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class BlogUserAdmin(UserAdmin):
|
||||
form = BlogUserChangeForm
|
||||
add_form = BlogUserCreationForm
|
||||
list_display = (
|
||||
'id',
|
||||
'nickname',
|
||||
'username',
|
||||
'email',
|
||||
'last_login',
|
||||
'date_joined',
|
||||
'source')
|
||||
list_display_links = ('id', 'username')
|
||||
ordering = ('-id',)
|
||||
@ -1,5 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = 'accounts'
|
||||
@ -1,35 +0,0 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from djangoblog.utils import get_current_site
|
||||
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class BlogUser(AbstractUser):
|
||||
nickname = models.CharField(_('nick name'), max_length=100, blank=True)
|
||||
creation_time = models.DateTimeField(_('creation time'), default=now)
|
||||
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
|
||||
source = models.CharField(_('create source'), max_length=100, blank=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
'blog:author_detail', kwargs={
|
||||
'author_name': self.username})
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
def get_full_url(self):
|
||||
site = get_current_site().domain
|
||||
url = "https://{site}{path}".format(site=site,
|
||||
path=self.get_absolute_url())
|
||||
return url
|
||||
|
||||
class Meta:
|
||||
ordering = ['-id']
|
||||
verbose_name = _('user')
|
||||
verbose_name_plural = verbose_name
|
||||
get_latest_by = 'id'
|
||||
@ -1,28 +0,0 @@
|
||||
from django.urls import path
|
||||
from django.urls import re_path
|
||||
|
||||
from . import views
|
||||
from .forms import LoginForm
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [re_path(r'^login/$',
|
||||
views.LoginView.as_view(success_url='/'),
|
||||
name='login',
|
||||
kwargs={'authentication_form': LoginForm}),
|
||||
re_path(r'^register/$',
|
||||
views.RegisterView.as_view(success_url="/"),
|
||||
name='register'),
|
||||
re_path(r'^logout/$',
|
||||
views.LogoutView.as_view(),
|
||||
name='logout'),
|
||||
path(r'account/result.html',
|
||||
views.account_result,
|
||||
name='result'),
|
||||
re_path(r'^forget_password/$',
|
||||
views.ForgetPasswordView.as_view(),
|
||||
name='forget_password'),
|
||||
re_path(r'^forget_password_code/$',
|
||||
views.ForgetPasswordEmailCode.as_view(),
|
||||
name='forget_password_code'),
|
||||
]
|
||||
@ -1,112 +0,0 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Register your models here.
|
||||
from .models import Article
|
||||
|
||||
|
||||
class ArticleForm(forms.ModelForm):
|
||||
# body = forms.CharField(widget=AdminPagedownWidget())
|
||||
|
||||
class Meta:
|
||||
model = Article
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
def makr_article_publish(modeladmin, request, queryset):
|
||||
queryset.update(status='p')
|
||||
|
||||
|
||||
def draft_article(modeladmin, request, queryset):
|
||||
queryset.update(status='d')
|
||||
|
||||
|
||||
def close_article_commentstatus(modeladmin, request, queryset):
|
||||
queryset.update(comment_status='c')
|
||||
|
||||
|
||||
def open_article_commentstatus(modeladmin, request, queryset):
|
||||
queryset.update(comment_status='o')
|
||||
|
||||
|
||||
makr_article_publish.short_description = _('Publish selected articles')
|
||||
draft_article.short_description = _('Draft selected articles')
|
||||
close_article_commentstatus.short_description = _('Close article comments')
|
||||
open_article_commentstatus.short_description = _('Open article comments')
|
||||
|
||||
|
||||
class ArticlelAdmin(admin.ModelAdmin):
|
||||
list_per_page = 20
|
||||
search_fields = ('body', 'title')
|
||||
form = ArticleForm
|
||||
list_display = (
|
||||
'id',
|
||||
'title',
|
||||
'author',
|
||||
'link_to_category',
|
||||
'creation_time',
|
||||
'views',
|
||||
'status',
|
||||
'type',
|
||||
'article_order')
|
||||
list_display_links = ('id', 'title')
|
||||
list_filter = ('status', 'type', 'category')
|
||||
filter_horizontal = ('tags',)
|
||||
exclude = ('creation_time', 'last_modify_time')
|
||||
view_on_site = True
|
||||
actions = [
|
||||
makr_article_publish,
|
||||
draft_article,
|
||||
close_article_commentstatus,
|
||||
open_article_commentstatus]
|
||||
|
||||
def link_to_category(self, obj):
|
||||
info = (obj.category._meta.app_label, obj.category._meta.model_name)
|
||||
link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
|
||||
return format_html(u'<a href="%s">%s</a>' % (link, obj.category.name))
|
||||
|
||||
link_to_category.short_description = _('category')
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
|
||||
form.base_fields['author'].queryset = get_user_model(
|
||||
).objects.filter(is_superuser=True)
|
||||
return form
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
super(ArticlelAdmin, self).save_model(request, obj, form, change)
|
||||
|
||||
def get_view_on_site_url(self, obj=None):
|
||||
if obj:
|
||||
url = obj.get_full_url()
|
||||
return url
|
||||
else:
|
||||
from djangoblog.utils import get_current_site
|
||||
site = get_current_site().domain
|
||||
return site
|
||||
|
||||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
exclude = ('slug', 'last_mod_time', 'creation_time')
|
||||
|
||||
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'parent_category', 'index')
|
||||
exclude = ('slug', 'last_mod_time', 'creation_time')
|
||||
|
||||
|
||||
class LinksAdmin(admin.ModelAdmin):
|
||||
exclude = ('last_mod_time', 'creation_time')
|
||||
|
||||
|
||||
class SideBarAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'content', 'is_enable', 'sequence')
|
||||
exclude = ('last_mod_time', 'creation_time')
|
||||
|
||||
|
||||
class BlogSettingsAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
@ -1,5 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
name = 'blog'
|
||||
@ -1,43 +0,0 @@
|
||||
import logging
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from djangoblog.utils import cache, get_blog_setting
|
||||
from .models import Category, Article
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seo_processor(requests):
|
||||
key = 'seo_processor'
|
||||
value = cache.get(key)
|
||||
if value:
|
||||
return value
|
||||
else:
|
||||
logger.info('set processor cache.')
|
||||
setting = get_blog_setting()
|
||||
value = {
|
||||
'SITE_NAME': setting.site_name,
|
||||
'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
|
||||
'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
|
||||
'SITE_SEO_DESCRIPTION': setting.site_seo_description,
|
||||
'SITE_DESCRIPTION': setting.site_description,
|
||||
'SITE_KEYWORDS': setting.site_keywords,
|
||||
'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
|
||||
'ARTICLE_SUB_LENGTH': setting.article_sub_length,
|
||||
'nav_category_list': Category.objects.all(),
|
||||
'nav_pages': Article.objects.filter(
|
||||
type='p',
|
||||
status='p'),
|
||||
'OPEN_SITE_COMMENT': setting.open_site_comment,
|
||||
'BEIAN_CODE': setting.beian_code,
|
||||
'ANALYTICS_CODE': setting.analytics_code,
|
||||
"BEIAN_CODE_GONGAN": setting.gongan_beiancode,
|
||||
"SHOW_GONGAN_CODE": setting.show_gongan_code,
|
||||
"CURRENT_YEAR": timezone.now().year,
|
||||
"GLOBAL_HEADER": setting.global_header,
|
||||
"GLOBAL_FOOTER": setting.global_footer,
|
||||
"COMMENT_NEED_REVIEW": setting.comment_need_review,
|
||||
}
|
||||
cache.set(key, value, 60 * 60 * 10)
|
||||
return value
|
||||
@ -1,19 +0,0 @@
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from haystack.forms import SearchForm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlogSearchForm(SearchForm):
|
||||
querydata = forms.CharField(required=True)
|
||||
|
||||
def search(self):
|
||||
datas = super(BlogSearchForm, self).search()
|
||||
if not self.is_valid():
|
||||
return self.no_query_found()
|
||||
|
||||
if self.cleaned_data['querydata']:
|
||||
logger.info(self.cleaned_data['querydata'])
|
||||
return datas
|
||||
@ -1,18 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
|
||||
ELASTICSEARCH_ENABLED
|
||||
|
||||
|
||||
# TODO 参数化
|
||||
class Command(BaseCommand):
|
||||
help = 'build search index'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if ELASTICSEARCH_ENABLED:
|
||||
ElaspedTimeDocumentManager.build_index()
|
||||
manager = ElapsedTimeDocument()
|
||||
manager.init()
|
||||
manager = ArticleDocumentManager()
|
||||
manager.delete_index()
|
||||
manager.rebuild()
|
||||
@ -1,13 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from blog.models import Tag, Category
|
||||
|
||||
|
||||
# TODO 参数化
|
||||
class Command(BaseCommand):
|
||||
help = 'build search words'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
datas = set([t.name for t in Tag.objects.all()] +
|
||||
[t.name for t in Category.objects.all()])
|
||||
print('\n'.join(datas))
|
||||
@ -1,11 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from djangoblog.utils import cache
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'clear the whole cache'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
cache.clear()
|
||||
self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
|
||||
@ -1,40 +0,0 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from blog.models import Article, Tag, Category
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'create test datas'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
user = get_user_model().objects.get_or_create(
|
||||
email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
|
||||
|
||||
pcategory = Category.objects.get_or_create(
|
||||
name='我是父类目', parent_category=None)[0]
|
||||
|
||||
category = Category.objects.get_or_create(
|
||||
name='子类目', parent_category=pcategory)[0]
|
||||
|
||||
category.save()
|
||||
basetag = Tag()
|
||||
basetag.name = "标签"
|
||||
basetag.save()
|
||||
for i in range(1, 20):
|
||||
article = Article.objects.get_or_create(
|
||||
category=category,
|
||||
title='nice title ' + str(i),
|
||||
body='nice content ' + str(i),
|
||||
author=user)[0]
|
||||
tag = Tag()
|
||||
tag.name = "标签" + str(i)
|
||||
tag.save()
|
||||
article.tags.add(tag)
|
||||
article.tags.add(basetag)
|
||||
article.save()
|
||||
|
||||
from djangoblog.utils import cache
|
||||
cache.clear()
|
||||
self.stdout.write(self.style.SUCCESS('created test datas \n'))
|
||||
@ -1,50 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from djangoblog.spider_notify import SpiderNotify
|
||||
from djangoblog.utils import get_current_site
|
||||
from blog.models import Article, Tag, Category
|
||||
|
||||
site = get_current_site().domain
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'notify baidu url'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'data_type',
|
||||
type=str,
|
||||
choices=[
|
||||
'all',
|
||||
'article',
|
||||
'tag',
|
||||
'category'],
|
||||
help='article : all article,tag : all tag,category: all category,all: All of these')
|
||||
|
||||
def get_full_url(self, path):
|
||||
url = "https://{site}{path}".format(site=site, path=path)
|
||||
return url
|
||||
|
||||
def handle(self, *args, **options):
|
||||
type = options['data_type']
|
||||
self.stdout.write('start get %s' % type)
|
||||
|
||||
urls = []
|
||||
if type == 'article' or type == 'all':
|
||||
for article in Article.objects.filter(status='p'):
|
||||
urls.append(article.get_full_url())
|
||||
if type == 'tag' or type == 'all':
|
||||
for tag in Tag.objects.all():
|
||||
url = tag.get_absolute_url()
|
||||
urls.append(self.get_full_url(url))
|
||||
if type == 'category' or type == 'all':
|
||||
for category in Category.objects.all():
|
||||
url = category.get_absolute_url()
|
||||
urls.append(self.get_full_url(url))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
'start notify %d urls' %
|
||||
len(urls)))
|
||||
SpiderNotify.baidu_notify(urls)
|
||||
self.stdout.write(self.style.SUCCESS('finish notify'))
|
||||
@ -1,47 +0,0 @@
|
||||
import requests
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.templatetags.static import static
|
||||
|
||||
from djangoblog.utils import save_user_avatar
|
||||
from oauth.models import OAuthUser
|
||||
from oauth.oauthmanager import get_manager_by_type
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'sync user avatar'
|
||||
|
||||
def test_picture(self, url):
|
||||
try:
|
||||
if requests.get(url, timeout=2).status_code == 200:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
def handle(self, *args, **options):
|
||||
static_url = static("../")
|
||||
users = OAuthUser.objects.all()
|
||||
self.stdout.write(f'开始同步{len(users)}个用户头像')
|
||||
for u in users:
|
||||
self.stdout.write(f'开始同步:{u.nickname}')
|
||||
url = u.picture
|
||||
if url:
|
||||
if url.startswith(static_url):
|
||||
if self.test_picture(url):
|
||||
continue
|
||||
else:
|
||||
if u.metadata:
|
||||
manage = get_manager_by_type(u.type)
|
||||
url = manage.get_picture(u.metadata)
|
||||
url = save_user_avatar(url)
|
||||
else:
|
||||
url = static('blog/img/avatar.png')
|
||||
else:
|
||||
url = save_user_avatar(url)
|
||||
else:
|
||||
url = static('blog/img/avatar.png')
|
||||
if url:
|
||||
self.stdout.write(
|
||||
f'结束同步:{u.nickname}.url:{url}')
|
||||
u.picture = url
|
||||
u.save()
|
||||
self.stdout.write('结束同步')
|
||||
@ -1,42 +0,0 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from ipware import get_client_ip
|
||||
from user_agents import parse
|
||||
|
||||
from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OnlineMiddleware(object):
|
||||
def __init__(self, get_response=None):
|
||||
self.get_response = get_response
|
||||
super().__init__()
|
||||
|
||||
def __call__(self, request):
|
||||
''' page render time '''
|
||||
start_time = time.time()
|
||||
response = self.get_response(request)
|
||||
http_user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
ip, _ = get_client_ip(request)
|
||||
user_agent = parse(http_user_agent)
|
||||
if not response.streaming:
|
||||
try:
|
||||
cast_time = time.time() - start_time
|
||||
if ELASTICSEARCH_ENABLED:
|
||||
time_taken = round((cast_time) * 1000, 2)
|
||||
url = request.path
|
||||
from django.utils import timezone
|
||||
ElaspedTimeDocumentManager.create(
|
||||
url=url,
|
||||
time_taken=time_taken,
|
||||
log_datetime=timezone.now(),
|
||||
useragent=user_agent,
|
||||
ip=ip)
|
||||
response.content = response.content.replace(
|
||||
b'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
|
||||
except Exception as e:
|
||||
logger.error("Error OnlineMiddleware: %s" % e)
|
||||
|
||||
return response
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-29 06:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('blog', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='blogsettings',
|
||||
name='global_footer',
|
||||
field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogsettings',
|
||||
name='global_header',
|
||||
field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
|
||||
),
|
||||
]
|
||||
@ -1,17 +0,0 @@
|
||||
# Generated by Django 4.2.1 on 2023-05-09 07:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('blog', '0002_blogsettings_global_footer_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='blogsettings',
|
||||
name='comment_need_review',
|
||||
field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
|
||||
),
|
||||
]
|
||||
@ -1,27 +0,0 @@
|
||||
# Generated by Django 4.2.1 on 2023-05-09 07:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('blog', '0003_blogsettings_comment_need_review'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='blogsettings',
|
||||
old_name='analyticscode',
|
||||
new_name='analytics_code',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='blogsettings',
|
||||
old_name='beiancode',
|
||||
new_name='beian_code',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='blogsettings',
|
||||
old_name='sitename',
|
||||
new_name='site_name',
|
||||
),
|
||||
]
|
||||
@ -1,13 +0,0 @@
|
||||
from haystack import indexes
|
||||
|
||||
from blog.models import Article
|
||||
|
||||
|
||||
class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
|
||||
text = indexes.CharField(document=True, use_template=True)
|
||||
|
||||
def get_model(self):
|
||||
return Article
|
||||
|
||||
def index_queryset(self, using=None):
|
||||
return self.get_model().objects.filter(status='p')
|
||||
@ -1,62 +0,0 @@
|
||||
from django.urls import path
|
||||
from django.views.decorators.cache import cache_page
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "blog"
|
||||
urlpatterns = [
|
||||
path(
|
||||
r'',
|
||||
views.IndexView.as_view(),
|
||||
name='index'),
|
||||
path(
|
||||
r'page/<int:page>/',
|
||||
views.IndexView.as_view(),
|
||||
name='index_page'),
|
||||
path(
|
||||
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
|
||||
views.ArticleDetailView.as_view(),
|
||||
name='detailbyid'),
|
||||
path(
|
||||
r'category/<slug:category_name>.html',
|
||||
views.CategoryDetailView.as_view(),
|
||||
name='category_detail'),
|
||||
path(
|
||||
r'category/<slug:category_name>/<int:page>.html',
|
||||
views.CategoryDetailView.as_view(),
|
||||
name='category_detail_page'),
|
||||
path(
|
||||
r'author/<author_name>.html',
|
||||
views.AuthorDetailView.as_view(),
|
||||
name='author_detail'),
|
||||
path(
|
||||
r'author/<author_name>/<int:page>.html',
|
||||
views.AuthorDetailView.as_view(),
|
||||
name='author_detail_page'),
|
||||
path(
|
||||
r'tag/<slug:tag_name>.html',
|
||||
views.TagDetailView.as_view(),
|
||||
name='tag_detail'),
|
||||
path(
|
||||
r'tag/<slug:tag_name>/<int:page>.html',
|
||||
views.TagDetailView.as_view(),
|
||||
name='tag_detail_page'),
|
||||
path(
|
||||
'archives.html',
|
||||
cache_page(
|
||||
60 * 60)(
|
||||
views.ArchivesView.as_view()),
|
||||
name='archives'),
|
||||
path(
|
||||
'links.html',
|
||||
views.LinkListView.as_view(),
|
||||
name='links'),
|
||||
path(
|
||||
r'upload',
|
||||
views.fileupload,
|
||||
name='upload'),
|
||||
path(
|
||||
r'clean',
|
||||
views.clean_cache_view,
|
||||
name='clean'),
|
||||
]
|
||||
@ -1,47 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def disable_commentstatus(modeladmin, request, queryset):
|
||||
queryset.update(is_enable=False)
|
||||
|
||||
|
||||
def enable_commentstatus(modeladmin, request, queryset):
|
||||
queryset.update(is_enable=True)
|
||||
|
||||
|
||||
disable_commentstatus.short_description = _('Disable comments')
|
||||
enable_commentstatus.short_description = _('Enable comments')
|
||||
|
||||
|
||||
class CommentAdmin(admin.ModelAdmin):
|
||||
list_per_page = 20
|
||||
list_display = (
|
||||
'id',
|
||||
'body',
|
||||
'link_to_userinfo',
|
||||
'link_to_article',
|
||||
'is_enable',
|
||||
'creation_time')
|
||||
list_display_links = ('id', 'body', 'is_enable')
|
||||
list_filter = ('is_enable',)
|
||||
exclude = ('creation_time', 'last_modify_time')
|
||||
actions = [disable_commentstatus, enable_commentstatus]
|
||||
|
||||
def link_to_userinfo(self, obj):
|
||||
info = (obj.author._meta.app_label, obj.author._meta.model_name)
|
||||
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
|
||||
return format_html(
|
||||
u'<a href="%s">%s</a>' %
|
||||
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
|
||||
|
||||
def link_to_article(self, obj):
|
||||
info = (obj.article._meta.app_label, obj.article._meta.model_name)
|
||||
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
|
||||
return format_html(
|
||||
u'<a href="%s">%s</a>' % (link, obj.article.title))
|
||||
|
||||
link_to_userinfo.short_description = _('User')
|
||||
link_to_article.short_description = _('Article')
|
||||
@ -1,5 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommentsConfig(AppConfig):
|
||||
name = 'comments'
|
||||
@ -1,39 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from blog.models import Article
|
||||
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class Comment(models.Model):
|
||||
body = models.TextField('正文', max_length=300)
|
||||
creation_time = models.DateTimeField(_('creation time'), default=now)
|
||||
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name=_('author'),
|
||||
on_delete=models.CASCADE)
|
||||
article = models.ForeignKey(
|
||||
Article,
|
||||
verbose_name=_('article'),
|
||||
on_delete=models.CASCADE)
|
||||
parent_comment = models.ForeignKey(
|
||||
'self',
|
||||
verbose_name=_('parent comment'),
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE)
|
||||
is_enable = models.BooleanField(_('enable'),
|
||||
default=False, blank=False, null=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-id']
|
||||
verbose_name = _('comment')
|
||||
verbose_name_plural = verbose_name
|
||||
get_latest_by = 'id'
|
||||
|
||||
def __str__(self):
|
||||
return self.body
|
||||
@ -1,11 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "comments"
|
||||
urlpatterns = [
|
||||
path(
|
||||
'article/<int:article_id>/postcomment',
|
||||
views.CommentPostView.as_view(),
|
||||
name='postcomment'),
|
||||
]
|
||||
@ -1,63 +0,0 @@
|
||||
# Create your views here.
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from accounts.models import BlogUser
|
||||
from blog.models import Article
|
||||
from .forms import CommentForm
|
||||
from .models import Comment
|
||||
|
||||
|
||||
class CommentPostView(FormView):
|
||||
form_class = CommentForm
|
||||
template_name = 'blog/article_detail.html'
|
||||
|
||||
@method_decorator(csrf_protect)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super(CommentPostView, self).dispatch(*args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
article_id = self.kwargs['article_id']
|
||||
article = get_object_or_404(Article, pk=article_id)
|
||||
url = article.get_absolute_url()
|
||||
return HttpResponseRedirect(url + "#comments")
|
||||
|
||||
def form_invalid(self, form):
|
||||
article_id = self.kwargs['article_id']
|
||||
article = get_object_or_404(Article, pk=article_id)
|
||||
|
||||
return self.render_to_response({
|
||||
'form': form,
|
||||
'article': article
|
||||
})
|
||||
|
||||
def form_valid(self, form):
|
||||
"""提交的数据验证合法后的逻辑"""
|
||||
user = self.request.user
|
||||
author = BlogUser.objects.get(pk=user.pk)
|
||||
article_id = self.kwargs['article_id']
|
||||
article = get_object_or_404(Article, pk=article_id)
|
||||
|
||||
if article.comment_status == 'c' or article.status == 'c':
|
||||
raise ValidationError("该文章评论已关闭.")
|
||||
comment = form.save(False)
|
||||
comment.article = article
|
||||
from djangoblog.utils import get_blog_setting
|
||||
settings = get_blog_setting()
|
||||
if not settings.comment_need_review:
|
||||
comment.is_enable = True
|
||||
comment.author = author
|
||||
|
||||
if form.cleaned_data['parent_comment_id']:
|
||||
parent_comment = Comment.objects.get(
|
||||
pk=form.cleaned_data['parent_comment_id'])
|
||||
comment.parent_comment = parent_comment
|
||||
|
||||
comment.save(True)
|
||||
return HttpResponseRedirect(
|
||||
"%s#div-comment-%d" %
|
||||
(article.get_absolute_url(), comment.pk))
|
||||
@ -1 +0,0 @@
|
||||
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
|
||||
@ -1,11 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class DjangoblogAppConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'djangoblog'
|
||||
|
||||
def ready(self):
|
||||
super().ready()
|
||||
# Import and load plugins here
|
||||
from .plugin_manage.loader import load_plugins
|
||||
load_plugins()
|
||||
@ -1,59 +0,0 @@
|
||||
from django.contrib.sitemaps import Sitemap
|
||||
from django.urls import reverse
|
||||
|
||||
from blog.models import Article, Category, Tag
|
||||
|
||||
|
||||
class StaticViewSitemap(Sitemap):
|
||||
priority = 0.5
|
||||
changefreq = 'daily'
|
||||
|
||||
def items(self):
|
||||
return ['blog:index', ]
|
||||
|
||||
def location(self, item):
|
||||
return reverse(item)
|
||||
|
||||
|
||||
class ArticleSiteMap(Sitemap):
|
||||
changefreq = "monthly"
|
||||
priority = "0.6"
|
||||
|
||||
def items(self):
|
||||
return Article.objects.filter(status='p')
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.last_modify_time
|
||||
|
||||
|
||||
class CategorySiteMap(Sitemap):
|
||||
changefreq = "Weekly"
|
||||
priority = "0.6"
|
||||
|
||||
def items(self):
|
||||
return Category.objects.all()
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.last_modify_time
|
||||
|
||||
|
||||
class TagSiteMap(Sitemap):
|
||||
changefreq = "Weekly"
|
||||
priority = "0.3"
|
||||
|
||||
def items(self):
|
||||
return Tag.objects.all()
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.last_modify_time
|
||||
|
||||
|
||||
class UserSiteMap(Sitemap):
|
||||
changefreq = "Weekly"
|
||||
priority = "0.3"
|
||||
|
||||
def items(self):
|
||||
return list(set(map(lambda x: x.author, Article.objects.all())))
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.date_joined
|
||||
@ -1,21 +0,0 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpiderNotify():
|
||||
@staticmethod
|
||||
def baidu_notify(urls):
|
||||
try:
|
||||
data = '\n'.join(urls)
|
||||
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
|
||||
logger.info(result.text)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
@staticmethod
|
||||
def notify(url):
|
||||
SpiderNotify.baidu_notify(url)
|
||||
@ -1,52 +0,0 @@
|
||||
{% extends 'share_layout/base.html' %}
|
||||
{% load blog_tags %}
|
||||
|
||||
{% block header %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div id="primary" class="site-content">
|
||||
<div id="content" role="main">
|
||||
{% load_article_detail article False user %}
|
||||
|
||||
{% if article.type == 'a' %}
|
||||
<nav class="nav-single">
|
||||
<h3 class="assistive-text">文章导航</h3>
|
||||
{% if next_article %}
|
||||
|
||||
<span class="nav-previous"><a href="{{ next_article.get_absolute_url }}" rel="prev"><span
|
||||
class="meta-nav">←</span> {{ next_article.title }}</a></span>
|
||||
{% endif %}
|
||||
{% if prev_article %}
|
||||
<span class="nav-next"><a href="{{ prev_article.get_absolute_url }}"
|
||||
rel="next">{{ prev_article.title }} <span
|
||||
class="meta-nav">→</span></a></span>
|
||||
{% endif %}
|
||||
</nav><!-- .nav-single -->
|
||||
{% endif %}
|
||||
|
||||
</div><!-- #content -->
|
||||
{% if article.comment_status == "o" and OPEN_SITE_COMMENT %}
|
||||
|
||||
|
||||
{% include 'comments/tags/comment_list.html' %}
|
||||
{% if user.is_authenticated %}
|
||||
{% include 'comments/tags/post_comment.html' %}
|
||||
{% else %}
|
||||
<div class="comments-area">
|
||||
<h3 class="comment-meta">您还没有登录,请您<a
|
||||
href="{% url "account:login" %}?next={{ request.get_full_path }}" rel="nofollow">登录</a>后发表评论。
|
||||
</h3>
|
||||
|
||||
{% load oauth_tags %}
|
||||
{% load_oauth_applications request %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div><!-- #primary -->
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% load_sidebar user "p" %}
|
||||
{% endblock %}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import IO
|
||||
|
||||
from . import ExifTags, Image, ImageFile
|
||||
|
||||
try:
|
||||
from . import _avif
|
||||
|
||||
SUPPORTED = True
|
||||
except ImportError:
|
||||
SUPPORTED = False
|
||||
|
||||
# Decoder options as module globals, until there is a way to pass parameters
|
||||
# to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
|
||||
DECODE_CODEC_CHOICE = "auto"
|
||||
DEFAULT_MAX_THREADS = 0
|
||||
|
||||
|
||||
def get_codec_version(codec_name: str) -> str | None:
|
||||
versions = _avif.codec_versions()
|
||||
for version in versions.split(", "):
|
||||
if version.split(" [")[0] == codec_name:
|
||||
return version.split(":")[-1].split(" ")[0]
|
||||
return None
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool | str:
|
||||
if prefix[4:8] != b"ftyp":
|
||||
return False
|
||||
major_brand = prefix[8:12]
|
||||
if major_brand in (
|
||||
# coding brands
|
||||
b"avif",
|
||||
b"avis",
|
||||
# We accept files with AVIF container brands; we can't yet know if
|
||||
# the ftyp box has the correct compatible brands, but if it doesn't
|
||||
# then the plugin will raise a SyntaxError which Pillow will catch
|
||||
# before moving on to the next plugin that accepts the file.
|
||||
#
|
||||
# Also, because this file might not actually be an AVIF file, we
|
||||
# don't raise an error if AVIF support isn't properly compiled.
|
||||
b"mif1",
|
||||
b"msf1",
|
||||
):
|
||||
if not SUPPORTED:
|
||||
return (
|
||||
"image file could not be identified because AVIF support not installed"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_default_max_threads() -> int:
|
||||
if DEFAULT_MAX_THREADS:
|
||||
return DEFAULT_MAX_THREADS
|
||||
if hasattr(os, "sched_getaffinity"):
|
||||
return len(os.sched_getaffinity(0))
|
||||
else:
|
||||
return os.cpu_count() or 1
|
||||
|
||||
|
||||
class AvifImageFile(ImageFile.ImageFile):
|
||||
format = "AVIF"
|
||||
format_description = "AVIF image"
|
||||
__frame = -1
|
||||
|
||||
def _open(self) -> None:
|
||||
if not SUPPORTED:
|
||||
msg = "image file could not be opened because AVIF support not installed"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
|
||||
DECODE_CODEC_CHOICE
|
||||
):
|
||||
msg = "Invalid opening codec"
|
||||
raise ValueError(msg)
|
||||
self._decoder = _avif.AvifDecoder(
|
||||
self.fp.read(),
|
||||
DECODE_CODEC_CHOICE,
|
||||
_get_default_max_threads(),
|
||||
)
|
||||
|
||||
# Get info from decoder
|
||||
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
|
||||
self._decoder.get_info()
|
||||
)
|
||||
self.is_animated = self.n_frames > 1
|
||||
|
||||
if icc:
|
||||
self.info["icc_profile"] = icc
|
||||
if xmp:
|
||||
self.info["xmp"] = xmp
|
||||
|
||||
if exif_orientation != 1 or exif:
|
||||
exif_data = Image.Exif()
|
||||
if exif:
|
||||
exif_data.load(exif)
|
||||
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
|
||||
else:
|
||||
original_orientation = 1
|
||||
if exif_orientation != original_orientation:
|
||||
exif_data[ExifTags.Base.Orientation] = exif_orientation
|
||||
exif = exif_data.tobytes()
|
||||
if exif:
|
||||
self.info["exif"] = exif
|
||||
self.seek(0)
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
|
||||
# Set tile
|
||||
self.__frame = frame
|
||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if self.tile:
|
||||
# We need to load the image data for this frame
|
||||
data, timescale, pts_in_timescales, duration_in_timescales = (
|
||||
self._decoder.get_frame(self.__frame)
|
||||
)
|
||||
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
|
||||
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
|
||||
|
||||
if self.fp and self._exclusive_fp:
|
||||
self.fp.close()
|
||||
self.fp = BytesIO(data)
|
||||
|
||||
return super().load()
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
pass
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.__frame
|
||||
|
||||
|
||||
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
_save(im, fp, filename, save_all=True)
|
||||
|
||||
|
||||
def _save(
|
||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
|
||||
) -> None:
|
||||
info = im.encoderinfo.copy()
|
||||
if save_all:
|
||||
append_images = list(info.get("append_images", []))
|
||||
else:
|
||||
append_images = []
|
||||
|
||||
total = 0
|
||||
for ims in [im] + append_images:
|
||||
total += getattr(ims, "n_frames", 1)
|
||||
|
||||
quality = info.get("quality", 75)
|
||||
if not isinstance(quality, int) or quality < 0 or quality > 100:
|
||||
msg = "Invalid quality setting"
|
||||
raise ValueError(msg)
|
||||
|
||||
duration = info.get("duration", 0)
|
||||
subsampling = info.get("subsampling", "4:2:0")
|
||||
speed = info.get("speed", 6)
|
||||
max_threads = info.get("max_threads", _get_default_max_threads())
|
||||
codec = info.get("codec", "auto")
|
||||
if codec != "auto" and not _avif.encoder_codec_available(codec):
|
||||
msg = "Invalid saving codec"
|
||||
raise ValueError(msg)
|
||||
range_ = info.get("range", "full")
|
||||
tile_rows_log2 = info.get("tile_rows", 0)
|
||||
tile_cols_log2 = info.get("tile_cols", 0)
|
||||
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
|
||||
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
|
||||
|
||||
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
|
||||
exif_orientation = 1
|
||||
if exif := info.get("exif"):
|
||||
if isinstance(exif, Image.Exif):
|
||||
exif_data = exif
|
||||
else:
|
||||
exif_data = Image.Exif()
|
||||
exif_data.load(exif)
|
||||
if ExifTags.Base.Orientation in exif_data:
|
||||
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
|
||||
exif = exif_data.tobytes() if exif_data else b""
|
||||
elif isinstance(exif, Image.Exif):
|
||||
exif = exif_data.tobytes()
|
||||
|
||||
xmp = info.get("xmp")
|
||||
|
||||
if isinstance(xmp, str):
|
||||
xmp = xmp.encode("utf-8")
|
||||
|
||||
advanced = info.get("advanced")
|
||||
if advanced is not None:
|
||||
if isinstance(advanced, dict):
|
||||
advanced = advanced.items()
|
||||
try:
|
||||
advanced = tuple(advanced)
|
||||
except TypeError:
|
||||
invalid = True
|
||||
else:
|
||||
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
|
||||
if invalid:
|
||||
msg = (
|
||||
"advanced codec options must be a dict of key-value string "
|
||||
"pairs or a series of key-value two-tuples"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Setup the AVIF encoder
|
||||
enc = _avif.AvifEncoder(
|
||||
im.size,
|
||||
subsampling,
|
||||
quality,
|
||||
speed,
|
||||
max_threads,
|
||||
codec,
|
||||
range_,
|
||||
tile_rows_log2,
|
||||
tile_cols_log2,
|
||||
alpha_premultiplied,
|
||||
autotiling,
|
||||
icc_profile or b"",
|
||||
exif or b"",
|
||||
exif_orientation,
|
||||
xmp or b"",
|
||||
advanced,
|
||||
)
|
||||
|
||||
# Add each frame
|
||||
frame_idx = 0
|
||||
frame_duration = 0
|
||||
cur_idx = im.tell()
|
||||
is_single_frame = total == 1
|
||||
try:
|
||||
for ims in [im] + append_images:
|
||||
# Get number of frames in this image
|
||||
nfr = getattr(ims, "n_frames", 1)
|
||||
|
||||
for idx in range(nfr):
|
||||
ims.seek(idx)
|
||||
|
||||
# Make sure image mode is supported
|
||||
frame = ims
|
||||
rawmode = ims.mode
|
||||
if ims.mode not in {"RGB", "RGBA"}:
|
||||
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
|
||||
frame = ims.convert(rawmode)
|
||||
|
||||
# Update frame duration
|
||||
if isinstance(duration, (list, tuple)):
|
||||
frame_duration = duration[frame_idx]
|
||||
else:
|
||||
frame_duration = duration
|
||||
|
||||
# Append the frame to the animation encoder
|
||||
enc.add(
|
||||
frame.tobytes("raw", rawmode),
|
||||
frame_duration,
|
||||
frame.size,
|
||||
rawmode,
|
||||
is_single_frame,
|
||||
)
|
||||
|
||||
# Update frame index
|
||||
frame_idx += 1
|
||||
|
||||
if not save_all:
|
||||
break
|
||||
|
||||
finally:
|
||||
im.seek(cur_idx)
|
||||
|
||||
# Get the final output from the encoder
|
||||
data = enc.finish()
|
||||
if data is None:
|
||||
msg = "cannot write file as AVIF (encoder returned None)"
|
||||
raise OSError(msg)
|
||||
|
||||
fp.write(data)
|
||||
|
||||
|
||||
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
|
||||
if SUPPORTED:
|
||||
Image.register_save(AvifImageFile.format, _save)
|
||||
Image.register_save_all(AvifImageFile.format, _save_all)
|
||||
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
|
||||
Image.register_mime(AvifImageFile.format, "image/avif")
|
||||
@ -0,0 +1,122 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# bitmap distribution font (bdf) file parser
|
||||
#
|
||||
# history:
|
||||
# 1996-05-16 fl created (as bdf2pil)
|
||||
# 1997-08-25 fl converted to FontFile driver
|
||||
# 2001-05-25 fl removed bogus __init__ call
|
||||
# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev)
|
||||
# 2003-04-22 fl more robustification (from Graham Dumpleton)
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||
# Copyright (c) 1997-2003 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
"""
|
||||
Parse X Bitmap Distribution Format (BDF)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import BinaryIO
|
||||
|
||||
from . import FontFile, Image
|
||||
|
||||
|
||||
def bdf_char(
|
||||
f: BinaryIO,
|
||||
) -> (
|
||||
tuple[
|
||||
str,
|
||||
int,
|
||||
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
|
||||
Image.Image,
|
||||
]
|
||||
| None
|
||||
):
|
||||
# skip to STARTCHAR
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s:
|
||||
return None
|
||||
if s.startswith(b"STARTCHAR"):
|
||||
break
|
||||
id = s[9:].strip().decode("ascii")
|
||||
|
||||
# load symbol properties
|
||||
props = {}
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s or s.startswith(b"BITMAP"):
|
||||
break
|
||||
i = s.find(b" ")
|
||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||
|
||||
# load bitmap
|
||||
bitmap = bytearray()
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s or s.startswith(b"ENDCHAR"):
|
||||
break
|
||||
bitmap += s[:-1]
|
||||
|
||||
# The word BBX
|
||||
# followed by the width in x (BBw), height in y (BBh),
|
||||
# and x and y displacement (BBxoff0, BByoff0)
|
||||
# of the lower left corner from the origin of the character.
|
||||
width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split())
|
||||
|
||||
# The word DWIDTH
|
||||
# followed by the width in x and y of the character in device pixels.
|
||||
dwx, dwy = (int(p) for p in props["DWIDTH"].split())
|
||||
|
||||
bbox = (
|
||||
(dwx, dwy),
|
||||
(x_disp, -y_disp - height, width + x_disp, -y_disp),
|
||||
(0, 0, width, height),
|
||||
)
|
||||
|
||||
try:
|
||||
im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
|
||||
except ValueError:
|
||||
# deal with zero-width characters
|
||||
im = Image.new("1", (width, height))
|
||||
|
||||
return id, int(props["ENCODING"]), bbox, im
|
||||
|
||||
|
||||
class BdfFontFile(FontFile.FontFile):
|
||||
"""Font file plugin for the X11 BDF format."""
|
||||
|
||||
def __init__(self, fp: BinaryIO) -> None:
|
||||
super().__init__()
|
||||
|
||||
s = fp.readline()
|
||||
if not s.startswith(b"STARTFONT 2.1"):
|
||||
msg = "not a valid BDF file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
props = {}
|
||||
comments = []
|
||||
|
||||
while True:
|
||||
s = fp.readline()
|
||||
if not s or s.startswith(b"ENDPROPERTIES"):
|
||||
break
|
||||
i = s.find(b" ")
|
||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||
if s[:i] in [b"COMMENT", b"COPYRIGHT"]:
|
||||
if s.find(b"LogicalFontDescription") < 0:
|
||||
comments.append(s[i + 1 : -1].decode("ascii"))
|
||||
|
||||
while True:
|
||||
c = bdf_char(fp)
|
||||
if not c:
|
||||
break
|
||||
id, ch, (xy, dst, src), im = c
|
||||
if 0 <= ch < len(self.glyph):
|
||||
self.glyph[ch] = xy, dst, src, im
|
||||
@ -0,0 +1,497 @@
|
||||
"""
|
||||
Blizzard Mipmap Format (.blp)
|
||||
Jerome Leclanche <jerome@leclan.ch>
|
||||
|
||||
The contents of this file are hereby released in the public domain (CC0)
|
||||
Full text of the CC0 license:
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
BLP1 files, used mostly in Warcraft III, are not fully supported.
|
||||
All types of BLP2 files used in World of Warcraft are supported.
|
||||
|
||||
The BLP file structure consists of a header, up to 16 mipmaps of the
|
||||
texture
|
||||
|
||||
Texture sizes must be powers of two, though the two dimensions do
|
||||
not have to be equal; 512x256 is valid, but 512x200 is not.
|
||||
The first mipmap (mipmap #0) is the full size image; each subsequent
|
||||
mipmap halves both dimensions. The final mipmap should be 1x1.
|
||||
|
||||
BLP files come in many different flavours:
|
||||
* JPEG-compressed (type == 0) - only supported for BLP1.
|
||||
* RAW images (type == 1, encoding == 1). Each mipmap is stored as an
|
||||
array of 8-bit values, one per pixel, left to right, top to bottom.
|
||||
Each value is an index to the palette.
|
||||
* DXT-compressed (type == 1, encoding == 2):
|
||||
- DXT1 compression is used if alpha_encoding == 0.
|
||||
- An additional alpha bit is used if alpha_depth == 1.
|
||||
- DXT3 compression is used if alpha_encoding == 1.
|
||||
- DXT5 compression is used if alpha_encoding == 7.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from io import BytesIO
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
|
||||
class Format(IntEnum):
|
||||
JPEG = 0
|
||||
|
||||
|
||||
class Encoding(IntEnum):
|
||||
UNCOMPRESSED = 1
|
||||
DXT = 2
|
||||
UNCOMPRESSED_RAW_BGRA = 3
|
||||
|
||||
|
||||
class AlphaEncoding(IntEnum):
|
||||
DXT1 = 0
|
||||
DXT3 = 1
|
||||
DXT5 = 7
|
||||
|
||||
|
||||
def unpack_565(i: int) -> tuple[int, int, int]:
|
||||
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
|
||||
|
||||
|
||||
def decode_dxt1(
|
||||
data: bytes, alpha: bool = False
|
||||
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 8 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
# Decode next 8-byte block.
|
||||
idx = block_index * 8
|
||||
color0, color1, bits = struct.unpack_from("<HHI", data, idx)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
# Decode this block into 4x4 pixels
|
||||
# Accumulate the results onto our 4 row accumulators
|
||||
for j in range(4):
|
||||
for i in range(4):
|
||||
# get next control op and generate a pixel
|
||||
|
||||
control = bits & 3
|
||||
bits = bits >> 2
|
||||
|
||||
a = 0xFF
|
||||
if control == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif control == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif control == 2:
|
||||
if color0 > color1:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
else:
|
||||
r = (r0 + r1) // 2
|
||||
g = (g0 + g1) // 2
|
||||
b = (b0 + b1) // 2
|
||||
elif control == 3:
|
||||
if color0 > color1:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
else:
|
||||
r, g, b, a = 0, 0, 0, 0
|
||||
|
||||
if alpha:
|
||||
ret[j].extend([r, g, b, a])
|
||||
else:
|
||||
ret[j].extend([r, g, b])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 16 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
idx = block_index * 16
|
||||
block = data[idx : idx + 16]
|
||||
# Decode next 16-byte block.
|
||||
bits = struct.unpack_from("<8B", block)
|
||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||
|
||||
(code,) = struct.unpack_from("<I", block, 12)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
for j in range(4):
|
||||
high = False # Do we want the higher bits?
|
||||
for i in range(4):
|
||||
alphacode_index = (4 * j + i) // 2
|
||||
a = bits[alphacode_index]
|
||||
if high:
|
||||
high = False
|
||||
a >>= 4
|
||||
else:
|
||||
high = True
|
||||
a &= 0xF
|
||||
a *= 17 # We get a value between 0 and 15
|
||||
|
||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||
|
||||
if color_code == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif color_code == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif color_code == 2:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
elif color_code == 3:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
|
||||
ret[j].extend([r, g, b, a])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4 * width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 16 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
idx = block_index * 16
|
||||
block = data[idx : idx + 16]
|
||||
# Decode next 16-byte block.
|
||||
a0, a1 = struct.unpack_from("<BB", block)
|
||||
|
||||
bits = struct.unpack_from("<6B", block, 2)
|
||||
alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24)
|
||||
alphacode2 = bits[0] | (bits[1] << 8)
|
||||
|
||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||
|
||||
(code,) = struct.unpack_from("<I", block, 12)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
for j in range(4):
|
||||
for i in range(4):
|
||||
# get next control op and generate a pixel
|
||||
alphacode_index = 3 * (4 * j + i)
|
||||
|
||||
if alphacode_index <= 12:
|
||||
alphacode = (alphacode2 >> alphacode_index) & 0x07
|
||||
elif alphacode_index == 15:
|
||||
alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06)
|
||||
else: # alphacode_index >= 18 and alphacode_index <= 45
|
||||
alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07
|
||||
|
||||
if alphacode == 0:
|
||||
a = a0
|
||||
elif alphacode == 1:
|
||||
a = a1
|
||||
elif a0 > a1:
|
||||
a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7
|
||||
elif alphacode == 6:
|
||||
a = 0
|
||||
elif alphacode == 7:
|
||||
a = 255
|
||||
else:
|
||||
a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5
|
||||
|
||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||
|
||||
if color_code == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif color_code == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif color_code == 2:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
elif color_code == 3:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
|
||||
ret[j].extend([r, g, b, a])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class BLPFormatError(NotImplementedError):
|
||||
pass
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith((b"BLP1", b"BLP2"))
|
||||
|
||||
|
||||
class BlpImageFile(ImageFile.ImageFile):
|
||||
"""
|
||||
Blizzard Mipmap Format
|
||||
"""
|
||||
|
||||
format = "BLP"
|
||||
format_description = "Blizzard Mipmap Format"
|
||||
|
||||
def _open(self) -> None:
|
||||
self.magic = self.fp.read(4)
|
||||
if not _accept(self.magic):
|
||||
msg = f"Bad BLP magic {repr(self.magic)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
compression = struct.unpack("<i", self.fp.read(4))[0]
|
||||
if self.magic == b"BLP1":
|
||||
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
|
||||
else:
|
||||
encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
|
||||
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||
self.fp.seek(1, os.SEEK_CUR) # mips
|
||||
|
||||
self._size = struct.unpack("<II", self.fp.read(8))
|
||||
|
||||
args: tuple[int, int, bool] | tuple[int, int, bool, int]
|
||||
if self.magic == b"BLP1":
|
||||
encoding = struct.unpack("<i", self.fp.read(4))[0]
|
||||
self.fp.seek(4, os.SEEK_CUR) # subtype
|
||||
|
||||
args = (compression, encoding, alpha)
|
||||
offset = 28
|
||||
else:
|
||||
args = (compression, encoding, alpha, alpha_encoding)
|
||||
offset = 20
|
||||
|
||||
decoder = self.magic.decode()
|
||||
|
||||
self._mode = "RGBA" if alpha else "RGB"
|
||||
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
|
||||
|
||||
|
||||
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
try:
|
||||
self._read_header()
|
||||
self._load()
|
||||
except struct.error as e:
|
||||
msg = "Truncated BLP file"
|
||||
raise OSError(msg) from e
|
||||
return -1, 0
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load(self) -> None:
|
||||
pass
|
||||
|
||||
def _read_header(self) -> None:
|
||||
self._offsets = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||
self._lengths = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||
|
||||
def _safe_read(self, length: int) -> bytes:
|
||||
assert self.fd is not None
|
||||
return ImageFile._safe_read(self.fd, length)
|
||||
|
||||
def _read_palette(self) -> list[tuple[int, int, int, int]]:
|
||||
ret = []
|
||||
for i in range(256):
|
||||
try:
|
||||
b, g, r, a = struct.unpack("<4B", self._safe_read(4))
|
||||
except struct.error:
|
||||
break
|
||||
ret.append((b, g, r, a))
|
||||
return ret
|
||||
|
||||
def _read_bgra(
|
||||
self, palette: list[tuple[int, int, int, int]], alpha: bool
|
||||
) -> bytearray:
|
||||
data = bytearray()
|
||||
_data = BytesIO(self._safe_read(self._lengths[0]))
|
||||
while True:
|
||||
try:
|
||||
(offset,) = struct.unpack("<B", _data.read(1))
|
||||
except struct.error:
|
||||
break
|
||||
b, g, r, a = palette[offset]
|
||||
d: tuple[int, ...] = (r, g, b)
|
||||
if alpha:
|
||||
d += (a,)
|
||||
data.extend(d)
|
||||
return data
|
||||
|
||||
|
||||
class BLP1Decoder(_BLPBaseDecoder):
|
||||
def _load(self) -> None:
|
||||
self._compression, self._encoding, alpha = self.args
|
||||
|
||||
if self._compression == Format.JPEG:
|
||||
self._decode_jpeg_stream()
|
||||
|
||||
elif self._compression == 1:
|
||||
if self._encoding in (4, 5):
|
||||
palette = self._read_palette()
|
||||
data = self._read_bgra(palette, alpha)
|
||||
self.set_as_raw(data)
|
||||
else:
|
||||
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
else:
|
||||
msg = f"Unsupported BLP compression {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
def _decode_jpeg_stream(self) -> None:
|
||||
from .JpegImagePlugin import JpegImageFile
|
||||
|
||||
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
|
||||
jpeg_header = self._safe_read(jpeg_header_size)
|
||||
assert self.fd is not None
|
||||
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
|
||||
data = self._safe_read(self._lengths[0])
|
||||
data = jpeg_header + data
|
||||
image = JpegImageFile(BytesIO(data))
|
||||
Image._decompression_bomb_check(image.size)
|
||||
if image.mode == "CMYK":
|
||||
args = image.tile[0].args
|
||||
assert isinstance(args, tuple)
|
||||
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
|
||||
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
|
||||
|
||||
|
||||
class BLP2Decoder(_BLPBaseDecoder):
|
||||
def _load(self) -> None:
|
||||
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
|
||||
|
||||
palette = self._read_palette()
|
||||
|
||||
assert self.fd is not None
|
||||
self.fd.seek(self._offsets[0])
|
||||
|
||||
if self._compression == 1:
|
||||
# Uncompressed or DirectX compression
|
||||
|
||||
if self._encoding == Encoding.UNCOMPRESSED:
|
||||
data = self._read_bgra(palette, alpha)
|
||||
|
||||
elif self._encoding == Encoding.DXT:
|
||||
data = bytearray()
|
||||
if self._alpha_encoding == AlphaEncoding.DXT1:
|
||||
linesize = (self.state.xsize + 3) // 4 * 8
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt1(self._safe_read(linesize), alpha):
|
||||
data += d
|
||||
|
||||
elif self._alpha_encoding == AlphaEncoding.DXT3:
|
||||
linesize = (self.state.xsize + 3) // 4 * 16
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt3(self._safe_read(linesize)):
|
||||
data += d
|
||||
|
||||
elif self._alpha_encoding == AlphaEncoding.DXT5:
|
||||
linesize = (self.state.xsize + 3) // 4 * 16
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt5(self._safe_read(linesize)):
|
||||
data += d
|
||||
else:
|
||||
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
else:
|
||||
msg = f"Unknown BLP encoding {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
else:
|
||||
msg = f"Unknown BLP compression {repr(self._compression)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
self.set_as_raw(data)
|
||||
|
||||
|
||||
class BLPEncoder(ImageFile.PyEncoder):
|
||||
_pushes_fd = True
|
||||
|
||||
def _write_palette(self) -> bytes:
|
||||
data = b""
|
||||
assert self.im is not None
|
||||
palette = self.im.getpalette("RGBA", "RGBA")
|
||||
for i in range(len(palette) // 4):
|
||||
r, g, b, a = palette[i * 4 : (i + 1) * 4]
|
||||
data += struct.pack("<4B", b, g, r, a)
|
||||
while len(data) < 256 * 4:
|
||||
data += b"\x00" * 4
|
||||
return data
|
||||
|
||||
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
|
||||
palette_data = self._write_palette()
|
||||
|
||||
offset = 20 + 16 * 4 * 2 + len(palette_data)
|
||||
data = struct.pack("<16I", offset, *((0,) * 15))
|
||||
|
||||
assert self.im is not None
|
||||
w, h = self.im.size
|
||||
data += struct.pack("<16I", w * h, *((0,) * 15))
|
||||
|
||||
data += palette_data
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
data += struct.pack("<B", self.im.getpixel((x, y)))
|
||||
|
||||
return len(data), 0, data
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if im.mode != "P":
|
||||
msg = "Unsupported BLP image mode"
|
||||
raise ValueError(msg)
|
||||
|
||||
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
|
||||
fp.write(magic)
|
||||
|
||||
assert im.palette is not None
|
||||
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
|
||||
|
||||
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
|
||||
if magic == b"BLP1":
|
||||
fp.write(struct.pack("<L", alpha_depth))
|
||||
else:
|
||||
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
|
||||
fp.write(struct.pack("<b", alpha_depth))
|
||||
fp.write(struct.pack("<b", 0)) # alpha encoding
|
||||
fp.write(struct.pack("<b", 0)) # mips
|
||||
fp.write(struct.pack("<II", *im.size))
|
||||
if magic == b"BLP1":
|
||||
fp.write(struct.pack("<i", 5))
|
||||
fp.write(struct.pack("<i", 0))
|
||||
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
|
||||
|
||||
|
||||
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
|
||||
Image.register_extension(BlpImageFile.format, ".blp")
|
||||
Image.register_decoder("BLP1", BLP1Decoder)
|
||||
Image.register_decoder("BLP2", BLP2Decoder)
|
||||
|
||||
Image.register_save(BlpImageFile.format, _save)
|
||||
Image.register_encoder("BLP", BLPEncoder)
|
||||
@ -0,0 +1,515 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# BMP file handler
|
||||
#
|
||||
# Windows (and OS/2) native bitmap storage format.
|
||||
#
|
||||
# history:
|
||||
# 1995-09-01 fl Created
|
||||
# 1996-04-30 fl Added save
|
||||
# 1997-08-27 fl Fixed save of 1-bit images
|
||||
# 1998-03-06 fl Load P images as L where possible
|
||||
# 1998-07-03 fl Load P images as 1 where possible
|
||||
# 1998-12-29 fl Handle small palettes
|
||||
# 2002-12-30 fl Fixed load of 1-bit palette images
|
||||
# 2003-04-21 fl Fixed load of 1-bit monochrome images
|
||||
# 2003-04-23 fl Added limited support for BI_BITFIELDS compression
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB
|
||||
# Copyright (c) 1995-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO, Any
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._binary import o16le as o16
|
||||
from ._binary import o32le as o32
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Read BMP file
|
||||
|
||||
BIT2MODE = {
|
||||
# bits => mode, rawmode
|
||||
1: ("P", "P;1"),
|
||||
4: ("P", "P;4"),
|
||||
8: ("P", "P"),
|
||||
16: ("RGB", "BGR;15"),
|
||||
24: ("RGB", "BGR"),
|
||||
32: ("RGB", "BGRX"),
|
||||
}
|
||||
|
||||
USE_RAW_ALPHA = False
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"BM")
|
||||
|
||||
|
||||
def _dib_accept(prefix: bytes) -> bool:
|
||||
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image plugin for the Windows BMP format.
|
||||
# =============================================================================
|
||||
class BmpImageFile(ImageFile.ImageFile):
|
||||
"""Image plugin for the Windows Bitmap format (BMP)"""
|
||||
|
||||
# ------------------------------------------------------------- Description
|
||||
format_description = "Windows Bitmap"
|
||||
format = "BMP"
|
||||
|
||||
# -------------------------------------------------- BMP Compression values
|
||||
COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5}
|
||||
for k, v in COMPRESSIONS.items():
|
||||
vars()[k] = v
|
||||
|
||||
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
|
||||
"""Read relevant info about the BMP"""
|
||||
read, seek = self.fp.read, self.fp.seek
|
||||
if header:
|
||||
seek(header)
|
||||
# read bmp header size @offset 14 (this is part of the header size)
|
||||
file_info: dict[str, bool | int | tuple[int, ...]] = {
|
||||
"header_size": i32(read(4)),
|
||||
"direction": -1,
|
||||
}
|
||||
|
||||
# -------------------- If requested, read header at a specific position
|
||||
# read the rest of the bmp header, without its size
|
||||
assert isinstance(file_info["header_size"], int)
|
||||
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
|
||||
|
||||
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
|
||||
# ----- This format has different offsets because of width/height types
|
||||
# 12: BITMAPCOREHEADER/OS21XBITMAPHEADER
|
||||
if file_info["header_size"] == 12:
|
||||
file_info["width"] = i16(header_data, 0)
|
||||
file_info["height"] = i16(header_data, 2)
|
||||
file_info["planes"] = i16(header_data, 4)
|
||||
file_info["bits"] = i16(header_data, 6)
|
||||
file_info["compression"] = self.COMPRESSIONS["RAW"]
|
||||
file_info["palette_padding"] = 3
|
||||
|
||||
# --------------------------------------------- Windows Bitmap v3 to v5
|
||||
# 40: BITMAPINFOHEADER
|
||||
# 52: BITMAPV2HEADER
|
||||
# 56: BITMAPV3HEADER
|
||||
# 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER
|
||||
# 108: BITMAPV4HEADER
|
||||
# 124: BITMAPV5HEADER
|
||||
elif file_info["header_size"] in (40, 52, 56, 64, 108, 124):
|
||||
file_info["y_flip"] = header_data[7] == 0xFF
|
||||
file_info["direction"] = 1 if file_info["y_flip"] else -1
|
||||
file_info["width"] = i32(header_data, 0)
|
||||
file_info["height"] = (
|
||||
i32(header_data, 4)
|
||||
if not file_info["y_flip"]
|
||||
else 2**32 - i32(header_data, 4)
|
||||
)
|
||||
file_info["planes"] = i16(header_data, 8)
|
||||
file_info["bits"] = i16(header_data, 10)
|
||||
file_info["compression"] = i32(header_data, 12)
|
||||
# byte size of pixel data
|
||||
file_info["data_size"] = i32(header_data, 16)
|
||||
file_info["pixels_per_meter"] = (
|
||||
i32(header_data, 20),
|
||||
i32(header_data, 24),
|
||||
)
|
||||
file_info["colors"] = i32(header_data, 28)
|
||||
file_info["palette_padding"] = 4
|
||||
assert isinstance(file_info["pixels_per_meter"], tuple)
|
||||
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
|
||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||
masks = ["r_mask", "g_mask", "b_mask"]
|
||||
if len(header_data) >= 48:
|
||||
if len(header_data) >= 52:
|
||||
masks.append("a_mask")
|
||||
else:
|
||||
file_info["a_mask"] = 0x0
|
||||
for idx, mask in enumerate(masks):
|
||||
file_info[mask] = i32(header_data, 36 + idx * 4)
|
||||
else:
|
||||
# 40 byte headers only have the three components in the
|
||||
# bitfields masks, ref:
|
||||
# https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx
|
||||
# See also
|
||||
# https://github.com/python-pillow/Pillow/issues/1293
|
||||
# There is a 4th component in the RGBQuad, in the alpha
|
||||
# location, but it is listed as a reserved component,
|
||||
# and it is not generally an alpha channel
|
||||
file_info["a_mask"] = 0x0
|
||||
for mask in masks:
|
||||
file_info[mask] = i32(read(4))
|
||||
assert isinstance(file_info["r_mask"], int)
|
||||
assert isinstance(file_info["g_mask"], int)
|
||||
assert isinstance(file_info["b_mask"], int)
|
||||
assert isinstance(file_info["a_mask"], int)
|
||||
file_info["rgb_mask"] = (
|
||||
file_info["r_mask"],
|
||||
file_info["g_mask"],
|
||||
file_info["b_mask"],
|
||||
)
|
||||
file_info["rgba_mask"] = (
|
||||
file_info["r_mask"],
|
||||
file_info["g_mask"],
|
||||
file_info["b_mask"],
|
||||
file_info["a_mask"],
|
||||
)
|
||||
else:
|
||||
msg = f"Unsupported BMP header type ({file_info['header_size']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# ------------------ Special case : header is reported 40, which
|
||||
# ---------------------- is shorter than real size for bpp >= 16
|
||||
assert isinstance(file_info["width"], int)
|
||||
assert isinstance(file_info["height"], int)
|
||||
self._size = file_info["width"], file_info["height"]
|
||||
|
||||
# ------- If color count was not found in the header, compute from bits
|
||||
assert isinstance(file_info["bits"], int)
|
||||
file_info["colors"] = (
|
||||
file_info["colors"]
|
||||
if file_info.get("colors", 0)
|
||||
else (1 << file_info["bits"])
|
||||
)
|
||||
assert isinstance(file_info["colors"], int)
|
||||
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
|
||||
offset += 4 * file_info["colors"]
|
||||
|
||||
# ---------------------- Check bit depth for unusual unsupported values
|
||||
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
|
||||
if not self.mode:
|
||||
msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# ---------------- Process BMP with Bitfields compression (not palette)
|
||||
decoder_name = "raw"
|
||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
|
||||
32: [
|
||||
(0xFF0000, 0xFF00, 0xFF, 0x0),
|
||||
(0xFF000000, 0xFF0000, 0xFF00, 0x0),
|
||||
(0xFF000000, 0xFF00, 0xFF, 0x0),
|
||||
(0xFF000000, 0xFF0000, 0xFF00, 0xFF),
|
||||
(0xFF, 0xFF00, 0xFF0000, 0xFF000000),
|
||||
(0xFF0000, 0xFF00, 0xFF, 0xFF000000),
|
||||
(0xFF000000, 0xFF00, 0xFF, 0xFF0000),
|
||||
(0x0, 0x0, 0x0, 0x0),
|
||||
],
|
||||
24: [(0xFF0000, 0xFF00, 0xFF)],
|
||||
16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)],
|
||||
}
|
||||
MASK_MODES = {
|
||||
(32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX",
|
||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR",
|
||||
(32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR",
|
||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR",
|
||||
(32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA",
|
||||
(32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA",
|
||||
(32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR",
|
||||
(32, (0x0, 0x0, 0x0, 0x0)): "BGRA",
|
||||
(24, (0xFF0000, 0xFF00, 0xFF)): "BGR",
|
||||
(16, (0xF800, 0x7E0, 0x1F)): "BGR;16",
|
||||
(16, (0x7C00, 0x3E0, 0x1F)): "BGR;15",
|
||||
}
|
||||
if file_info["bits"] in SUPPORTED:
|
||||
if (
|
||||
file_info["bits"] == 32
|
||||
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
|
||||
):
|
||||
assert isinstance(file_info["rgba_mask"], tuple)
|
||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
|
||||
self._mode = "RGBA" if "A" in raw_mode else self.mode
|
||||
elif (
|
||||
file_info["bits"] in (24, 16)
|
||||
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
|
||||
):
|
||||
assert isinstance(file_info["rgb_mask"], tuple)
|
||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
|
||||
else:
|
||||
msg = "Unsupported BMP bitfields layout"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
msg = "Unsupported BMP bitfields layout"
|
||||
raise OSError(msg)
|
||||
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
|
||||
if file_info["bits"] == 32 and (
|
||||
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
|
||||
):
|
||||
raw_mode, self._mode = "BGRA", "RGBA"
|
||||
elif file_info["compression"] in (
|
||||
self.COMPRESSIONS["RLE8"],
|
||||
self.COMPRESSIONS["RLE4"],
|
||||
):
|
||||
decoder_name = "bmp_rle"
|
||||
else:
|
||||
msg = f"Unsupported BMP compression ({file_info['compression']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# --------------- Once the header is processed, process the palette/LUT
|
||||
if self.mode == "P": # Paletted for 1, 4 and 8 bit images
|
||||
# ---------------------------------------------------- 1-bit images
|
||||
if not (0 < file_info["colors"] <= 65536):
|
||||
msg = f"Unsupported BMP Palette size ({file_info['colors']})"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
assert isinstance(file_info["palette_padding"], int)
|
||||
padding = file_info["palette_padding"]
|
||||
palette = read(padding * file_info["colors"])
|
||||
grayscale = True
|
||||
indices = (
|
||||
(0, 255)
|
||||
if file_info["colors"] == 2
|
||||
else list(range(file_info["colors"]))
|
||||
)
|
||||
|
||||
# ----------------- Check if grayscale and ignore palette if so
|
||||
for ind, val in enumerate(indices):
|
||||
rgb = palette[ind * padding : ind * padding + 3]
|
||||
if rgb != o8(val) * 3:
|
||||
grayscale = False
|
||||
|
||||
# ------- If all colors are gray, white or black, ditch palette
|
||||
if grayscale:
|
||||
self._mode = "1" if file_info["colors"] == 2 else "L"
|
||||
raw_mode = self.mode
|
||||
else:
|
||||
self._mode = "P"
|
||||
self.palette = ImagePalette.raw(
|
||||
"BGRX" if padding == 4 else "BGR", palette
|
||||
)
|
||||
|
||||
# ---------------------------- Finally set the tile data for the plugin
|
||||
self.info["compression"] = file_info["compression"]
|
||||
args: list[Any] = [raw_mode]
|
||||
if decoder_name == "bmp_rle":
|
||||
args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
|
||||
else:
|
||||
assert isinstance(file_info["width"], int)
|
||||
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
|
||||
args.append(file_info["direction"])
|
||||
self.tile = [
|
||||
ImageFile._Tile(
|
||||
decoder_name,
|
||||
(0, 0, file_info["width"], file_info["height"]),
|
||||
offset or self.fp.tell(),
|
||||
tuple(args),
|
||||
)
|
||||
]
|
||||
|
||||
def _open(self) -> None:
|
||||
"""Open file, check magic number and read header"""
|
||||
# read 14 bytes: magic number, filesize, reserved, header final offset
|
||||
head_data = self.fp.read(14)
|
||||
# choke if the file does not have the required magic bytes
|
||||
if not _accept(head_data):
|
||||
msg = "Not a BMP file"
|
||||
raise SyntaxError(msg)
|
||||
# read the start position of the BMP image data (u32)
|
||||
offset = i32(head_data, 10)
|
||||
# load bitmap information (offset=raster info)
|
||||
self._bitmap(offset=offset)
|
||||
|
||||
|
||||
class BmpRleDecoder(ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
assert self.fd is not None
|
||||
rle4 = self.args[1]
|
||||
data = bytearray()
|
||||
x = 0
|
||||
dest_length = self.state.xsize * self.state.ysize
|
||||
while len(data) < dest_length:
|
||||
pixels = self.fd.read(1)
|
||||
byte = self.fd.read(1)
|
||||
if not pixels or not byte:
|
||||
break
|
||||
num_pixels = pixels[0]
|
||||
if num_pixels:
|
||||
# encoded mode
|
||||
if x + num_pixels > self.state.xsize:
|
||||
# Too much data for row
|
||||
num_pixels = max(0, self.state.xsize - x)
|
||||
if rle4:
|
||||
first_pixel = o8(byte[0] >> 4)
|
||||
second_pixel = o8(byte[0] & 0x0F)
|
||||
for index in range(num_pixels):
|
||||
if index % 2 == 0:
|
||||
data += first_pixel
|
||||
else:
|
||||
data += second_pixel
|
||||
else:
|
||||
data += byte * num_pixels
|
||||
x += num_pixels
|
||||
else:
|
||||
if byte[0] == 0:
|
||||
# end of line
|
||||
while len(data) % self.state.xsize != 0:
|
||||
data += b"\x00"
|
||||
x = 0
|
||||
elif byte[0] == 1:
|
||||
# end of bitmap
|
||||
break
|
||||
elif byte[0] == 2:
|
||||
# delta
|
||||
bytes_read = self.fd.read(2)
|
||||
if len(bytes_read) < 2:
|
||||
break
|
||||
right, up = self.fd.read(2)
|
||||
data += b"\x00" * (right + up * self.state.xsize)
|
||||
x = len(data) % self.state.xsize
|
||||
else:
|
||||
# absolute mode
|
||||
if rle4:
|
||||
# 2 pixels per byte
|
||||
byte_count = byte[0] // 2
|
||||
bytes_read = self.fd.read(byte_count)
|
||||
for byte_read in bytes_read:
|
||||
data += o8(byte_read >> 4)
|
||||
data += o8(byte_read & 0x0F)
|
||||
else:
|
||||
byte_count = byte[0]
|
||||
bytes_read = self.fd.read(byte_count)
|
||||
data += bytes_read
|
||||
if len(bytes_read) < byte_count:
|
||||
break
|
||||
x += byte[0]
|
||||
|
||||
# align to 16-bit word boundary
|
||||
if self.fd.tell() % 2 != 0:
|
||||
self.fd.seek(1, os.SEEK_CUR)
|
||||
rawmode = "L" if self.mode == "L" else "P"
|
||||
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
|
||||
return -1, 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image plugin for the DIB format (BMP alias)
|
||||
# =============================================================================
|
||||
class DibImageFile(BmpImageFile):
|
||||
format = "DIB"
|
||||
format_description = "Windows Bitmap"
|
||||
|
||||
def _open(self) -> None:
|
||||
self._bitmap()
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Write BMP file
|
||||
|
||||
|
||||
SAVE = {
|
||||
"1": ("1", 1, 2),
|
||||
"L": ("L", 8, 256),
|
||||
"P": ("P", 8, 256),
|
||||
"RGB": ("BGR", 24, 0),
|
||||
"RGBA": ("BGRA", 32, 0),
|
||||
}
|
||||
|
||||
|
||||
def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
_save(im, fp, filename, False)
|
||||
|
||||
|
||||
def _save(
|
||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
|
||||
) -> None:
|
||||
try:
|
||||
rawmode, bits, colors = SAVE[im.mode]
|
||||
except KeyError as e:
|
||||
msg = f"cannot write mode {im.mode} as BMP"
|
||||
raise OSError(msg) from e
|
||||
|
||||
info = im.encoderinfo
|
||||
|
||||
dpi = info.get("dpi", (96, 96))
|
||||
|
||||
# 1 meter == 39.3701 inches
|
||||
ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi)
|
||||
|
||||
stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3)
|
||||
header = 40 # or 64 for OS/2 version 2
|
||||
image = stride * im.size[1]
|
||||
|
||||
if im.mode == "1":
|
||||
palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255))
|
||||
elif im.mode == "L":
|
||||
palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256))
|
||||
elif im.mode == "P":
|
||||
palette = im.im.getpalette("RGB", "BGRX")
|
||||
colors = len(palette) // 4
|
||||
else:
|
||||
palette = None
|
||||
|
||||
# bitmap header
|
||||
if bitmap_header:
|
||||
offset = 14 + header + colors * 4
|
||||
file_size = offset + image
|
||||
if file_size > 2**32 - 1:
|
||||
msg = "File size is too large for the BMP format"
|
||||
raise ValueError(msg)
|
||||
fp.write(
|
||||
b"BM" # file type (magic)
|
||||
+ o32(file_size) # file size
|
||||
+ o32(0) # reserved
|
||||
+ o32(offset) # image data offset
|
||||
)
|
||||
|
||||
# bitmap info header
|
||||
fp.write(
|
||||
o32(header) # info header size
|
||||
+ o32(im.size[0]) # width
|
||||
+ o32(im.size[1]) # height
|
||||
+ o16(1) # planes
|
||||
+ o16(bits) # depth
|
||||
+ o32(0) # compression (0=uncompressed)
|
||||
+ o32(image) # size of bitmap
|
||||
+ o32(ppm[0]) # resolution
|
||||
+ o32(ppm[1]) # resolution
|
||||
+ o32(colors) # colors used
|
||||
+ o32(colors) # colors important
|
||||
)
|
||||
|
||||
fp.write(b"\0" * (header - 40)) # padding (for OS/2 format)
|
||||
|
||||
if palette:
|
||||
fp.write(palette)
|
||||
|
||||
ImageFile._save(
|
||||
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
|
||||
Image.register_open(BmpImageFile.format, BmpImageFile, _accept)
|
||||
Image.register_save(BmpImageFile.format, _save)
|
||||
|
||||
Image.register_extension(BmpImageFile.format, ".bmp")
|
||||
|
||||
Image.register_mime(BmpImageFile.format, "image/bmp")
|
||||
|
||||
Image.register_decoder("bmp_rle", BmpRleDecoder)
|
||||
|
||||
Image.register_open(DibImageFile.format, DibImageFile, _dib_accept)
|
||||
Image.register_save(DibImageFile.format, _dib_save)
|
||||
|
||||
Image.register_extension(DibImageFile.format, ".dib")
|
||||
|
||||
Image.register_mime(DibImageFile.format, "image/bmp")
|
||||
@ -0,0 +1,75 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# BUFR stub adapter
|
||||
#
|
||||
# Copyright (c) 1996-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
_handler = None
|
||||
|
||||
|
||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
||||
"""
|
||||
Install application-specific BUFR image handler.
|
||||
|
||||
:param handler: Handler object.
|
||||
"""
|
||||
global _handler
|
||||
_handler = handler
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Image adapter
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith((b"BUFR", b"ZCZC"))
|
||||
|
||||
|
||||
class BufrStubImageFile(ImageFile.StubImageFile):
|
||||
format = "BUFR"
|
||||
format_description = "BUFR"
|
||||
|
||||
def _open(self) -> None:
|
||||
if not _accept(self.fp.read(4)):
|
||||
msg = "Not a BUFR file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self.fp.seek(-4, os.SEEK_CUR)
|
||||
|
||||
# make something up
|
||||
self._mode = "F"
|
||||
self._size = 1, 1
|
||||
|
||||
loader = self._load()
|
||||
if loader:
|
||||
loader.open(self)
|
||||
|
||||
def _load(self) -> ImageFile.StubHandler | None:
|
||||
return _handler
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if _handler is None or not hasattr(_handler, "save"):
|
||||
msg = "BUFR save handler not installed"
|
||||
raise OSError(msg)
|
||||
_handler.save(im, fp, filename)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept)
|
||||
Image.register_save(BufrStubImageFile.format, _save)
|
||||
|
||||
Image.register_extension(BufrStubImageFile.format, ".bufr")
|
||||
@ -0,0 +1,173 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# a class to read from a container file
|
||||
#
|
||||
# History:
|
||||
# 1995-06-18 fl Created
|
||||
# 1995-09-07 fl Added readline(), readlines()
|
||||
#
|
||||
# Copyright (c) 1997-2001 by Secret Labs AB
|
||||
# Copyright (c) 1995 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections.abc import Iterable
|
||||
from typing import IO, AnyStr, NoReturn
|
||||
|
||||
|
||||
class ContainerIO(IO[AnyStr]):
|
||||
"""
|
||||
A file object that provides read access to a part of an existing
|
||||
file (for example a TAR file).
|
||||
"""
|
||||
|
||||
def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
|
||||
"""
|
||||
Create file object.
|
||||
|
||||
:param file: Existing file.
|
||||
:param offset: Start of region, in bytes.
|
||||
:param length: Size of region, in bytes.
|
||||
"""
|
||||
self.fh: IO[AnyStr] = file
|
||||
self.pos = 0
|
||||
self.offset = offset
|
||||
self.length = length
|
||||
self.fh.seek(offset)
|
||||
|
||||
##
|
||||
# Always false.
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return False
|
||||
|
||||
def seekable(self) -> bool:
|
||||
return True
|
||||
|
||||
def seek(self, offset: int, mode: int = io.SEEK_SET) -> int:
|
||||
"""
|
||||
Move file pointer.
|
||||
|
||||
:param offset: Offset in bytes.
|
||||
:param mode: Starting position. Use 0 for beginning of region, 1
|
||||
for current offset, and 2 for end of region. You cannot move
|
||||
the pointer outside the defined region.
|
||||
:returns: Offset from start of region, in bytes.
|
||||
"""
|
||||
if mode == 1:
|
||||
self.pos = self.pos + offset
|
||||
elif mode == 2:
|
||||
self.pos = self.length + offset
|
||||
else:
|
||||
self.pos = offset
|
||||
# clamp
|
||||
self.pos = max(0, min(self.pos, self.length))
|
||||
self.fh.seek(self.offset + self.pos)
|
||||
return self.pos
|
||||
|
||||
def tell(self) -> int:
|
||||
"""
|
||||
Get current file pointer.
|
||||
|
||||
:returns: Offset from start of region, in bytes.
|
||||
"""
|
||||
return self.pos
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def read(self, n: int = -1) -> AnyStr:
|
||||
"""
|
||||
Read data.
|
||||
|
||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||
read until end of region.
|
||||
:returns: An 8-bit string.
|
||||
"""
|
||||
if n > 0:
|
||||
n = min(n, self.length - self.pos)
|
||||
else:
|
||||
n = self.length - self.pos
|
||||
if n <= 0: # EOF
|
||||
return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
|
||||
self.pos = self.pos + n
|
||||
return self.fh.read(n)
|
||||
|
||||
def readline(self, n: int = -1) -> AnyStr:
|
||||
"""
|
||||
Read a line of text.
|
||||
|
||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||
read until end of line.
|
||||
:returns: An 8-bit string.
|
||||
"""
|
||||
s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
|
||||
newline_character = b"\n" if "b" in self.fh.mode else "\n"
|
||||
while True:
|
||||
c = self.read(1)
|
||||
if not c:
|
||||
break
|
||||
s = s + c
|
||||
if c == newline_character or len(s) == n:
|
||||
break
|
||||
return s
|
||||
|
||||
def readlines(self, n: int | None = -1) -> list[AnyStr]:
|
||||
"""
|
||||
Read multiple lines of text.
|
||||
|
||||
:param n: Number of lines to read. If omitted, zero, negative or None,
|
||||
read until end of region.
|
||||
:returns: A list of 8-bit strings.
|
||||
"""
|
||||
lines = []
|
||||
while True:
|
||||
s = self.readline()
|
||||
if not s:
|
||||
break
|
||||
lines.append(s)
|
||||
if len(lines) == n:
|
||||
break
|
||||
return lines
|
||||
|
||||
def writable(self) -> bool:
|
||||
return False
|
||||
|
||||
def write(self, b: AnyStr) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
def writelines(self, lines: Iterable[AnyStr]) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
def truncate(self, size: int | None = None) -> int:
|
||||
raise NotImplementedError()
|
||||
|
||||
def __enter__(self) -> ContainerIO[AnyStr]:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def __iter__(self) -> ContainerIO[AnyStr]:
|
||||
return self
|
||||
|
||||
def __next__(self) -> AnyStr:
|
||||
line = self.readline()
|
||||
if not line:
|
||||
msg = "end of region"
|
||||
raise StopIteration(msg)
|
||||
return line
|
||||
|
||||
def fileno(self) -> int:
|
||||
return self.fh.fileno()
|
||||
|
||||
def flush(self) -> None:
|
||||
self.fh.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
self.fh.close()
|
||||
@ -0,0 +1,75 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# Windows Cursor support for PIL
|
||||
#
|
||||
# notes:
|
||||
# uses BmpImagePlugin.py to read the bitmap data.
|
||||
#
|
||||
# history:
|
||||
# 96-05-27 fl Created
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import BmpImagePlugin, Image
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"\0\0\2\0")
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for Windows Cursor files.
|
||||
|
||||
|
||||
class CurImageFile(BmpImagePlugin.BmpImageFile):
|
||||
format = "CUR"
|
||||
format_description = "Windows Cursor"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
offset = self.fp.tell()
|
||||
|
||||
# check magic
|
||||
s = self.fp.read(6)
|
||||
if not _accept(s):
|
||||
msg = "not a CUR file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# pick the largest cursor in the file
|
||||
m = b""
|
||||
for i in range(i16(s, 4)):
|
||||
s = self.fp.read(16)
|
||||
if not m:
|
||||
m = s
|
||||
elif s[0] > m[0] and s[1] > m[1]:
|
||||
m = s
|
||||
if not m:
|
||||
msg = "No cursors were found"
|
||||
raise TypeError(msg)
|
||||
|
||||
# load as bitmap
|
||||
self._bitmap(i32(m, 12) + offset)
|
||||
|
||||
# patch up the bitmap height
|
||||
self._size = self.size[0], self.size[1] // 2
|
||||
self.tile = [self.tile[0]._replace(extents=(0, 0) + self.size)]
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
Image.register_open(CurImageFile.format, CurImageFile, _accept)
|
||||
|
||||
Image.register_extension(CurImageFile.format, ".cur")
|
||||
@ -0,0 +1,83 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# DCX file handling
|
||||
#
|
||||
# DCX is a container file format defined by Intel, commonly used
|
||||
# for fax applications. Each DCX file consists of a directory
|
||||
# (a list of file offsets) followed by a set of (usually 1-bit)
|
||||
# PCX files.
|
||||
#
|
||||
# History:
|
||||
# 1995-09-09 fl Created
|
||||
# 1996-03-20 fl Properly derived from PcxImageFile.
|
||||
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
||||
# 2002-07-30 fl Fixed file handling
|
||||
#
|
||||
# Copyright (c) 1997-98 by Secret Labs AB.
|
||||
# Copyright (c) 1995-96 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image
|
||||
from ._binary import i32le as i32
|
||||
from ._util import DeferredError
|
||||
from .PcxImagePlugin import PcxImageFile
|
||||
|
||||
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return len(prefix) >= 4 and i32(prefix) == MAGIC
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the Intel DCX format.
|
||||
|
||||
|
||||
class DcxImageFile(PcxImageFile):
|
||||
format = "DCX"
|
||||
format_description = "Intel DCX"
|
||||
_close_exclusive_fp_after_loading = False
|
||||
|
||||
def _open(self) -> None:
|
||||
# Header
|
||||
s = self.fp.read(4)
|
||||
if not _accept(s):
|
||||
msg = "not a DCX file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# Component directory
|
||||
self._offset = []
|
||||
for i in range(1024):
|
||||
offset = i32(self.fp.read(4))
|
||||
if not offset:
|
||||
break
|
||||
self._offset.append(offset)
|
||||
|
||||
self._fp = self.fp
|
||||
self.frame = -1
|
||||
self.n_frames = len(self._offset)
|
||||
self.is_animated = self.n_frames > 1
|
||||
self.seek(0)
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
if isinstance(self._fp, DeferredError):
|
||||
raise self._fp.ex
|
||||
self.frame = frame
|
||||
self.fp = self._fp
|
||||
self.fp.seek(self._offset[frame])
|
||||
PcxImageFile._open(self)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.frame
|
||||
|
||||
|
||||
Image.register_open(DcxImageFile.format, DcxImageFile, _accept)
|
||||
|
||||
Image.register_extension(DcxImageFile.format, ".dcx")
|
||||
@ -0,0 +1,624 @@
|
||||
"""
|
||||
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
|
||||
Jerome Leclanche <jerome@leclan.ch>
|
||||
|
||||
Documentation:
|
||||
https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
|
||||
|
||||
The contents of this file are hereby released in the public domain (CC0)
|
||||
Full text of the CC0 license:
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import struct
|
||||
import sys
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._binary import o32le as o32
|
||||
|
||||
# Magic ("DDS ")
|
||||
DDS_MAGIC = 0x20534444
|
||||
|
||||
|
||||
# DDS flags
|
||||
class DDSD(IntFlag):
|
||||
CAPS = 0x1
|
||||
HEIGHT = 0x2
|
||||
WIDTH = 0x4
|
||||
PITCH = 0x8
|
||||
PIXELFORMAT = 0x1000
|
||||
MIPMAPCOUNT = 0x20000
|
||||
LINEARSIZE = 0x80000
|
||||
DEPTH = 0x800000
|
||||
|
||||
|
||||
# DDS caps
|
||||
class DDSCAPS(IntFlag):
|
||||
COMPLEX = 0x8
|
||||
TEXTURE = 0x1000
|
||||
MIPMAP = 0x400000
|
||||
|
||||
|
||||
class DDSCAPS2(IntFlag):
|
||||
CUBEMAP = 0x200
|
||||
CUBEMAP_POSITIVEX = 0x400
|
||||
CUBEMAP_NEGATIVEX = 0x800
|
||||
CUBEMAP_POSITIVEY = 0x1000
|
||||
CUBEMAP_NEGATIVEY = 0x2000
|
||||
CUBEMAP_POSITIVEZ = 0x4000
|
||||
CUBEMAP_NEGATIVEZ = 0x8000
|
||||
VOLUME = 0x200000
|
||||
|
||||
|
||||
# Pixel Format
|
||||
class DDPF(IntFlag):
|
||||
ALPHAPIXELS = 0x1
|
||||
ALPHA = 0x2
|
||||
FOURCC = 0x4
|
||||
PALETTEINDEXED8 = 0x20
|
||||
RGB = 0x40
|
||||
LUMINANCE = 0x20000
|
||||
|
||||
|
||||
# dxgiformat.h
|
||||
class DXGI_FORMAT(IntEnum):
|
||||
UNKNOWN = 0
|
||||
R32G32B32A32_TYPELESS = 1
|
||||
R32G32B32A32_FLOAT = 2
|
||||
R32G32B32A32_UINT = 3
|
||||
R32G32B32A32_SINT = 4
|
||||
R32G32B32_TYPELESS = 5
|
||||
R32G32B32_FLOAT = 6
|
||||
R32G32B32_UINT = 7
|
||||
R32G32B32_SINT = 8
|
||||
R16G16B16A16_TYPELESS = 9
|
||||
R16G16B16A16_FLOAT = 10
|
||||
R16G16B16A16_UNORM = 11
|
||||
R16G16B16A16_UINT = 12
|
||||
R16G16B16A16_SNORM = 13
|
||||
R16G16B16A16_SINT = 14
|
||||
R32G32_TYPELESS = 15
|
||||
R32G32_FLOAT = 16
|
||||
R32G32_UINT = 17
|
||||
R32G32_SINT = 18
|
||||
R32G8X24_TYPELESS = 19
|
||||
D32_FLOAT_S8X24_UINT = 20
|
||||
R32_FLOAT_X8X24_TYPELESS = 21
|
||||
X32_TYPELESS_G8X24_UINT = 22
|
||||
R10G10B10A2_TYPELESS = 23
|
||||
R10G10B10A2_UNORM = 24
|
||||
R10G10B10A2_UINT = 25
|
||||
R11G11B10_FLOAT = 26
|
||||
R8G8B8A8_TYPELESS = 27
|
||||
R8G8B8A8_UNORM = 28
|
||||
R8G8B8A8_UNORM_SRGB = 29
|
||||
R8G8B8A8_UINT = 30
|
||||
R8G8B8A8_SNORM = 31
|
||||
R8G8B8A8_SINT = 32
|
||||
R16G16_TYPELESS = 33
|
||||
R16G16_FLOAT = 34
|
||||
R16G16_UNORM = 35
|
||||
R16G16_UINT = 36
|
||||
R16G16_SNORM = 37
|
||||
R16G16_SINT = 38
|
||||
R32_TYPELESS = 39
|
||||
D32_FLOAT = 40
|
||||
R32_FLOAT = 41
|
||||
R32_UINT = 42
|
||||
R32_SINT = 43
|
||||
R24G8_TYPELESS = 44
|
||||
D24_UNORM_S8_UINT = 45
|
||||
R24_UNORM_X8_TYPELESS = 46
|
||||
X24_TYPELESS_G8_UINT = 47
|
||||
R8G8_TYPELESS = 48
|
||||
R8G8_UNORM = 49
|
||||
R8G8_UINT = 50
|
||||
R8G8_SNORM = 51
|
||||
R8G8_SINT = 52
|
||||
R16_TYPELESS = 53
|
||||
R16_FLOAT = 54
|
||||
D16_UNORM = 55
|
||||
R16_UNORM = 56
|
||||
R16_UINT = 57
|
||||
R16_SNORM = 58
|
||||
R16_SINT = 59
|
||||
R8_TYPELESS = 60
|
||||
R8_UNORM = 61
|
||||
R8_UINT = 62
|
||||
R8_SNORM = 63
|
||||
R8_SINT = 64
|
||||
A8_UNORM = 65
|
||||
R1_UNORM = 66
|
||||
R9G9B9E5_SHAREDEXP = 67
|
||||
R8G8_B8G8_UNORM = 68
|
||||
G8R8_G8B8_UNORM = 69
|
||||
BC1_TYPELESS = 70
|
||||
BC1_UNORM = 71
|
||||
BC1_UNORM_SRGB = 72
|
||||
BC2_TYPELESS = 73
|
||||
BC2_UNORM = 74
|
||||
BC2_UNORM_SRGB = 75
|
||||
BC3_TYPELESS = 76
|
||||
BC3_UNORM = 77
|
||||
BC3_UNORM_SRGB = 78
|
||||
BC4_TYPELESS = 79
|
||||
BC4_UNORM = 80
|
||||
BC4_SNORM = 81
|
||||
BC5_TYPELESS = 82
|
||||
BC5_UNORM = 83
|
||||
BC5_SNORM = 84
|
||||
B5G6R5_UNORM = 85
|
||||
B5G5R5A1_UNORM = 86
|
||||
B8G8R8A8_UNORM = 87
|
||||
B8G8R8X8_UNORM = 88
|
||||
R10G10B10_XR_BIAS_A2_UNORM = 89
|
||||
B8G8R8A8_TYPELESS = 90
|
||||
B8G8R8A8_UNORM_SRGB = 91
|
||||
B8G8R8X8_TYPELESS = 92
|
||||
B8G8R8X8_UNORM_SRGB = 93
|
||||
BC6H_TYPELESS = 94
|
||||
BC6H_UF16 = 95
|
||||
BC6H_SF16 = 96
|
||||
BC7_TYPELESS = 97
|
||||
BC7_UNORM = 98
|
||||
BC7_UNORM_SRGB = 99
|
||||
AYUV = 100
|
||||
Y410 = 101
|
||||
Y416 = 102
|
||||
NV12 = 103
|
||||
P010 = 104
|
||||
P016 = 105
|
||||
OPAQUE_420 = 106
|
||||
YUY2 = 107
|
||||
Y210 = 108
|
||||
Y216 = 109
|
||||
NV11 = 110
|
||||
AI44 = 111
|
||||
IA44 = 112
|
||||
P8 = 113
|
||||
A8P8 = 114
|
||||
B4G4R4A4_UNORM = 115
|
||||
P208 = 130
|
||||
V208 = 131
|
||||
V408 = 132
|
||||
SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189
|
||||
SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190
|
||||
|
||||
|
||||
class D3DFMT(IntEnum):
|
||||
UNKNOWN = 0
|
||||
R8G8B8 = 20
|
||||
A8R8G8B8 = 21
|
||||
X8R8G8B8 = 22
|
||||
R5G6B5 = 23
|
||||
X1R5G5B5 = 24
|
||||
A1R5G5B5 = 25
|
||||
A4R4G4B4 = 26
|
||||
R3G3B2 = 27
|
||||
A8 = 28
|
||||
A8R3G3B2 = 29
|
||||
X4R4G4B4 = 30
|
||||
A2B10G10R10 = 31
|
||||
A8B8G8R8 = 32
|
||||
X8B8G8R8 = 33
|
||||
G16R16 = 34
|
||||
A2R10G10B10 = 35
|
||||
A16B16G16R16 = 36
|
||||
A8P8 = 40
|
||||
P8 = 41
|
||||
L8 = 50
|
||||
A8L8 = 51
|
||||
A4L4 = 52
|
||||
V8U8 = 60
|
||||
L6V5U5 = 61
|
||||
X8L8V8U8 = 62
|
||||
Q8W8V8U8 = 63
|
||||
V16U16 = 64
|
||||
A2W10V10U10 = 67
|
||||
D16_LOCKABLE = 70
|
||||
D32 = 71
|
||||
D15S1 = 73
|
||||
D24S8 = 75
|
||||
D24X8 = 77
|
||||
D24X4S4 = 79
|
||||
D16 = 80
|
||||
D32F_LOCKABLE = 82
|
||||
D24FS8 = 83
|
||||
D32_LOCKABLE = 84
|
||||
S8_LOCKABLE = 85
|
||||
L16 = 81
|
||||
VERTEXDATA = 100
|
||||
INDEX16 = 101
|
||||
INDEX32 = 102
|
||||
Q16W16V16U16 = 110
|
||||
R16F = 111
|
||||
G16R16F = 112
|
||||
A16B16G16R16F = 113
|
||||
R32F = 114
|
||||
G32R32F = 115
|
||||
A32B32G32R32F = 116
|
||||
CxV8U8 = 117
|
||||
A1 = 118
|
||||
A2B10G10R10_XR_BIAS = 119
|
||||
BINARYBUFFER = 199
|
||||
|
||||
UYVY = i32(b"UYVY")
|
||||
R8G8_B8G8 = i32(b"RGBG")
|
||||
YUY2 = i32(b"YUY2")
|
||||
G8R8_G8B8 = i32(b"GRGB")
|
||||
DXT1 = i32(b"DXT1")
|
||||
DXT2 = i32(b"DXT2")
|
||||
DXT3 = i32(b"DXT3")
|
||||
DXT4 = i32(b"DXT4")
|
||||
DXT5 = i32(b"DXT5")
|
||||
DX10 = i32(b"DX10")
|
||||
BC4S = i32(b"BC4S")
|
||||
BC4U = i32(b"BC4U")
|
||||
BC5S = i32(b"BC5S")
|
||||
BC5U = i32(b"BC5U")
|
||||
ATI1 = i32(b"ATI1")
|
||||
ATI2 = i32(b"ATI2")
|
||||
MULTI2_ARGB8 = i32(b"MET1")
|
||||
|
||||
|
||||
# Backward compatibility layer
|
||||
module = sys.modules[__name__]
|
||||
for item in DDSD:
|
||||
assert item.name is not None
|
||||
setattr(module, f"DDSD_{item.name}", item.value)
|
||||
for item1 in DDSCAPS:
|
||||
assert item1.name is not None
|
||||
setattr(module, f"DDSCAPS_{item1.name}", item1.value)
|
||||
for item2 in DDSCAPS2:
|
||||
assert item2.name is not None
|
||||
setattr(module, f"DDSCAPS2_{item2.name}", item2.value)
|
||||
for item3 in DDPF:
|
||||
assert item3.name is not None
|
||||
setattr(module, f"DDPF_{item3.name}", item3.value)
|
||||
|
||||
DDS_FOURCC = DDPF.FOURCC
|
||||
DDS_RGB = DDPF.RGB
|
||||
DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS
|
||||
DDS_LUMINANCE = DDPF.LUMINANCE
|
||||
DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS
|
||||
DDS_ALPHA = DDPF.ALPHA
|
||||
DDS_PAL8 = DDPF.PALETTEINDEXED8
|
||||
|
||||
DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
||||
DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT
|
||||
DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH
|
||||
DDS_HEADER_FLAGS_PITCH = DDSD.PITCH
|
||||
DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE
|
||||
|
||||
DDS_HEIGHT = DDSD.HEIGHT
|
||||
DDS_WIDTH = DDSD.WIDTH
|
||||
|
||||
DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE
|
||||
DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP
|
||||
DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX
|
||||
|
||||
DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX
|
||||
DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX
|
||||
DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY
|
||||
DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY
|
||||
DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ
|
||||
DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ
|
||||
|
||||
DXT1_FOURCC = D3DFMT.DXT1
|
||||
DXT3_FOURCC = D3DFMT.DXT3
|
||||
DXT5_FOURCC = D3DFMT.DXT5
|
||||
|
||||
DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB
|
||||
DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS
|
||||
DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM
|
||||
DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM
|
||||
DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16
|
||||
DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16
|
||||
DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS
|
||||
DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM
|
||||
DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB
|
||||
|
||||
|
||||
class DdsImageFile(ImageFile.ImageFile):
|
||||
format = "DDS"
|
||||
format_description = "DirectDraw Surface"
|
||||
|
||||
def _open(self) -> None:
|
||||
if not _accept(self.fp.read(4)):
|
||||
msg = "not a DDS file"
|
||||
raise SyntaxError(msg)
|
||||
(header_size,) = struct.unpack("<I", self.fp.read(4))
|
||||
if header_size != 124:
|
||||
msg = f"Unsupported header size {repr(header_size)}"
|
||||
raise OSError(msg)
|
||||
header_bytes = self.fp.read(header_size - 4)
|
||||
if len(header_bytes) != 120:
|
||||
msg = f"Incomplete header: {len(header_bytes)} bytes"
|
||||
raise OSError(msg)
|
||||
header = io.BytesIO(header_bytes)
|
||||
|
||||
flags, height, width = struct.unpack("<3I", header.read(12))
|
||||
self._size = (width, height)
|
||||
extents = (0, 0) + self.size
|
||||
|
||||
pitch, depth, mipmaps = struct.unpack("<3I", header.read(12))
|
||||
struct.unpack("<11I", header.read(44)) # reserved
|
||||
|
||||
# pixel format
|
||||
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header.read(16))
|
||||
n = 0
|
||||
rawmode = None
|
||||
if pfflags & DDPF.RGB:
|
||||
# Texture contains uncompressed RGB data
|
||||
if pfflags & DDPF.ALPHAPIXELS:
|
||||
self._mode = "RGBA"
|
||||
mask_count = 4
|
||||
else:
|
||||
self._mode = "RGB"
|
||||
mask_count = 3
|
||||
|
||||
masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4))
|
||||
self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
|
||||
return
|
||||
elif pfflags & DDPF.LUMINANCE:
|
||||
if bitcount == 8:
|
||||
self._mode = "L"
|
||||
elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS:
|
||||
self._mode = "LA"
|
||||
else:
|
||||
msg = f"Unsupported bitcount {bitcount} for {pfflags}"
|
||||
raise OSError(msg)
|
||||
elif pfflags & DDPF.PALETTEINDEXED8:
|
||||
self._mode = "P"
|
||||
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
|
||||
self.palette.mode = "RGBA"
|
||||
elif pfflags & DDPF.FOURCC:
|
||||
offset = header_size + 4
|
||||
if fourcc == D3DFMT.DXT1:
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "DXT1"
|
||||
n = 1
|
||||
elif fourcc == D3DFMT.DXT3:
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "DXT3"
|
||||
n = 2
|
||||
elif fourcc == D3DFMT.DXT5:
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "DXT5"
|
||||
n = 3
|
||||
elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1):
|
||||
self._mode = "L"
|
||||
self.pixel_format = "BC4"
|
||||
n = 4
|
||||
elif fourcc == D3DFMT.BC5S:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5S"
|
||||
n = 5
|
||||
elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2):
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5"
|
||||
n = 5
|
||||
elif fourcc == D3DFMT.DX10:
|
||||
offset += 20
|
||||
# ignoring flags which pertain to volume textures and cubemaps
|
||||
(dxgi_format,) = struct.unpack("<I", self.fp.read(4))
|
||||
self.fp.read(16)
|
||||
if dxgi_format in (
|
||||
DXGI_FORMAT.BC1_UNORM,
|
||||
DXGI_FORMAT.BC1_TYPELESS,
|
||||
):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC1"
|
||||
n = 1
|
||||
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC2"
|
||||
n = 2
|
||||
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC3"
|
||||
n = 3
|
||||
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
|
||||
self._mode = "L"
|
||||
self.pixel_format = "BC4"
|
||||
n = 4
|
||||
elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM):
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5"
|
||||
n = 5
|
||||
elif dxgi_format == DXGI_FORMAT.BC5_SNORM:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5S"
|
||||
n = 5
|
||||
elif dxgi_format == DXGI_FORMAT.BC6H_UF16:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC6H"
|
||||
n = 6
|
||||
elif dxgi_format == DXGI_FORMAT.BC6H_SF16:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC6HS"
|
||||
n = 6
|
||||
elif dxgi_format in (
|
||||
DXGI_FORMAT.BC7_TYPELESS,
|
||||
DXGI_FORMAT.BC7_UNORM,
|
||||
DXGI_FORMAT.BC7_UNORM_SRGB,
|
||||
):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC7"
|
||||
n = 7
|
||||
if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB:
|
||||
self.info["gamma"] = 1 / 2.2
|
||||
elif dxgi_format in (
|
||||
DXGI_FORMAT.R8G8B8A8_TYPELESS,
|
||||
DXGI_FORMAT.R8G8B8A8_UNORM,
|
||||
DXGI_FORMAT.R8G8B8A8_UNORM_SRGB,
|
||||
):
|
||||
self._mode = "RGBA"
|
||||
if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB:
|
||||
self.info["gamma"] = 1 / 2.2
|
||||
else:
|
||||
msg = f"Unimplemented DXGI format {dxgi_format}"
|
||||
raise NotImplementedError(msg)
|
||||
else:
|
||||
msg = f"Unimplemented pixel format {repr(fourcc)}"
|
||||
raise NotImplementedError(msg)
|
||||
else:
|
||||
msg = f"Unknown pixel format flags {pfflags}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
if n:
|
||||
self.tile = [
|
||||
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
|
||||
]
|
||||
else:
|
||||
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class DdsRgbDecoder(ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
assert self.fd is not None
|
||||
bitcount, masks = self.args
|
||||
|
||||
# Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
|
||||
# Calculate how many zeros each mask is padded with
|
||||
mask_offsets = []
|
||||
# And the maximum value of each channel without the padding
|
||||
mask_totals = []
|
||||
for mask in masks:
|
||||
offset = 0
|
||||
if mask != 0:
|
||||
while mask >> (offset + 1) << (offset + 1) == mask:
|
||||
offset += 1
|
||||
mask_offsets.append(offset)
|
||||
mask_totals.append(mask >> offset)
|
||||
|
||||
data = bytearray()
|
||||
bytecount = bitcount // 8
|
||||
dest_length = self.state.xsize * self.state.ysize * len(masks)
|
||||
while len(data) < dest_length:
|
||||
value = int.from_bytes(self.fd.read(bytecount), "little")
|
||||
for i, mask in enumerate(masks):
|
||||
masked_value = value & mask
|
||||
# Remove the zero padding, and scale it to 8 bits
|
||||
data += o8(
|
||||
int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
|
||||
)
|
||||
self.set_as_raw(data)
|
||||
return -1, 0
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
||||
msg = f"cannot write mode {im.mode} as DDS"
|
||||
raise OSError(msg)
|
||||
|
||||
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
||||
bitcount = len(im.getbands()) * 8
|
||||
pixel_format = im.encoderinfo.get("pixel_format")
|
||||
args: tuple[int] | str
|
||||
if pixel_format:
|
||||
codec_name = "bcn"
|
||||
flags |= DDSD.LINEARSIZE
|
||||
pitch = (im.width + 3) * 4
|
||||
rgba_mask = [0, 0, 0, 0]
|
||||
pixel_flags = DDPF.FOURCC
|
||||
if pixel_format == "DXT1":
|
||||
fourcc = D3DFMT.DXT1
|
||||
args = (1,)
|
||||
elif pixel_format == "DXT3":
|
||||
fourcc = D3DFMT.DXT3
|
||||
args = (2,)
|
||||
elif pixel_format == "DXT5":
|
||||
fourcc = D3DFMT.DXT5
|
||||
args = (3,)
|
||||
else:
|
||||
fourcc = D3DFMT.DX10
|
||||
if pixel_format == "BC2":
|
||||
args = (2,)
|
||||
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
|
||||
elif pixel_format == "BC3":
|
||||
args = (3,)
|
||||
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
|
||||
elif pixel_format == "BC5":
|
||||
args = (5,)
|
||||
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
|
||||
if im.mode != "RGB":
|
||||
msg = "only RGB mode can be written as BC5"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
msg = f"cannot write pixel format {pixel_format}"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
codec_name = "raw"
|
||||
flags |= DDSD.PITCH
|
||||
pitch = (im.width * bitcount + 7) // 8
|
||||
|
||||
alpha = im.mode[-1] == "A"
|
||||
if im.mode[0] == "L":
|
||||
pixel_flags = DDPF.LUMINANCE
|
||||
args = im.mode
|
||||
if alpha:
|
||||
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
|
||||
else:
|
||||
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
|
||||
else:
|
||||
pixel_flags = DDPF.RGB
|
||||
args = im.mode[::-1]
|
||||
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
|
||||
|
||||
if alpha:
|
||||
r, g, b, a = im.split()
|
||||
im = Image.merge("RGBA", (a, r, g, b))
|
||||
if alpha:
|
||||
pixel_flags |= DDPF.ALPHAPIXELS
|
||||
rgba_mask.append(0xFF000000 if alpha else 0)
|
||||
|
||||
fourcc = D3DFMT.UNKNOWN
|
||||
fp.write(
|
||||
o32(DDS_MAGIC)
|
||||
+ struct.pack(
|
||||
"<7I",
|
||||
124, # header size
|
||||
flags, # flags
|
||||
im.height,
|
||||
im.width,
|
||||
pitch,
|
||||
0, # depth
|
||||
0, # mipmaps
|
||||
)
|
||||
+ struct.pack("11I", *((0,) * 11)) # reserved
|
||||
# pfsize, pfflags, fourcc, bitcount
|
||||
+ struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
|
||||
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
|
||||
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
|
||||
)
|
||||
if fourcc == D3DFMT.DX10:
|
||||
fp.write(
|
||||
# dxgi_format, 2D resource, misc, array size, straight alpha
|
||||
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
|
||||
)
|
||||
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"DDS ")
|
||||
|
||||
|
||||
Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
|
||||
Image.register_decoder("dds_rgb", DdsRgbDecoder)
|
||||
Image.register_save(DdsImageFile.format, _save)
|
||||
Image.register_extension(DdsImageFile.format, ".dds")
|
||||
@ -0,0 +1,479 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# EPS file handling
|
||||
#
|
||||
# History:
|
||||
# 1995-09-01 fl Created (0.1)
|
||||
# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
|
||||
# 1996-08-22 fl Don't choke on floating point BoundingBox values
|
||||
# 1996-08-23 fl Handle files from Macintosh (0.3)
|
||||
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
|
||||
# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
|
||||
# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
|
||||
# resizing
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||
# Copyright (c) 1995-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
from ._binary import i32le as i32
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
|
||||
field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
|
||||
|
||||
gs_binary: str | bool | None = None
|
||||
gs_windows_binary = None
|
||||
|
||||
|
||||
def has_ghostscript() -> bool:
|
||||
global gs_binary, gs_windows_binary
|
||||
if gs_binary is None:
|
||||
if sys.platform.startswith("win"):
|
||||
if gs_windows_binary is None:
|
||||
import shutil
|
||||
|
||||
for binary in ("gswin32c", "gswin64c", "gs"):
|
||||
if shutil.which(binary) is not None:
|
||||
gs_windows_binary = binary
|
||||
break
|
||||
else:
|
||||
gs_windows_binary = False
|
||||
gs_binary = gs_windows_binary
|
||||
else:
|
||||
try:
|
||||
subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
|
||||
gs_binary = "gs"
|
||||
except OSError:
|
||||
gs_binary = False
|
||||
return gs_binary is not False
|
||||
|
||||
|
||||
def Ghostscript(
|
||||
tile: list[ImageFile._Tile],
|
||||
size: tuple[int, int],
|
||||
fp: IO[bytes],
|
||||
scale: int = 1,
|
||||
transparency: bool = False,
|
||||
) -> Image.core.ImagingCore:
|
||||
"""Render an image using Ghostscript"""
|
||||
global gs_binary
|
||||
if not has_ghostscript():
|
||||
msg = "Unable to locate Ghostscript on paths"
|
||||
raise OSError(msg)
|
||||
assert isinstance(gs_binary, str)
|
||||
|
||||
# Unpack decoder tile
|
||||
args = tile[0].args
|
||||
assert isinstance(args, tuple)
|
||||
length, bbox = args
|
||||
|
||||
# Hack to support hi-res rendering
|
||||
scale = int(scale) or 1
|
||||
width = size[0] * scale
|
||||
height = size[1] * scale
|
||||
# resolution is dependent on bbox and size
|
||||
res_x = 72.0 * width / (bbox[2] - bbox[0])
|
||||
res_y = 72.0 * height / (bbox[3] - bbox[1])
|
||||
|
||||
out_fd, outfile = tempfile.mkstemp()
|
||||
os.close(out_fd)
|
||||
|
||||
infile_temp = None
|
||||
if hasattr(fp, "name") and os.path.exists(fp.name):
|
||||
infile = fp.name
|
||||
else:
|
||||
in_fd, infile_temp = tempfile.mkstemp()
|
||||
os.close(in_fd)
|
||||
infile = infile_temp
|
||||
|
||||
# Ignore length and offset!
|
||||
# Ghostscript can read it
|
||||
# Copy whole file to read in Ghostscript
|
||||
with open(infile_temp, "wb") as f:
|
||||
# fetch length of fp
|
||||
fp.seek(0, io.SEEK_END)
|
||||
fsize = fp.tell()
|
||||
# ensure start position
|
||||
# go back
|
||||
fp.seek(0)
|
||||
lengthfile = fsize
|
||||
while lengthfile > 0:
|
||||
s = fp.read(min(lengthfile, 100 * 1024))
|
||||
if not s:
|
||||
break
|
||||
lengthfile -= len(s)
|
||||
f.write(s)
|
||||
|
||||
if transparency:
|
||||
# "RGBA"
|
||||
device = "pngalpha"
|
||||
else:
|
||||
# "pnmraw" automatically chooses between
|
||||
# PBM ("1"), PGM ("L"), and PPM ("RGB").
|
||||
device = "pnmraw"
|
||||
|
||||
# Build Ghostscript command
|
||||
command = [
|
||||
gs_binary,
|
||||
"-q", # quiet mode
|
||||
f"-g{width:d}x{height:d}", # set output geometry (pixels)
|
||||
f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
|
||||
"-dBATCH", # exit after processing
|
||||
"-dNOPAUSE", # don't pause between pages
|
||||
"-dSAFER", # safe mode
|
||||
f"-sDEVICE={device}",
|
||||
f"-sOutputFile={outfile}", # output file
|
||||
# adjust for image origin
|
||||
"-c",
|
||||
f"{-bbox[0]} {-bbox[1]} translate",
|
||||
"-f",
|
||||
infile, # input file
|
||||
# showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
|
||||
"-c",
|
||||
"showpage",
|
||||
]
|
||||
|
||||
# push data through Ghostscript
|
||||
try:
|
||||
startupinfo = None
|
||||
if sys.platform.startswith("win"):
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
subprocess.check_call(command, startupinfo=startupinfo)
|
||||
with Image.open(outfile) as out_im:
|
||||
out_im.load()
|
||||
return out_im.im.copy()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(outfile)
|
||||
if infile_temp:
|
||||
os.unlink(infile_temp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"%!PS") or (
|
||||
len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
|
||||
)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for Encapsulated PostScript. This plugin supports only
|
||||
# a few variants of this format.
|
||||
|
||||
|
||||
class EpsImageFile(ImageFile.ImageFile):
|
||||
"""EPS File Parser for the Python Imaging Library"""
|
||||
|
||||
format = "EPS"
|
||||
format_description = "Encapsulated Postscript"
|
||||
|
||||
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
|
||||
|
||||
def _open(self) -> None:
|
||||
(length, offset) = self._find_offset(self.fp)
|
||||
|
||||
# go to offset - start of "%!PS"
|
||||
self.fp.seek(offset)
|
||||
|
||||
self._mode = "RGB"
|
||||
|
||||
# When reading header comments, the first comment is used.
|
||||
# When reading trailer comments, the last comment is used.
|
||||
bounding_box: list[int] | None = None
|
||||
imagedata_size: tuple[int, int] | None = None
|
||||
|
||||
byte_arr = bytearray(255)
|
||||
bytes_mv = memoryview(byte_arr)
|
||||
bytes_read = 0
|
||||
reading_header_comments = True
|
||||
reading_trailer_comments = False
|
||||
trailer_reached = False
|
||||
|
||||
def check_required_header_comments() -> None:
|
||||
"""
|
||||
The EPS specification requires that some headers exist.
|
||||
This should be checked when the header comments formally end,
|
||||
when image data starts, or when the file ends, whichever comes first.
|
||||
"""
|
||||
if "PS-Adobe" not in self.info:
|
||||
msg = 'EPS header missing "%!PS-Adobe" comment'
|
||||
raise SyntaxError(msg)
|
||||
if "BoundingBox" not in self.info:
|
||||
msg = 'EPS header missing "%%BoundingBox" comment'
|
||||
raise SyntaxError(msg)
|
||||
|
||||
def read_comment(s: str) -> bool:
|
||||
nonlocal bounding_box, reading_trailer_comments
|
||||
try:
|
||||
m = split.match(s)
|
||||
except re.error as e:
|
||||
msg = "not an EPS file"
|
||||
raise SyntaxError(msg) from e
|
||||
|
||||
if not m:
|
||||
return False
|
||||
|
||||
k, v = m.group(1, 2)
|
||||
self.info[k] = v
|
||||
if k == "BoundingBox":
|
||||
if v == "(atend)":
|
||||
reading_trailer_comments = True
|
||||
elif not bounding_box or (trailer_reached and reading_trailer_comments):
|
||||
try:
|
||||
# Note: The DSC spec says that BoundingBox
|
||||
# fields should be integers, but some drivers
|
||||
# put floating point values there anyway.
|
||||
bounding_box = [int(float(i)) for i in v.split()]
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
while True:
|
||||
byte = self.fp.read(1)
|
||||
if byte == b"":
|
||||
# if we didn't read a byte we must be at the end of the file
|
||||
if bytes_read == 0:
|
||||
if reading_header_comments:
|
||||
check_required_header_comments()
|
||||
break
|
||||
elif byte in b"\r\n":
|
||||
# if we read a line ending character, ignore it and parse what
|
||||
# we have already read. if we haven't read any other characters,
|
||||
# continue reading
|
||||
if bytes_read == 0:
|
||||
continue
|
||||
else:
|
||||
# ASCII/hexadecimal lines in an EPS file must not exceed
|
||||
# 255 characters, not including line ending characters
|
||||
if bytes_read >= 255:
|
||||
# only enforce this for lines starting with a "%",
|
||||
# otherwise assume it's binary data
|
||||
if byte_arr[0] == ord("%"):
|
||||
msg = "not an EPS file"
|
||||
raise SyntaxError(msg)
|
||||
else:
|
||||
if reading_header_comments:
|
||||
check_required_header_comments()
|
||||
reading_header_comments = False
|
||||
# reset bytes_read so we can keep reading
|
||||
# data until the end of the line
|
||||
bytes_read = 0
|
||||
byte_arr[bytes_read] = byte[0]
|
||||
bytes_read += 1
|
||||
continue
|
||||
|
||||
if reading_header_comments:
|
||||
# Load EPS header
|
||||
|
||||
# if this line doesn't start with a "%",
|
||||
# or does start with "%%EndComments",
|
||||
# then we've reached the end of the header/comments
|
||||
if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
|
||||
check_required_header_comments()
|
||||
reading_header_comments = False
|
||||
continue
|
||||
|
||||
s = str(bytes_mv[:bytes_read], "latin-1")
|
||||
if not read_comment(s):
|
||||
m = field.match(s)
|
||||
if m:
|
||||
k = m.group(1)
|
||||
if k.startswith("PS-Adobe"):
|
||||
self.info["PS-Adobe"] = k[9:]
|
||||
else:
|
||||
self.info[k] = ""
|
||||
elif s[0] == "%":
|
||||
# handle non-DSC PostScript comments that some
|
||||
# tools mistakenly put in the Comments section
|
||||
pass
|
||||
else:
|
||||
msg = "bad EPS header"
|
||||
raise OSError(msg)
|
||||
elif bytes_mv[:11] == b"%ImageData:":
|
||||
# Check for an "ImageData" descriptor
|
||||
# https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
|
||||
|
||||
# If we've already read an "ImageData" descriptor,
|
||||
# don't read another one.
|
||||
if imagedata_size:
|
||||
bytes_read = 0
|
||||
continue
|
||||
|
||||
# Values:
|
||||
# columns
|
||||
# rows
|
||||
# bit depth (1 or 8)
|
||||
# mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
|
||||
# number of padding channels
|
||||
# block size (number of bytes per row per channel)
|
||||
# binary/ascii (1: binary, 2: ascii)
|
||||
# data start identifier (the image data follows after a single line
|
||||
# consisting only of this quoted value)
|
||||
image_data_values = byte_arr[11:bytes_read].split(None, 7)
|
||||
columns, rows, bit_depth, mode_id = (
|
||||
int(value) for value in image_data_values[:4]
|
||||
)
|
||||
|
||||
if bit_depth == 1:
|
||||
self._mode = "1"
|
||||
elif bit_depth == 8:
|
||||
try:
|
||||
self._mode = self.mode_map[mode_id]
|
||||
except ValueError:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
# Parse the columns and rows after checking the bit depth and mode
|
||||
# in case the bit depth and/or mode are invalid.
|
||||
imagedata_size = columns, rows
|
||||
elif bytes_mv[:5] == b"%%EOF":
|
||||
break
|
||||
elif trailer_reached and reading_trailer_comments:
|
||||
# Load EPS trailer
|
||||
s = str(bytes_mv[:bytes_read], "latin-1")
|
||||
read_comment(s)
|
||||
elif bytes_mv[:9] == b"%%Trailer":
|
||||
trailer_reached = True
|
||||
elif bytes_mv[:14] == b"%%BeginBinary:":
|
||||
bytecount = int(byte_arr[14:bytes_read])
|
||||
self.fp.seek(bytecount, os.SEEK_CUR)
|
||||
bytes_read = 0
|
||||
|
||||
# A "BoundingBox" is always required,
|
||||
# even if an "ImageData" descriptor size exists.
|
||||
if not bounding_box:
|
||||
msg = "cannot determine EPS bounding box"
|
||||
raise OSError(msg)
|
||||
|
||||
# An "ImageData" size takes precedence over the "BoundingBox".
|
||||
self._size = imagedata_size or (
|
||||
bounding_box[2] - bounding_box[0],
|
||||
bounding_box[3] - bounding_box[1],
|
||||
)
|
||||
|
||||
self.tile = [
|
||||
ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
|
||||
]
|
||||
|
||||
def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
|
||||
s = fp.read(4)
|
||||
|
||||
if s == b"%!PS":
|
||||
# for HEAD without binary preview
|
||||
fp.seek(0, io.SEEK_END)
|
||||
length = fp.tell()
|
||||
offset = 0
|
||||
elif i32(s) == 0xC6D3D0C5:
|
||||
# FIX for: Some EPS file not handled correctly / issue #302
|
||||
# EPS can contain binary data
|
||||
# or start directly with latin coding
|
||||
# more info see:
|
||||
# https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
|
||||
s = fp.read(8)
|
||||
offset = i32(s)
|
||||
length = i32(s, 4)
|
||||
else:
|
||||
msg = "not an EPS file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
return length, offset
|
||||
|
||||
def load(
|
||||
self, scale: int = 1, transparency: bool = False
|
||||
) -> Image.core.PixelAccess | None:
|
||||
# Load EPS via Ghostscript
|
||||
if self.tile:
|
||||
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
|
||||
self._mode = self.im.mode
|
||||
self._size = self.im.size
|
||||
self.tile = []
|
||||
return Image.Image.load(self)
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
# we can't incrementally load, so force ImageFile.parser to
|
||||
# use our custom load method by defining this method.
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
|
||||
"""EPS Writer for the Python Imaging Library."""
|
||||
|
||||
# make sure image data is available
|
||||
im.load()
|
||||
|
||||
# determine PostScript image mode
|
||||
if im.mode == "L":
|
||||
operator = (8, 1, b"image")
|
||||
elif im.mode == "RGB":
|
||||
operator = (8, 3, b"false 3 colorimage")
|
||||
elif im.mode == "CMYK":
|
||||
operator = (8, 4, b"false 4 colorimage")
|
||||
else:
|
||||
msg = "image mode is not supported"
|
||||
raise ValueError(msg)
|
||||
|
||||
if eps:
|
||||
# write EPS header
|
||||
fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
|
||||
fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
|
||||
# fp.write("%%CreationDate: %s"...)
|
||||
fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
|
||||
fp.write(b"%%Pages: 1\n")
|
||||
fp.write(b"%%EndComments\n")
|
||||
fp.write(b"%%Page: 1 1\n")
|
||||
fp.write(b"%%ImageData: %d %d " % im.size)
|
||||
fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
|
||||
|
||||
# image header
|
||||
fp.write(b"gsave\n")
|
||||
fp.write(b"10 dict begin\n")
|
||||
fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
|
||||
fp.write(b"%d %d scale\n" % im.size)
|
||||
fp.write(b"%d %d 8\n" % im.size) # <= bits
|
||||
fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
|
||||
fp.write(b"{ currentfile buf readhexstring pop } bind\n")
|
||||
fp.write(operator[2] + b"\n")
|
||||
if hasattr(fp, "flush"):
|
||||
fp.flush()
|
||||
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)])
|
||||
|
||||
fp.write(b"\n%%%%EndBinary\n")
|
||||
fp.write(b"grestore end\n")
|
||||
if hasattr(fp, "flush"):
|
||||
fp.flush()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
|
||||
|
||||
Image.register_save(EpsImageFile.format, _save)
|
||||
|
||||
Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
|
||||
|
||||
Image.register_mime(EpsImageFile.format, "application/postscript")
|
||||
@ -0,0 +1,382 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# EXIF tags
|
||||
#
|
||||
# Copyright (c) 2003 by Secret Labs AB
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
"""
|
||||
This module provides constants and clear-text names for various
|
||||
well-known EXIF tags.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class Base(IntEnum):
|
||||
# possibly incomplete
|
||||
InteropIndex = 0x0001
|
||||
ProcessingSoftware = 0x000B
|
||||
NewSubfileType = 0x00FE
|
||||
SubfileType = 0x00FF
|
||||
ImageWidth = 0x0100
|
||||
ImageLength = 0x0101
|
||||
BitsPerSample = 0x0102
|
||||
Compression = 0x0103
|
||||
PhotometricInterpretation = 0x0106
|
||||
Thresholding = 0x0107
|
||||
CellWidth = 0x0108
|
||||
CellLength = 0x0109
|
||||
FillOrder = 0x010A
|
||||
DocumentName = 0x010D
|
||||
ImageDescription = 0x010E
|
||||
Make = 0x010F
|
||||
Model = 0x0110
|
||||
StripOffsets = 0x0111
|
||||
Orientation = 0x0112
|
||||
SamplesPerPixel = 0x0115
|
||||
RowsPerStrip = 0x0116
|
||||
StripByteCounts = 0x0117
|
||||
MinSampleValue = 0x0118
|
||||
MaxSampleValue = 0x0119
|
||||
XResolution = 0x011A
|
||||
YResolution = 0x011B
|
||||
PlanarConfiguration = 0x011C
|
||||
PageName = 0x011D
|
||||
FreeOffsets = 0x0120
|
||||
FreeByteCounts = 0x0121
|
||||
GrayResponseUnit = 0x0122
|
||||
GrayResponseCurve = 0x0123
|
||||
T4Options = 0x0124
|
||||
T6Options = 0x0125
|
||||
ResolutionUnit = 0x0128
|
||||
PageNumber = 0x0129
|
||||
TransferFunction = 0x012D
|
||||
Software = 0x0131
|
||||
DateTime = 0x0132
|
||||
Artist = 0x013B
|
||||
HostComputer = 0x013C
|
||||
Predictor = 0x013D
|
||||
WhitePoint = 0x013E
|
||||
PrimaryChromaticities = 0x013F
|
||||
ColorMap = 0x0140
|
||||
HalftoneHints = 0x0141
|
||||
TileWidth = 0x0142
|
||||
TileLength = 0x0143
|
||||
TileOffsets = 0x0144
|
||||
TileByteCounts = 0x0145
|
||||
SubIFDs = 0x014A
|
||||
InkSet = 0x014C
|
||||
InkNames = 0x014D
|
||||
NumberOfInks = 0x014E
|
||||
DotRange = 0x0150
|
||||
TargetPrinter = 0x0151
|
||||
ExtraSamples = 0x0152
|
||||
SampleFormat = 0x0153
|
||||
SMinSampleValue = 0x0154
|
||||
SMaxSampleValue = 0x0155
|
||||
TransferRange = 0x0156
|
||||
ClipPath = 0x0157
|
||||
XClipPathUnits = 0x0158
|
||||
YClipPathUnits = 0x0159
|
||||
Indexed = 0x015A
|
||||
JPEGTables = 0x015B
|
||||
OPIProxy = 0x015F
|
||||
JPEGProc = 0x0200
|
||||
JpegIFOffset = 0x0201
|
||||
JpegIFByteCount = 0x0202
|
||||
JpegRestartInterval = 0x0203
|
||||
JpegLosslessPredictors = 0x0205
|
||||
JpegPointTransforms = 0x0206
|
||||
JpegQTables = 0x0207
|
||||
JpegDCTables = 0x0208
|
||||
JpegACTables = 0x0209
|
||||
YCbCrCoefficients = 0x0211
|
||||
YCbCrSubSampling = 0x0212
|
||||
YCbCrPositioning = 0x0213
|
||||
ReferenceBlackWhite = 0x0214
|
||||
XMLPacket = 0x02BC
|
||||
RelatedImageFileFormat = 0x1000
|
||||
RelatedImageWidth = 0x1001
|
||||
RelatedImageLength = 0x1002
|
||||
Rating = 0x4746
|
||||
RatingPercent = 0x4749
|
||||
ImageID = 0x800D
|
||||
CFARepeatPatternDim = 0x828D
|
||||
BatteryLevel = 0x828F
|
||||
Copyright = 0x8298
|
||||
ExposureTime = 0x829A
|
||||
FNumber = 0x829D
|
||||
IPTCNAA = 0x83BB
|
||||
ImageResources = 0x8649
|
||||
ExifOffset = 0x8769
|
||||
InterColorProfile = 0x8773
|
||||
ExposureProgram = 0x8822
|
||||
SpectralSensitivity = 0x8824
|
||||
GPSInfo = 0x8825
|
||||
ISOSpeedRatings = 0x8827
|
||||
OECF = 0x8828
|
||||
Interlace = 0x8829
|
||||
TimeZoneOffset = 0x882A
|
||||
SelfTimerMode = 0x882B
|
||||
SensitivityType = 0x8830
|
||||
StandardOutputSensitivity = 0x8831
|
||||
RecommendedExposureIndex = 0x8832
|
||||
ISOSpeed = 0x8833
|
||||
ISOSpeedLatitudeyyy = 0x8834
|
||||
ISOSpeedLatitudezzz = 0x8835
|
||||
ExifVersion = 0x9000
|
||||
DateTimeOriginal = 0x9003
|
||||
DateTimeDigitized = 0x9004
|
||||
OffsetTime = 0x9010
|
||||
OffsetTimeOriginal = 0x9011
|
||||
OffsetTimeDigitized = 0x9012
|
||||
ComponentsConfiguration = 0x9101
|
||||
CompressedBitsPerPixel = 0x9102
|
||||
ShutterSpeedValue = 0x9201
|
||||
ApertureValue = 0x9202
|
||||
BrightnessValue = 0x9203
|
||||
ExposureBiasValue = 0x9204
|
||||
MaxApertureValue = 0x9205
|
||||
SubjectDistance = 0x9206
|
||||
MeteringMode = 0x9207
|
||||
LightSource = 0x9208
|
||||
Flash = 0x9209
|
||||
FocalLength = 0x920A
|
||||
Noise = 0x920D
|
||||
ImageNumber = 0x9211
|
||||
SecurityClassification = 0x9212
|
||||
ImageHistory = 0x9213
|
||||
TIFFEPStandardID = 0x9216
|
||||
MakerNote = 0x927C
|
||||
UserComment = 0x9286
|
||||
SubsecTime = 0x9290
|
||||
SubsecTimeOriginal = 0x9291
|
||||
SubsecTimeDigitized = 0x9292
|
||||
AmbientTemperature = 0x9400
|
||||
Humidity = 0x9401
|
||||
Pressure = 0x9402
|
||||
WaterDepth = 0x9403
|
||||
Acceleration = 0x9404
|
||||
CameraElevationAngle = 0x9405
|
||||
XPTitle = 0x9C9B
|
||||
XPComment = 0x9C9C
|
||||
XPAuthor = 0x9C9D
|
||||
XPKeywords = 0x9C9E
|
||||
XPSubject = 0x9C9F
|
||||
FlashPixVersion = 0xA000
|
||||
ColorSpace = 0xA001
|
||||
ExifImageWidth = 0xA002
|
||||
ExifImageHeight = 0xA003
|
||||
RelatedSoundFile = 0xA004
|
||||
ExifInteroperabilityOffset = 0xA005
|
||||
FlashEnergy = 0xA20B
|
||||
SpatialFrequencyResponse = 0xA20C
|
||||
FocalPlaneXResolution = 0xA20E
|
||||
FocalPlaneYResolution = 0xA20F
|
||||
FocalPlaneResolutionUnit = 0xA210
|
||||
SubjectLocation = 0xA214
|
||||
ExposureIndex = 0xA215
|
||||
SensingMethod = 0xA217
|
||||
FileSource = 0xA300
|
||||
SceneType = 0xA301
|
||||
CFAPattern = 0xA302
|
||||
CustomRendered = 0xA401
|
||||
ExposureMode = 0xA402
|
||||
WhiteBalance = 0xA403
|
||||
DigitalZoomRatio = 0xA404
|
||||
FocalLengthIn35mmFilm = 0xA405
|
||||
SceneCaptureType = 0xA406
|
||||
GainControl = 0xA407
|
||||
Contrast = 0xA408
|
||||
Saturation = 0xA409
|
||||
Sharpness = 0xA40A
|
||||
DeviceSettingDescription = 0xA40B
|
||||
SubjectDistanceRange = 0xA40C
|
||||
ImageUniqueID = 0xA420
|
||||
CameraOwnerName = 0xA430
|
||||
BodySerialNumber = 0xA431
|
||||
LensSpecification = 0xA432
|
||||
LensMake = 0xA433
|
||||
LensModel = 0xA434
|
||||
LensSerialNumber = 0xA435
|
||||
CompositeImage = 0xA460
|
||||
CompositeImageCount = 0xA461
|
||||
CompositeImageExposureTimes = 0xA462
|
||||
Gamma = 0xA500
|
||||
PrintImageMatching = 0xC4A5
|
||||
DNGVersion = 0xC612
|
||||
DNGBackwardVersion = 0xC613
|
||||
UniqueCameraModel = 0xC614
|
||||
LocalizedCameraModel = 0xC615
|
||||
CFAPlaneColor = 0xC616
|
||||
CFALayout = 0xC617
|
||||
LinearizationTable = 0xC618
|
||||
BlackLevelRepeatDim = 0xC619
|
||||
BlackLevel = 0xC61A
|
||||
BlackLevelDeltaH = 0xC61B
|
||||
BlackLevelDeltaV = 0xC61C
|
||||
WhiteLevel = 0xC61D
|
||||
DefaultScale = 0xC61E
|
||||
DefaultCropOrigin = 0xC61F
|
||||
DefaultCropSize = 0xC620
|
||||
ColorMatrix1 = 0xC621
|
||||
ColorMatrix2 = 0xC622
|
||||
CameraCalibration1 = 0xC623
|
||||
CameraCalibration2 = 0xC624
|
||||
ReductionMatrix1 = 0xC625
|
||||
ReductionMatrix2 = 0xC626
|
||||
AnalogBalance = 0xC627
|
||||
AsShotNeutral = 0xC628
|
||||
AsShotWhiteXY = 0xC629
|
||||
BaselineExposure = 0xC62A
|
||||
BaselineNoise = 0xC62B
|
||||
BaselineSharpness = 0xC62C
|
||||
BayerGreenSplit = 0xC62D
|
||||
LinearResponseLimit = 0xC62E
|
||||
CameraSerialNumber = 0xC62F
|
||||
LensInfo = 0xC630
|
||||
ChromaBlurRadius = 0xC631
|
||||
AntiAliasStrength = 0xC632
|
||||
ShadowScale = 0xC633
|
||||
DNGPrivateData = 0xC634
|
||||
MakerNoteSafety = 0xC635
|
||||
CalibrationIlluminant1 = 0xC65A
|
||||
CalibrationIlluminant2 = 0xC65B
|
||||
BestQualityScale = 0xC65C
|
||||
RawDataUniqueID = 0xC65D
|
||||
OriginalRawFileName = 0xC68B
|
||||
OriginalRawFileData = 0xC68C
|
||||
ActiveArea = 0xC68D
|
||||
MaskedAreas = 0xC68E
|
||||
AsShotICCProfile = 0xC68F
|
||||
AsShotPreProfileMatrix = 0xC690
|
||||
CurrentICCProfile = 0xC691
|
||||
CurrentPreProfileMatrix = 0xC692
|
||||
ColorimetricReference = 0xC6BF
|
||||
CameraCalibrationSignature = 0xC6F3
|
||||
ProfileCalibrationSignature = 0xC6F4
|
||||
AsShotProfileName = 0xC6F6
|
||||
NoiseReductionApplied = 0xC6F7
|
||||
ProfileName = 0xC6F8
|
||||
ProfileHueSatMapDims = 0xC6F9
|
||||
ProfileHueSatMapData1 = 0xC6FA
|
||||
ProfileHueSatMapData2 = 0xC6FB
|
||||
ProfileToneCurve = 0xC6FC
|
||||
ProfileEmbedPolicy = 0xC6FD
|
||||
ProfileCopyright = 0xC6FE
|
||||
ForwardMatrix1 = 0xC714
|
||||
ForwardMatrix2 = 0xC715
|
||||
PreviewApplicationName = 0xC716
|
||||
PreviewApplicationVersion = 0xC717
|
||||
PreviewSettingsName = 0xC718
|
||||
PreviewSettingsDigest = 0xC719
|
||||
PreviewColorSpace = 0xC71A
|
||||
PreviewDateTime = 0xC71B
|
||||
RawImageDigest = 0xC71C
|
||||
OriginalRawFileDigest = 0xC71D
|
||||
SubTileBlockSize = 0xC71E
|
||||
RowInterleaveFactor = 0xC71F
|
||||
ProfileLookTableDims = 0xC725
|
||||
ProfileLookTableData = 0xC726
|
||||
OpcodeList1 = 0xC740
|
||||
OpcodeList2 = 0xC741
|
||||
OpcodeList3 = 0xC74E
|
||||
NoiseProfile = 0xC761
|
||||
|
||||
|
||||
"""Maps EXIF tags to tag names."""
|
||||
TAGS = {
|
||||
**{i.value: i.name for i in Base},
|
||||
0x920C: "SpatialFrequencyResponse",
|
||||
0x9214: "SubjectLocation",
|
||||
0x9215: "ExposureIndex",
|
||||
0x828E: "CFAPattern",
|
||||
0x920B: "FlashEnergy",
|
||||
0x9216: "TIFF/EPStandardID",
|
||||
}
|
||||
|
||||
|
||||
class GPS(IntEnum):
|
||||
GPSVersionID = 0x00
|
||||
GPSLatitudeRef = 0x01
|
||||
GPSLatitude = 0x02
|
||||
GPSLongitudeRef = 0x03
|
||||
GPSLongitude = 0x04
|
||||
GPSAltitudeRef = 0x05
|
||||
GPSAltitude = 0x06
|
||||
GPSTimeStamp = 0x07
|
||||
GPSSatellites = 0x08
|
||||
GPSStatus = 0x09
|
||||
GPSMeasureMode = 0x0A
|
||||
GPSDOP = 0x0B
|
||||
GPSSpeedRef = 0x0C
|
||||
GPSSpeed = 0x0D
|
||||
GPSTrackRef = 0x0E
|
||||
GPSTrack = 0x0F
|
||||
GPSImgDirectionRef = 0x10
|
||||
GPSImgDirection = 0x11
|
||||
GPSMapDatum = 0x12
|
||||
GPSDestLatitudeRef = 0x13
|
||||
GPSDestLatitude = 0x14
|
||||
GPSDestLongitudeRef = 0x15
|
||||
GPSDestLongitude = 0x16
|
||||
GPSDestBearingRef = 0x17
|
||||
GPSDestBearing = 0x18
|
||||
GPSDestDistanceRef = 0x19
|
||||
GPSDestDistance = 0x1A
|
||||
GPSProcessingMethod = 0x1B
|
||||
GPSAreaInformation = 0x1C
|
||||
GPSDateStamp = 0x1D
|
||||
GPSDifferential = 0x1E
|
||||
GPSHPositioningError = 0x1F
|
||||
|
||||
|
||||
"""Maps EXIF GPS tags to tag names."""
|
||||
GPSTAGS = {i.value: i.name for i in GPS}
|
||||
|
||||
|
||||
class Interop(IntEnum):
|
||||
InteropIndex = 0x0001
|
||||
InteropVersion = 0x0002
|
||||
RelatedImageFileFormat = 0x1000
|
||||
RelatedImageWidth = 0x1001
|
||||
RelatedImageHeight = 0x1002
|
||||
|
||||
|
||||
class IFD(IntEnum):
|
||||
Exif = 0x8769
|
||||
GPSInfo = 0x8825
|
||||
MakerNote = 0x927C
|
||||
Makernote = 0x927C # Deprecated
|
||||
Interop = 0xA005
|
||||
IFD1 = -1
|
||||
|
||||
|
||||
class LightSource(IntEnum):
|
||||
Unknown = 0x00
|
||||
Daylight = 0x01
|
||||
Fluorescent = 0x02
|
||||
Tungsten = 0x03
|
||||
Flash = 0x04
|
||||
Fine = 0x09
|
||||
Cloudy = 0x0A
|
||||
Shade = 0x0B
|
||||
DaylightFluorescent = 0x0C
|
||||
DayWhiteFluorescent = 0x0D
|
||||
CoolWhiteFluorescent = 0x0E
|
||||
WhiteFluorescent = 0x0F
|
||||
StandardLightA = 0x11
|
||||
StandardLightB = 0x12
|
||||
StandardLightC = 0x13
|
||||
D55 = 0x14
|
||||
D65 = 0x15
|
||||
D75 = 0x16
|
||||
D50 = 0x17
|
||||
ISO = 0x18
|
||||
Other = 0xFF
|
||||
@ -0,0 +1,152 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# FITS file handling
|
||||
#
|
||||
# Copyright (c) 1998-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import math
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"SIMPLE")
|
||||
|
||||
|
||||
class FitsImageFile(ImageFile.ImageFile):
|
||||
format = "FITS"
|
||||
format_description = "FITS"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
|
||||
headers: dict[bytes, bytes] = {}
|
||||
header_in_progress = False
|
||||
decoder_name = ""
|
||||
while True:
|
||||
header = self.fp.read(80)
|
||||
if not header:
|
||||
msg = "Truncated FITS file"
|
||||
raise OSError(msg)
|
||||
keyword = header[:8].strip()
|
||||
if keyword in (b"SIMPLE", b"XTENSION"):
|
||||
header_in_progress = True
|
||||
elif headers and not header_in_progress:
|
||||
# This is now a data unit
|
||||
break
|
||||
elif keyword == b"END":
|
||||
# Seek to the end of the header unit
|
||||
self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880)
|
||||
if not decoder_name:
|
||||
decoder_name, offset, args = self._parse_headers(headers)
|
||||
|
||||
header_in_progress = False
|
||||
continue
|
||||
|
||||
if decoder_name:
|
||||
# Keep going to read past the headers
|
||||
continue
|
||||
|
||||
value = header[8:].split(b"/")[0].strip()
|
||||
if value.startswith(b"="):
|
||||
value = value[1:].strip()
|
||||
if not headers and (not _accept(keyword) or value != b"T"):
|
||||
msg = "Not a FITS file"
|
||||
raise SyntaxError(msg)
|
||||
headers[keyword] = value
|
||||
|
||||
if not decoder_name:
|
||||
msg = "No image data"
|
||||
raise ValueError(msg)
|
||||
|
||||
offset += self.fp.tell() - 80
|
||||
self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)]
|
||||
|
||||
def _get_size(
|
||||
self, headers: dict[bytes, bytes], prefix: bytes
|
||||
) -> tuple[int, int] | None:
|
||||
naxis = int(headers[prefix + b"NAXIS"])
|
||||
if naxis == 0:
|
||||
return None
|
||||
|
||||
if naxis == 1:
|
||||
return 1, int(headers[prefix + b"NAXIS1"])
|
||||
else:
|
||||
return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"])
|
||||
|
||||
def _parse_headers(
|
||||
self, headers: dict[bytes, bytes]
|
||||
) -> tuple[str, int, tuple[str | int, ...]]:
|
||||
prefix = b""
|
||||
decoder_name = "raw"
|
||||
offset = 0
|
||||
if (
|
||||
headers.get(b"XTENSION") == b"'BINTABLE'"
|
||||
and headers.get(b"ZIMAGE") == b"T"
|
||||
and headers[b"ZCMPTYPE"] == b"'GZIP_1 '"
|
||||
):
|
||||
no_prefix_size = self._get_size(headers, prefix) or (0, 0)
|
||||
number_of_bits = int(headers[b"BITPIX"])
|
||||
offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8)
|
||||
|
||||
prefix = b"Z"
|
||||
decoder_name = "fits_gzip"
|
||||
|
||||
size = self._get_size(headers, prefix)
|
||||
if not size:
|
||||
return "", 0, ()
|
||||
|
||||
self._size = size
|
||||
|
||||
number_of_bits = int(headers[prefix + b"BITPIX"])
|
||||
if number_of_bits == 8:
|
||||
self._mode = "L"
|
||||
elif number_of_bits == 16:
|
||||
self._mode = "I;16"
|
||||
elif number_of_bits == 32:
|
||||
self._mode = "I"
|
||||
elif number_of_bits in (-32, -64):
|
||||
self._mode = "F"
|
||||
|
||||
args: tuple[str | int, ...]
|
||||
if decoder_name == "raw":
|
||||
args = (self.mode, 0, -1)
|
||||
else:
|
||||
args = (number_of_bits,)
|
||||
return decoder_name, offset, args
|
||||
|
||||
|
||||
class FitsGzipDecoder(ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
assert self.fd is not None
|
||||
value = gzip.decompress(self.fd.read())
|
||||
|
||||
rows = []
|
||||
offset = 0
|
||||
number_of_bits = min(self.args[0] // 8, 4)
|
||||
for y in range(self.state.ysize):
|
||||
row = bytearray()
|
||||
for x in range(self.state.xsize):
|
||||
row += value[offset + (4 - number_of_bits) : offset + 4]
|
||||
offset += 4
|
||||
rows.append(row)
|
||||
self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row]))
|
||||
return -1, 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
Image.register_open(FitsImageFile.format, FitsImageFile, _accept)
|
||||
Image.register_decoder("fits_gzip", FitsGzipDecoder)
|
||||
|
||||
Image.register_extensions(FitsImageFile.format, [".fit", ".fits"])
|
||||
@ -0,0 +1,184 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# FLI/FLC file handling.
|
||||
#
|
||||
# History:
|
||||
# 95-09-01 fl Created
|
||||
# 97-01-03 fl Fixed parser, setup decoder tile
|
||||
# 98-07-15 fl Renamed offset attribute to avoid name clash
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997-98.
|
||||
# Copyright (c) Fredrik Lundh 1995-97.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._util import DeferredError
|
||||
|
||||
#
|
||||
# decoder
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return (
|
||||
len(prefix) >= 16
|
||||
and i16(prefix, 4) in [0xAF11, 0xAF12]
|
||||
and i16(prefix, 14) in [0, 3] # flags
|
||||
)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the FLI/FLC animation format. Use the <b>seek</b>
|
||||
# method to load individual frames.
|
||||
|
||||
|
||||
class FliImageFile(ImageFile.ImageFile):
|
||||
format = "FLI"
|
||||
format_description = "Autodesk FLI/FLC Animation"
|
||||
_close_exclusive_fp_after_loading = False
|
||||
|
||||
def _open(self) -> None:
|
||||
# HEAD
|
||||
assert self.fp is not None
|
||||
s = self.fp.read(128)
|
||||
if not (
|
||||
_accept(s)
|
||||
and s[20:22] == b"\x00" * 2
|
||||
and s[42:80] == b"\x00" * 38
|
||||
and s[88:] == b"\x00" * 40
|
||||
):
|
||||
msg = "not an FLI/FLC file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# frames
|
||||
self.n_frames = i16(s, 6)
|
||||
self.is_animated = self.n_frames > 1
|
||||
|
||||
# image characteristics
|
||||
self._mode = "P"
|
||||
self._size = i16(s, 8), i16(s, 10)
|
||||
|
||||
# animation speed
|
||||
duration = i32(s, 16)
|
||||
magic = i16(s, 4)
|
||||
if magic == 0xAF11:
|
||||
duration = (duration * 1000) // 70
|
||||
self.info["duration"] = duration
|
||||
|
||||
# look for palette
|
||||
palette = [(a, a, a) for a in range(256)]
|
||||
|
||||
s = self.fp.read(16)
|
||||
|
||||
self.__offset = 128
|
||||
|
||||
if i16(s, 4) == 0xF100:
|
||||
# prefix chunk; ignore it
|
||||
self.fp.seek(self.__offset + i32(s))
|
||||
s = self.fp.read(16)
|
||||
|
||||
if i16(s, 4) == 0xF1FA:
|
||||
# look for palette chunk
|
||||
number_of_subchunks = i16(s, 6)
|
||||
chunk_size: int | None = None
|
||||
for _ in range(number_of_subchunks):
|
||||
if chunk_size is not None:
|
||||
self.fp.seek(chunk_size - 6, os.SEEK_CUR)
|
||||
s = self.fp.read(6)
|
||||
chunk_type = i16(s, 4)
|
||||
if chunk_type in (4, 11):
|
||||
self._palette(palette, 2 if chunk_type == 11 else 0)
|
||||
break
|
||||
chunk_size = i32(s)
|
||||
if not chunk_size:
|
||||
break
|
||||
|
||||
self.palette = ImagePalette.raw(
|
||||
"RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette)
|
||||
)
|
||||
|
||||
# set things up to decode first frame
|
||||
self.__frame = -1
|
||||
self._fp = self.fp
|
||||
self.__rewind = self.fp.tell()
|
||||
self.seek(0)
|
||||
|
||||
def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None:
|
||||
# load palette
|
||||
|
||||
i = 0
|
||||
assert self.fp is not None
|
||||
for e in range(i16(self.fp.read(2))):
|
||||
s = self.fp.read(2)
|
||||
i = i + s[0]
|
||||
n = s[1]
|
||||
if n == 0:
|
||||
n = 256
|
||||
s = self.fp.read(n * 3)
|
||||
for n in range(0, len(s), 3):
|
||||
r = s[n] << shift
|
||||
g = s[n + 1] << shift
|
||||
b = s[n + 2] << shift
|
||||
palette[i] = (r, g, b)
|
||||
i += 1
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
if frame < self.__frame:
|
||||
self._seek(0)
|
||||
|
||||
for f in range(self.__frame + 1, frame + 1):
|
||||
self._seek(f)
|
||||
|
||||
def _seek(self, frame: int) -> None:
|
||||
if isinstance(self._fp, DeferredError):
|
||||
raise self._fp.ex
|
||||
if frame == 0:
|
||||
self.__frame = -1
|
||||
self._fp.seek(self.__rewind)
|
||||
self.__offset = 128
|
||||
else:
|
||||
# ensure that the previous frame was loaded
|
||||
self.load()
|
||||
|
||||
if frame != self.__frame + 1:
|
||||
msg = f"cannot seek to frame {frame}"
|
||||
raise ValueError(msg)
|
||||
self.__frame = frame
|
||||
|
||||
# move to next frame
|
||||
self.fp = self._fp
|
||||
self.fp.seek(self.__offset)
|
||||
|
||||
s = self.fp.read(4)
|
||||
if not s:
|
||||
msg = "missing frame size"
|
||||
raise EOFError(msg)
|
||||
|
||||
framesize = i32(s)
|
||||
|
||||
self.decodermaxblock = framesize
|
||||
self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)]
|
||||
|
||||
self.__offset += framesize
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.__frame
|
||||
|
||||
|
||||
#
|
||||
# registry
|
||||
|
||||
Image.register_open(FliImageFile.format, FliImageFile, _accept)
|
||||
|
||||
Image.register_extensions(FliImageFile.format, [".fli", ".flc"])
|
||||
@ -0,0 +1,134 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# base class for raster font file parsers
|
||||
#
|
||||
# history:
|
||||
# 1997-06-05 fl created
|
||||
# 1997-08-19 fl restrict image width
|
||||
#
|
||||
# Copyright (c) 1997-1998 by Secret Labs AB
|
||||
# Copyright (c) 1997-1998 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import BinaryIO
|
||||
|
||||
from . import Image, _binary
|
||||
|
||||
WIDTH = 800
|
||||
|
||||
|
||||
def puti16(
|
||||
fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int]
|
||||
) -> None:
|
||||
"""Write network order (big-endian) 16-bit sequence"""
|
||||
for v in values:
|
||||
if v < 0:
|
||||
v += 65536
|
||||
fp.write(_binary.o16be(v))
|
||||
|
||||
|
||||
class FontFile:
|
||||
"""Base class for raster font file handlers."""
|
||||
|
||||
bitmap: Image.Image | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.info: dict[bytes, bytes | int] = {}
|
||||
self.glyph: list[
|
||||
tuple[
|
||||
tuple[int, int],
|
||||
tuple[int, int, int, int],
|
||||
tuple[int, int, int, int],
|
||||
Image.Image,
|
||||
]
|
||||
| None
|
||||
] = [None] * 256
|
||||
|
||||
def __getitem__(self, ix: int) -> (
|
||||
tuple[
|
||||
tuple[int, int],
|
||||
tuple[int, int, int, int],
|
||||
tuple[int, int, int, int],
|
||||
Image.Image,
|
||||
]
|
||||
| None
|
||||
):
|
||||
return self.glyph[ix]
|
||||
|
||||
def compile(self) -> None:
|
||||
"""Create metrics and bitmap"""
|
||||
|
||||
if self.bitmap:
|
||||
return
|
||||
|
||||
# create bitmap large enough to hold all data
|
||||
h = w = maxwidth = 0
|
||||
lines = 1
|
||||
for glyph in self.glyph:
|
||||
if glyph:
|
||||
d, dst, src, im = glyph
|
||||
h = max(h, src[3] - src[1])
|
||||
w = w + (src[2] - src[0])
|
||||
if w > WIDTH:
|
||||
lines += 1
|
||||
w = src[2] - src[0]
|
||||
maxwidth = max(maxwidth, w)
|
||||
|
||||
xsize = maxwidth
|
||||
ysize = lines * h
|
||||
|
||||
if xsize == 0 and ysize == 0:
|
||||
return
|
||||
|
||||
self.ysize = h
|
||||
|
||||
# paste glyphs into bitmap
|
||||
self.bitmap = Image.new("1", (xsize, ysize))
|
||||
self.metrics: list[
|
||||
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||
| None
|
||||
] = [None] * 256
|
||||
x = y = 0
|
||||
for i in range(256):
|
||||
glyph = self[i]
|
||||
if glyph:
|
||||
d, dst, src, im = glyph
|
||||
xx = src[2] - src[0]
|
||||
x0, y0 = x, y
|
||||
x = x + xx
|
||||
if x > WIDTH:
|
||||
x, y = 0, y + h
|
||||
x0, y0 = x, y
|
||||
x = xx
|
||||
s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0
|
||||
self.bitmap.paste(im.crop(src), s)
|
||||
self.metrics[i] = d, dst, s
|
||||
|
||||
def save(self, filename: str) -> None:
|
||||
"""Save font"""
|
||||
|
||||
self.compile()
|
||||
|
||||
# font data
|
||||
if not self.bitmap:
|
||||
msg = "No bitmap created"
|
||||
raise ValueError(msg)
|
||||
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
|
||||
|
||||
# font metrics
|
||||
with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp:
|
||||
fp.write(b"PILfont\n")
|
||||
fp.write(f";;;;;;{self.ysize};\n".encode("ascii")) # HACK!!!
|
||||
fp.write(b"DATA\n")
|
||||
for id in range(256):
|
||||
m = self.metrics[id]
|
||||
if not m:
|
||||
puti16(fp, (0,) * 10)
|
||||
else:
|
||||
puti16(fp, m[0] + m[1] + m[2])
|
||||
@ -0,0 +1,257 @@
|
||||
#
|
||||
# THIS IS WORK IN PROGRESS
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# FlashPix support for PIL
|
||||
#
|
||||
# History:
|
||||
# 97-01-25 fl Created (reads uncompressed RGB images only)
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1997.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import olefile
|
||||
|
||||
from . import Image, ImageFile
|
||||
from ._binary import i32le as i32
|
||||
|
||||
# we map from colour field tuples to (mode, rawmode) descriptors
|
||||
MODES = {
|
||||
# opacity
|
||||
(0x00007FFE,): ("A", "L"),
|
||||
# monochrome
|
||||
(0x00010000,): ("L", "L"),
|
||||
(0x00018000, 0x00017FFE): ("RGBA", "LA"),
|
||||
# photo YCC
|
||||
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"),
|
||||
(0x00028000, 0x00028001, 0x00028002, 0x00027FFE): ("RGBA", "YCCA;P"),
|
||||
# standard RGB (NIFRGB)
|
||||
(0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"),
|
||||
(0x00038000, 0x00038001, 0x00038002, 0x00037FFE): ("RGBA", "RGBA"),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(olefile.MAGIC)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the FlashPix images.
|
||||
|
||||
|
||||
class FpxImageFile(ImageFile.ImageFile):
|
||||
format = "FPX"
|
||||
format_description = "FlashPix"
|
||||
|
||||
def _open(self) -> None:
|
||||
#
|
||||
# read the OLE directory and see if this is a likely
|
||||
# to be a FlashPix file
|
||||
|
||||
try:
|
||||
self.ole = olefile.OleFileIO(self.fp)
|
||||
except OSError as e:
|
||||
msg = "not an FPX file; invalid OLE file"
|
||||
raise SyntaxError(msg) from e
|
||||
|
||||
root = self.ole.root
|
||||
if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B":
|
||||
msg = "not an FPX file; bad root CLSID"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self._open_index(1)
|
||||
|
||||
def _open_index(self, index: int = 1) -> None:
|
||||
#
|
||||
# get the Image Contents Property Set
|
||||
|
||||
prop = self.ole.getproperties(
|
||||
[f"Data Object Store {index:06d}", "\005Image Contents"]
|
||||
)
|
||||
|
||||
# size (highest resolution)
|
||||
|
||||
assert isinstance(prop[0x1000002], int)
|
||||
assert isinstance(prop[0x1000003], int)
|
||||
self._size = prop[0x1000002], prop[0x1000003]
|
||||
|
||||
size = max(self.size)
|
||||
i = 1
|
||||
while size > 64:
|
||||
size = size // 2
|
||||
i += 1
|
||||
self.maxid = i - 1
|
||||
|
||||
# mode. instead of using a single field for this, flashpix
|
||||
# requires you to specify the mode for each channel in each
|
||||
# resolution subimage, and leaves it to the decoder to make
|
||||
# sure that they all match. for now, we'll cheat and assume
|
||||
# that this is always the case.
|
||||
|
||||
id = self.maxid << 16
|
||||
|
||||
s = prop[0x2000002 | id]
|
||||
|
||||
if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4:
|
||||
msg = "Invalid number of bands"
|
||||
raise OSError(msg)
|
||||
|
||||
# note: for now, we ignore the "uncalibrated" flag
|
||||
colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands))
|
||||
|
||||
self._mode, self.rawmode = MODES[colors]
|
||||
|
||||
# load JPEG tables, if any
|
||||
self.jpeg = {}
|
||||
for i in range(256):
|
||||
id = 0x3000001 | (i << 16)
|
||||
if id in prop:
|
||||
self.jpeg[i] = prop[id]
|
||||
|
||||
self._open_subimage(1, self.maxid)
|
||||
|
||||
def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
|
||||
#
|
||||
# setup tile descriptors for a given subimage
|
||||
|
||||
stream = [
|
||||
f"Data Object Store {index:06d}",
|
||||
f"Resolution {subimage:04d}",
|
||||
"Subimage 0000 Header",
|
||||
]
|
||||
|
||||
fp = self.ole.openstream(stream)
|
||||
|
||||
# skip prefix
|
||||
fp.read(28)
|
||||
|
||||
# header stream
|
||||
s = fp.read(36)
|
||||
|
||||
size = i32(s, 4), i32(s, 8)
|
||||
# tilecount = i32(s, 12)
|
||||
tilesize = i32(s, 16), i32(s, 20)
|
||||
# channels = i32(s, 24)
|
||||
offset = i32(s, 28)
|
||||
length = i32(s, 32)
|
||||
|
||||
if size != self.size:
|
||||
msg = "subimage mismatch"
|
||||
raise OSError(msg)
|
||||
|
||||
# get tile descriptors
|
||||
fp.seek(28 + offset)
|
||||
s = fp.read(i32(s, 12) * length)
|
||||
|
||||
x = y = 0
|
||||
xsize, ysize = size
|
||||
xtile, ytile = tilesize
|
||||
self.tile = []
|
||||
|
||||
for i in range(0, len(s), length):
|
||||
x1 = min(xsize, x + xtile)
|
||||
y1 = min(ysize, y + ytile)
|
||||
|
||||
compression = i32(s, i + 8)
|
||||
|
||||
if compression == 0:
|
||||
self.tile.append(
|
||||
ImageFile._Tile(
|
||||
"raw",
|
||||
(x, y, x1, y1),
|
||||
i32(s, i) + 28,
|
||||
self.rawmode,
|
||||
)
|
||||
)
|
||||
|
||||
elif compression == 1:
|
||||
# FIXME: the fill decoder is not implemented
|
||||
self.tile.append(
|
||||
ImageFile._Tile(
|
||||
"fill",
|
||||
(x, y, x1, y1),
|
||||
i32(s, i) + 28,
|
||||
(self.rawmode, s[12:16]),
|
||||
)
|
||||
)
|
||||
|
||||
elif compression == 2:
|
||||
internal_color_conversion = s[14]
|
||||
jpeg_tables = s[15]
|
||||
rawmode = self.rawmode
|
||||
|
||||
if internal_color_conversion:
|
||||
# The image is stored as usual (usually YCbCr).
|
||||
if rawmode == "RGBA":
|
||||
# For "RGBA", data is stored as YCbCrA based on
|
||||
# negative RGB. The following trick works around
|
||||
# this problem :
|
||||
jpegmode, rawmode = "YCbCrK", "CMYK"
|
||||
else:
|
||||
jpegmode = None # let the decoder decide
|
||||
|
||||
else:
|
||||
# The image is stored as defined by rawmode
|
||||
jpegmode = rawmode
|
||||
|
||||
self.tile.append(
|
||||
ImageFile._Tile(
|
||||
"jpeg",
|
||||
(x, y, x1, y1),
|
||||
i32(s, i) + 28,
|
||||
(rawmode, jpegmode),
|
||||
)
|
||||
)
|
||||
|
||||
# FIXME: jpeg tables are tile dependent; the prefix
|
||||
# data must be placed in the tile descriptor itself!
|
||||
|
||||
if jpeg_tables:
|
||||
self.tile_prefix = self.jpeg[jpeg_tables]
|
||||
|
||||
else:
|
||||
msg = "unknown/invalid compression"
|
||||
raise OSError(msg)
|
||||
|
||||
x = x + xtile
|
||||
if x >= xsize:
|
||||
x, y = 0, y + ytile
|
||||
if y >= ysize:
|
||||
break # isn't really required
|
||||
|
||||
self.stream = stream
|
||||
self._fp = self.fp
|
||||
self.fp = None
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if not self.fp:
|
||||
self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"])
|
||||
|
||||
return ImageFile.ImageFile.load(self)
|
||||
|
||||
def close(self) -> None:
|
||||
self.ole.close()
|
||||
super().close()
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.ole.close()
|
||||
super().__exit__()
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
Image.register_open(FpxImageFile.format, FpxImageFile, _accept)
|
||||
|
||||
Image.register_extension(FpxImageFile.format, ".fpx")
|
||||
@ -0,0 +1,114 @@
|
||||
"""
|
||||
A Pillow loader for .ftc and .ftu files (FTEX)
|
||||
Jerome Leclanche <jerome@leclan.ch>
|
||||
|
||||
The contents of this file are hereby released in the public domain (CC0)
|
||||
Full text of the CC0 license:
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
Independence War 2: Edge Of Chaos - Texture File Format - 16 October 2001
|
||||
|
||||
The textures used for 3D objects in Independence War 2: Edge Of Chaos are in a
|
||||
packed custom format called FTEX. This file format uses file extensions FTC
|
||||
and FTU.
|
||||
* FTC files are compressed textures (using standard texture compression).
|
||||
* FTU files are not compressed.
|
||||
Texture File Format
|
||||
The FTC and FTU texture files both use the same format. This
|
||||
has the following structure:
|
||||
{header}
|
||||
{format_directory}
|
||||
{data}
|
||||
Where:
|
||||
{header} = {
|
||||
u32:magic,
|
||||
u32:version,
|
||||
u32:width,
|
||||
u32:height,
|
||||
u32:mipmap_count,
|
||||
u32:format_count
|
||||
}
|
||||
|
||||
* The "magic" number is "FTEX".
|
||||
* "width" and "height" are the dimensions of the texture.
|
||||
* "mipmap_count" is the number of mipmaps in the texture.
|
||||
* "format_count" is the number of texture formats (different versions of the
|
||||
same texture) in this file.
|
||||
|
||||
{format_directory} = format_count * { u32:format, u32:where }
|
||||
|
||||
The format value is 0 for DXT1 compressed textures and 1 for 24-bit RGB
|
||||
uncompressed textures.
|
||||
The texture data for a format starts at the position "where" in the file.
|
||||
|
||||
Each set of texture data in the file has the following structure:
|
||||
{data} = format_count * { u32:mipmap_size, mipmap_size * { u8 } }
|
||||
* "mipmap_size" is the number of bytes in that mip level. For compressed
|
||||
textures this is the size of the texture data compressed with DXT1. For 24 bit
|
||||
uncompressed textures, this is 3 * width * height. Following this are the image
|
||||
bytes for that mipmap level.
|
||||
|
||||
Note: All data is stored in little-Endian (Intel) byte order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from io import BytesIO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
MAGIC = b"FTEX"
|
||||
|
||||
|
||||
class Format(IntEnum):
|
||||
DXT1 = 0
|
||||
UNCOMPRESSED = 1
|
||||
|
||||
|
||||
class FtexImageFile(ImageFile.ImageFile):
|
||||
format = "FTEX"
|
||||
format_description = "Texture File Format (IW2:EOC)"
|
||||
|
||||
def _open(self) -> None:
|
||||
if not _accept(self.fp.read(4)):
|
||||
msg = "not an FTEX file"
|
||||
raise SyntaxError(msg)
|
||||
struct.unpack("<i", self.fp.read(4)) # version
|
||||
self._size = struct.unpack("<2i", self.fp.read(8))
|
||||
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
|
||||
|
||||
# Only support single-format files.
|
||||
# I don't know of any multi-format file.
|
||||
assert format_count == 1
|
||||
|
||||
format, where = struct.unpack("<2i", self.fp.read(8))
|
||||
self.fp.seek(where)
|
||||
(mipmap_size,) = struct.unpack("<i", self.fp.read(4))
|
||||
|
||||
data = self.fp.read(mipmap_size)
|
||||
|
||||
if format == Format.DXT1:
|
||||
self._mode = "RGBA"
|
||||
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
|
||||
elif format == Format.UNCOMPRESSED:
|
||||
self._mode = "RGB"
|
||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
|
||||
else:
|
||||
msg = f"Invalid texture compression format: {repr(format)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
self.fp.close()
|
||||
self.fp = BytesIO(data)
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(MAGIC)
|
||||
|
||||
|
||||
Image.register_open(FtexImageFile.format, FtexImageFile, _accept)
|
||||
Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"])
|
||||
@ -0,0 +1,101 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
#
|
||||
# load a GIMP brush file
|
||||
#
|
||||
# History:
|
||||
# 96-03-14 fl Created
|
||||
# 16-01-08 es Version 2
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
# Copyright (c) Eric Soroos 2016.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
#
|
||||
# See https://github.com/GNOME/gimp/blob/mainline/devel-docs/gbr.txt for
|
||||
# format documentation.
|
||||
#
|
||||
# This code Interprets version 1 and 2 .gbr files.
|
||||
# Version 1 files are obsolete, and should not be used for new
|
||||
# brushes.
|
||||
# Version 2 files are saved by GIMP v2.8 (at least)
|
||||
# Version 3 files have a format specifier of 18 for 16bit floats in
|
||||
# the color depth field. This is currently unsupported by Pillow.
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image, ImageFile
|
||||
from ._binary import i32be as i32
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the GIMP brush format.
|
||||
|
||||
|
||||
class GbrImageFile(ImageFile.ImageFile):
|
||||
format = "GBR"
|
||||
format_description = "GIMP brush file"
|
||||
|
||||
def _open(self) -> None:
|
||||
header_size = i32(self.fp.read(4))
|
||||
if header_size < 20:
|
||||
msg = "not a GIMP brush"
|
||||
raise SyntaxError(msg)
|
||||
version = i32(self.fp.read(4))
|
||||
if version not in (1, 2):
|
||||
msg = f"Unsupported GIMP brush version: {version}"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
width = i32(self.fp.read(4))
|
||||
height = i32(self.fp.read(4))
|
||||
color_depth = i32(self.fp.read(4))
|
||||
if width == 0 or height == 0:
|
||||
msg = "not a GIMP brush"
|
||||
raise SyntaxError(msg)
|
||||
if color_depth not in (1, 4):
|
||||
msg = f"Unsupported GIMP brush color depth: {color_depth}"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if version == 1:
|
||||
comment_length = header_size - 20
|
||||
else:
|
||||
comment_length = header_size - 28
|
||||
magic_number = self.fp.read(4)
|
||||
if magic_number != b"GIMP":
|
||||
msg = "not a GIMP brush, bad magic number"
|
||||
raise SyntaxError(msg)
|
||||
self.info["spacing"] = i32(self.fp.read(4))
|
||||
|
||||
self.info["comment"] = self.fp.read(comment_length)[:-1]
|
||||
|
||||
if color_depth == 1:
|
||||
self._mode = "L"
|
||||
else:
|
||||
self._mode = "RGBA"
|
||||
|
||||
self._size = width, height
|
||||
|
||||
# Image might not be small
|
||||
Image._decompression_bomb_check(self.size)
|
||||
|
||||
# Data is an uncompressed block of w * h * bytes/pixel
|
||||
self._data_size = width * height * color_depth
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if self._im is None:
|
||||
self.im = Image.core.new(self.mode, self.size)
|
||||
self.frombytes(self.fp.read(self._data_size))
|
||||
return Image.Image.load(self)
|
||||
|
||||
|
||||
#
|
||||
# registry
|
||||
|
||||
|
||||
Image.register_open(GbrImageFile.format, GbrImageFile, _accept)
|
||||
Image.register_extension(GbrImageFile.format, ".gbr")
|
||||
@ -0,0 +1,102 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# GD file handling
|
||||
#
|
||||
# History:
|
||||
# 1996-04-12 fl Created
|
||||
#
|
||||
# Copyright (c) 1997 by Secret Labs AB.
|
||||
# Copyright (c) 1996 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
|
||||
"""
|
||||
.. note::
|
||||
This format cannot be automatically recognized, so the
|
||||
class is not registered for use with :py:func:`PIL.Image.open()`. To open a
|
||||
gd file, use the :py:func:`PIL.GdImageFile.open()` function instead.
|
||||
|
||||
.. warning::
|
||||
THE GD FORMAT IS NOT DESIGNED FOR DATA INTERCHANGE. This
|
||||
implementation is provided for convenience and demonstrational
|
||||
purposes only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import IO
|
||||
|
||||
from . import ImageFile, ImagePalette, UnidentifiedImageError
|
||||
from ._binary import i16be as i16
|
||||
from ._binary import i32be as i32
|
||||
from ._typing import StrOrBytesPath
|
||||
|
||||
|
||||
class GdImageFile(ImageFile.ImageFile):
|
||||
"""
|
||||
Image plugin for the GD uncompressed format. Note that this format
|
||||
is not supported by the standard :py:func:`PIL.Image.open()` function. To use
|
||||
this plugin, you have to import the :py:mod:`PIL.GdImageFile` module and
|
||||
use the :py:func:`PIL.GdImageFile.open()` function.
|
||||
"""
|
||||
|
||||
format = "GD"
|
||||
format_description = "GD uncompressed images"
|
||||
|
||||
def _open(self) -> None:
|
||||
# Header
|
||||
assert self.fp is not None
|
||||
|
||||
s = self.fp.read(1037)
|
||||
|
||||
if i16(s) not in [65534, 65535]:
|
||||
msg = "Not a valid GD 2.x .gd file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self._mode = "P"
|
||||
self._size = i16(s, 2), i16(s, 4)
|
||||
|
||||
true_color = s[6]
|
||||
true_color_offset = 2 if true_color else 0
|
||||
|
||||
# transparency index
|
||||
tindex = i32(s, 7 + true_color_offset)
|
||||
if tindex < 256:
|
||||
self.info["transparency"] = tindex
|
||||
|
||||
self.palette = ImagePalette.raw(
|
||||
"RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
|
||||
)
|
||||
|
||||
self.tile = [
|
||||
ImageFile._Tile(
|
||||
"raw",
|
||||
(0, 0) + self.size,
|
||||
7 + true_color_offset + 6 + 256 * 4,
|
||||
"L",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile:
|
||||
"""
|
||||
Load texture from a GD image file.
|
||||
|
||||
:param fp: GD file name, or an opened file handle.
|
||||
:param mode: Optional mode. In this version, if the mode argument
|
||||
is given, it must be "r".
|
||||
:returns: An image instance.
|
||||
:raises OSError: If the image could not be read.
|
||||
"""
|
||||
if mode != "r":
|
||||
msg = "bad mode"
|
||||
raise ValueError(msg)
|
||||
|
||||
try:
|
||||
return GdImageFile(fp)
|
||||
except SyntaxError as e:
|
||||
msg = "cannot identify this image file"
|
||||
raise UnidentifiedImageError(msg) from e
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,153 @@
|
||||
#
|
||||
# Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# stuff to read (and render) GIMP gradient files
|
||||
#
|
||||
# History:
|
||||
# 97-08-23 fl Created
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1997.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
"""
|
||||
Stuff to translate curve segments to palette values (derived from
|
||||
the corresponding code in GIMP, written by Federico Mena Quintero.
|
||||
See the GIMP distribution for more information.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from math import log, pi, sin, sqrt
|
||||
|
||||
from ._binary import o8
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from typing import IO
|
||||
|
||||
EPSILON = 1e-10
|
||||
"""""" # Enable auto-doc for data member
|
||||
|
||||
|
||||
def linear(middle: float, pos: float) -> float:
|
||||
if pos <= middle:
|
||||
if middle < EPSILON:
|
||||
return 0.0
|
||||
else:
|
||||
return 0.5 * pos / middle
|
||||
else:
|
||||
pos = pos - middle
|
||||
middle = 1.0 - middle
|
||||
if middle < EPSILON:
|
||||
return 1.0
|
||||
else:
|
||||
return 0.5 + 0.5 * pos / middle
|
||||
|
||||
|
||||
def curved(middle: float, pos: float) -> float:
|
||||
return pos ** (log(0.5) / log(max(middle, EPSILON)))
|
||||
|
||||
|
||||
def sine(middle: float, pos: float) -> float:
|
||||
return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0
|
||||
|
||||
|
||||
def sphere_increasing(middle: float, pos: float) -> float:
|
||||
return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2)
|
||||
|
||||
|
||||
def sphere_decreasing(middle: float, pos: float) -> float:
|
||||
return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2)
|
||||
|
||||
|
||||
SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
|
||||
"""""" # Enable auto-doc for data member
|
||||
|
||||
|
||||
class GradientFile:
|
||||
gradient: (
|
||||
list[
|
||||
tuple[
|
||||
float,
|
||||
float,
|
||||
float,
|
||||
list[float],
|
||||
list[float],
|
||||
Callable[[float, float], float],
|
||||
]
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
|
||||
def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
|
||||
assert self.gradient is not None
|
||||
palette = []
|
||||
|
||||
ix = 0
|
||||
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
||||
|
||||
for i in range(entries):
|
||||
x = i / (entries - 1)
|
||||
|
||||
while x1 < x:
|
||||
ix += 1
|
||||
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
||||
|
||||
w = x1 - x0
|
||||
|
||||
if w < EPSILON:
|
||||
scale = segment(0.5, 0.5)
|
||||
else:
|
||||
scale = segment((xm - x0) / w, (x - x0) / w)
|
||||
|
||||
# expand to RGBA
|
||||
r = o8(int(255 * ((rgb1[0] - rgb0[0]) * scale + rgb0[0]) + 0.5))
|
||||
g = o8(int(255 * ((rgb1[1] - rgb0[1]) * scale + rgb0[1]) + 0.5))
|
||||
b = o8(int(255 * ((rgb1[2] - rgb0[2]) * scale + rgb0[2]) + 0.5))
|
||||
a = o8(int(255 * ((rgb1[3] - rgb0[3]) * scale + rgb0[3]) + 0.5))
|
||||
|
||||
# add to palette
|
||||
palette.append(r + g + b + a)
|
||||
|
||||
return b"".join(palette), "RGBA"
|
||||
|
||||
|
||||
class GimpGradientFile(GradientFile):
|
||||
"""File handler for GIMP's gradient format."""
|
||||
|
||||
def __init__(self, fp: IO[bytes]) -> None:
|
||||
if not fp.readline().startswith(b"GIMP Gradient"):
|
||||
msg = "not a GIMP gradient file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
line = fp.readline()
|
||||
|
||||
# GIMP 1.2 gradient files don't contain a name, but GIMP 1.3 files do
|
||||
if line.startswith(b"Name: "):
|
||||
line = fp.readline().strip()
|
||||
|
||||
count = int(line)
|
||||
|
||||
self.gradient = []
|
||||
|
||||
for i in range(count):
|
||||
s = fp.readline().split()
|
||||
w = [float(x) for x in s[:11]]
|
||||
|
||||
x0, x1 = w[0], w[2]
|
||||
xm = w[1]
|
||||
rgb0 = w[3:7]
|
||||
rgb1 = w[7:11]
|
||||
|
||||
segment = SEGMENTS[int(s[11])]
|
||||
cspace = int(s[12])
|
||||
|
||||
if cspace != 0:
|
||||
msg = "cannot handle HSV colour space"
|
||||
raise OSError(msg)
|
||||
|
||||
self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))
|
||||
@ -0,0 +1,75 @@
|
||||
#
|
||||
# Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# stuff to read GIMP palette files
|
||||
#
|
||||
# History:
|
||||
# 1997-08-23 fl Created
|
||||
# 2004-09-07 fl Support GIMP 2.0 palette files.
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997-2004. All rights reserved.
|
||||
# Copyright (c) Fredrik Lundh 1997-2004.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from typing import IO
|
||||
|
||||
|
||||
class GimpPaletteFile:
|
||||
"""File handler for GIMP's palette format."""
|
||||
|
||||
rawmode = "RGB"
|
||||
|
||||
def _read(self, fp: IO[bytes], limit: bool = True) -> None:
|
||||
if not fp.readline().startswith(b"GIMP Palette"):
|
||||
msg = "not a GIMP palette file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
palette: list[int] = []
|
||||
i = 0
|
||||
while True:
|
||||
if limit and i == 256 + 3:
|
||||
break
|
||||
|
||||
i += 1
|
||||
s = fp.readline()
|
||||
if not s:
|
||||
break
|
||||
|
||||
# skip fields and comment lines
|
||||
if re.match(rb"\w+:|#", s):
|
||||
continue
|
||||
if limit and len(s) > 100:
|
||||
msg = "bad palette file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
v = s.split(maxsplit=3)
|
||||
if len(v) < 3:
|
||||
msg = "bad palette entry"
|
||||
raise ValueError(msg)
|
||||
|
||||
palette += (int(v[i]) for i in range(3))
|
||||
if limit and len(palette) == 768:
|
||||
break
|
||||
|
||||
self.palette = bytes(palette)
|
||||
|
||||
def __init__(self, fp: IO[bytes]) -> None:
|
||||
self._read(fp)
|
||||
|
||||
@classmethod
|
||||
def frombytes(cls, data: bytes) -> GimpPaletteFile:
|
||||
self = cls.__new__(cls)
|
||||
self._read(BytesIO(data), False)
|
||||
return self
|
||||
|
||||
def getpalette(self) -> tuple[bytes, str]:
|
||||
return self.palette, self.rawmode
|
||||
@ -0,0 +1,75 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# GRIB stub adapter
|
||||
#
|
||||
# Copyright (c) 1996-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
_handler = None
|
||||
|
||||
|
||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
||||
"""
|
||||
Install application-specific GRIB image handler.
|
||||
|
||||
:param handler: Handler object.
|
||||
"""
|
||||
global _handler
|
||||
_handler = handler
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Image adapter
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1
|
||||
|
||||
|
||||
class GribStubImageFile(ImageFile.StubImageFile):
|
||||
format = "GRIB"
|
||||
format_description = "GRIB"
|
||||
|
||||
def _open(self) -> None:
|
||||
if not _accept(self.fp.read(8)):
|
||||
msg = "Not a GRIB file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self.fp.seek(-8, os.SEEK_CUR)
|
||||
|
||||
# make something up
|
||||
self._mode = "F"
|
||||
self._size = 1, 1
|
||||
|
||||
loader = self._load()
|
||||
if loader:
|
||||
loader.open(self)
|
||||
|
||||
def _load(self) -> ImageFile.StubHandler | None:
|
||||
return _handler
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if _handler is None or not hasattr(_handler, "save"):
|
||||
msg = "GRIB save handler not installed"
|
||||
raise OSError(msg)
|
||||
_handler.save(im, fp, filename)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
Image.register_open(GribStubImageFile.format, GribStubImageFile, _accept)
|
||||
Image.register_save(GribStubImageFile.format, _save)
|
||||
|
||||
Image.register_extension(GribStubImageFile.format, ".grib")
|
||||
@ -0,0 +1,75 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# HDF5 stub adapter
|
||||
#
|
||||
# Copyright (c) 2000-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
_handler = None
|
||||
|
||||
|
||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
||||
"""
|
||||
Install application-specific HDF5 image handler.
|
||||
|
||||
:param handler: Handler object.
|
||||
"""
|
||||
global _handler
|
||||
_handler = handler
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Image adapter
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"\x89HDF\r\n\x1a\n")
|
||||
|
||||
|
||||
class HDF5StubImageFile(ImageFile.StubImageFile):
|
||||
format = "HDF5"
|
||||
format_description = "HDF5"
|
||||
|
||||
def _open(self) -> None:
|
||||
if not _accept(self.fp.read(8)):
|
||||
msg = "Not an HDF file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self.fp.seek(-8, os.SEEK_CUR)
|
||||
|
||||
# make something up
|
||||
self._mode = "F"
|
||||
self._size = 1, 1
|
||||
|
||||
loader = self._load()
|
||||
if loader:
|
||||
loader.open(self)
|
||||
|
||||
def _load(self) -> ImageFile.StubHandler | None:
|
||||
return _handler
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if _handler is None or not hasattr(_handler, "save"):
|
||||
msg = "HDF5 save handler not installed"
|
||||
raise OSError(msg)
|
||||
_handler.save(im, fp, filename)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
Image.register_open(HDF5StubImageFile.format, HDF5StubImageFile, _accept)
|
||||
Image.register_save(HDF5StubImageFile.format, _save)
|
||||
|
||||
Image.register_extensions(HDF5StubImageFile.format, [".h5", ".hdf"])
|
||||
@ -0,0 +1,401 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# macOS icns file decoder, based on icns.py by Bob Ippolito.
|
||||
#
|
||||
# history:
|
||||
# 2004-10-09 fl Turned into a PIL plugin; removed 2.3 dependencies.
|
||||
# 2020-04-04 Allow saving on all operating systems.
|
||||
#
|
||||
# Copyright (c) 2004 by Bob Ippolito.
|
||||
# Copyright (c) 2004 by Secret Labs.
|
||||
# Copyright (c) 2004 by Fredrik Lundh.
|
||||
# Copyright (c) 2014 by Alastair Houghton.
|
||||
# Copyright (c) 2020 by Pan Jing.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile, PngImagePlugin, features
|
||||
|
||||
enable_jpeg2k = features.check_codec("jpg_2000")
|
||||
if enable_jpeg2k:
|
||||
from . import Jpeg2KImagePlugin
|
||||
|
||||
MAGIC = b"icns"
|
||||
HEADERSIZE = 8
|
||||
|
||||
|
||||
def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]:
|
||||
return struct.unpack(">4sI", fobj.read(HEADERSIZE))
|
||||
|
||||
|
||||
def read_32t(
|
||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
||||
) -> dict[str, Image.Image]:
|
||||
# The 128x128 icon seems to have an extra header for some reason.
|
||||
(start, length) = start_length
|
||||
fobj.seek(start)
|
||||
sig = fobj.read(4)
|
||||
if sig != b"\x00\x00\x00\x00":
|
||||
msg = "Unknown signature, expecting 0x00000000"
|
||||
raise SyntaxError(msg)
|
||||
return read_32(fobj, (start + 4, length - 4), size)
|
||||
|
||||
|
||||
def read_32(
|
||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
||||
) -> dict[str, Image.Image]:
|
||||
"""
|
||||
Read a 32bit RGB icon resource. Seems to be either uncompressed or
|
||||
an RLE packbits-like scheme.
|
||||
"""
|
||||
(start, length) = start_length
|
||||
fobj.seek(start)
|
||||
pixel_size = (size[0] * size[2], size[1] * size[2])
|
||||
sizesq = pixel_size[0] * pixel_size[1]
|
||||
if length == sizesq * 3:
|
||||
# uncompressed ("RGBRGBGB")
|
||||
indata = fobj.read(length)
|
||||
im = Image.frombuffer("RGB", pixel_size, indata, "raw", "RGB", 0, 1)
|
||||
else:
|
||||
# decode image
|
||||
im = Image.new("RGB", pixel_size, None)
|
||||
for band_ix in range(3):
|
||||
data = []
|
||||
bytesleft = sizesq
|
||||
while bytesleft > 0:
|
||||
byte = fobj.read(1)
|
||||
if not byte:
|
||||
break
|
||||
byte_int = byte[0]
|
||||
if byte_int & 0x80:
|
||||
blocksize = byte_int - 125
|
||||
byte = fobj.read(1)
|
||||
for i in range(blocksize):
|
||||
data.append(byte)
|
||||
else:
|
||||
blocksize = byte_int + 1
|
||||
data.append(fobj.read(blocksize))
|
||||
bytesleft -= blocksize
|
||||
if bytesleft <= 0:
|
||||
break
|
||||
if bytesleft != 0:
|
||||
msg = f"Error reading channel [{repr(bytesleft)} left]"
|
||||
raise SyntaxError(msg)
|
||||
band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1)
|
||||
im.im.putband(band.im, band_ix)
|
||||
return {"RGB": im}
|
||||
|
||||
|
||||
def read_mk(
|
||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
||||
) -> dict[str, Image.Image]:
|
||||
# Alpha masks seem to be uncompressed
|
||||
start = start_length[0]
|
||||
fobj.seek(start)
|
||||
pixel_size = (size[0] * size[2], size[1] * size[2])
|
||||
sizesq = pixel_size[0] * pixel_size[1]
|
||||
band = Image.frombuffer("L", pixel_size, fobj.read(sizesq), "raw", "L", 0, 1)
|
||||
return {"A": band}
|
||||
|
||||
|
||||
def read_png_or_jpeg2000(
|
||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
||||
) -> dict[str, Image.Image]:
|
||||
(start, length) = start_length
|
||||
fobj.seek(start)
|
||||
sig = fobj.read(12)
|
||||
|
||||
im: Image.Image
|
||||
if sig.startswith(b"\x89PNG\x0d\x0a\x1a\x0a"):
|
||||
fobj.seek(start)
|
||||
im = PngImagePlugin.PngImageFile(fobj)
|
||||
Image._decompression_bomb_check(im.size)
|
||||
return {"RGBA": im}
|
||||
elif (
|
||||
sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a"))
|
||||
or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
|
||||
):
|
||||
if not enable_jpeg2k:
|
||||
msg = (
|
||||
"Unsupported icon subimage format (rebuild PIL "
|
||||
"with JPEG 2000 support to fix this)"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
# j2k, jpc or j2c
|
||||
fobj.seek(start)
|
||||
jp2kstream = fobj.read(length)
|
||||
f = io.BytesIO(jp2kstream)
|
||||
im = Jpeg2KImagePlugin.Jpeg2KImageFile(f)
|
||||
Image._decompression_bomb_check(im.size)
|
||||
if im.mode != "RGBA":
|
||||
im = im.convert("RGBA")
|
||||
return {"RGBA": im}
|
||||
else:
|
||||
msg = "Unsupported icon subimage format"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
class IcnsFile:
|
||||
SIZES = {
|
||||
(512, 512, 2): [(b"ic10", read_png_or_jpeg2000)],
|
||||
(512, 512, 1): [(b"ic09", read_png_or_jpeg2000)],
|
||||
(256, 256, 2): [(b"ic14", read_png_or_jpeg2000)],
|
||||
(256, 256, 1): [(b"ic08", read_png_or_jpeg2000)],
|
||||
(128, 128, 2): [(b"ic13", read_png_or_jpeg2000)],
|
||||
(128, 128, 1): [
|
||||
(b"ic07", read_png_or_jpeg2000),
|
||||
(b"it32", read_32t),
|
||||
(b"t8mk", read_mk),
|
||||
],
|
||||
(64, 64, 1): [(b"icp6", read_png_or_jpeg2000)],
|
||||
(32, 32, 2): [(b"ic12", read_png_or_jpeg2000)],
|
||||
(48, 48, 1): [(b"ih32", read_32), (b"h8mk", read_mk)],
|
||||
(32, 32, 1): [
|
||||
(b"icp5", read_png_or_jpeg2000),
|
||||
(b"il32", read_32),
|
||||
(b"l8mk", read_mk),
|
||||
],
|
||||
(16, 16, 2): [(b"ic11", read_png_or_jpeg2000)],
|
||||
(16, 16, 1): [
|
||||
(b"icp4", read_png_or_jpeg2000),
|
||||
(b"is32", read_32),
|
||||
(b"s8mk", read_mk),
|
||||
],
|
||||
}
|
||||
|
||||
def __init__(self, fobj: IO[bytes]) -> None:
|
||||
"""
|
||||
fobj is a file-like object as an icns resource
|
||||
"""
|
||||
# signature : (start, length)
|
||||
self.dct = {}
|
||||
self.fobj = fobj
|
||||
sig, filesize = nextheader(fobj)
|
||||
if not _accept(sig):
|
||||
msg = "not an icns file"
|
||||
raise SyntaxError(msg)
|
||||
i = HEADERSIZE
|
||||
while i < filesize:
|
||||
sig, blocksize = nextheader(fobj)
|
||||
if blocksize <= 0:
|
||||
msg = "invalid block header"
|
||||
raise SyntaxError(msg)
|
||||
i += HEADERSIZE
|
||||
blocksize -= HEADERSIZE
|
||||
self.dct[sig] = (i, blocksize)
|
||||
fobj.seek(blocksize, io.SEEK_CUR)
|
||||
i += blocksize
|
||||
|
||||
def itersizes(self) -> list[tuple[int, int, int]]:
|
||||
sizes = []
|
||||
for size, fmts in self.SIZES.items():
|
||||
for fmt, reader in fmts:
|
||||
if fmt in self.dct:
|
||||
sizes.append(size)
|
||||
break
|
||||
return sizes
|
||||
|
||||
def bestsize(self) -> tuple[int, int, int]:
|
||||
sizes = self.itersizes()
|
||||
if not sizes:
|
||||
msg = "No 32bit icon resources found"
|
||||
raise SyntaxError(msg)
|
||||
return max(sizes)
|
||||
|
||||
def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]:
|
||||
"""
|
||||
Get an icon resource as {channel: array}. Note that
|
||||
the arrays are bottom-up like windows bitmaps and will likely
|
||||
need to be flipped or transposed in some way.
|
||||
"""
|
||||
dct = {}
|
||||
for code, reader in self.SIZES[size]:
|
||||
desc = self.dct.get(code)
|
||||
if desc is not None:
|
||||
dct.update(reader(self.fobj, desc, size))
|
||||
return dct
|
||||
|
||||
def getimage(
|
||||
self, size: tuple[int, int] | tuple[int, int, int] | None = None
|
||||
) -> Image.Image:
|
||||
if size is None:
|
||||
size = self.bestsize()
|
||||
elif len(size) == 2:
|
||||
size = (size[0], size[1], 1)
|
||||
channels = self.dataforsize(size)
|
||||
|
||||
im = channels.get("RGBA")
|
||||
if im:
|
||||
return im
|
||||
|
||||
im = channels["RGB"].copy()
|
||||
try:
|
||||
im.putalpha(channels["A"])
|
||||
except KeyError:
|
||||
pass
|
||||
return im
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for Mac OS icons.
|
||||
|
||||
|
||||
class IcnsImageFile(ImageFile.ImageFile):
|
||||
"""
|
||||
PIL image support for Mac OS .icns files.
|
||||
Chooses the best resolution, but will possibly load
|
||||
a different size image if you mutate the size attribute
|
||||
before calling 'load'.
|
||||
|
||||
The info dictionary has a key 'sizes' that is a list
|
||||
of sizes that the icns file has.
|
||||
"""
|
||||
|
||||
format = "ICNS"
|
||||
format_description = "Mac OS icns resource"
|
||||
|
||||
def _open(self) -> None:
|
||||
self.icns = IcnsFile(self.fp)
|
||||
self._mode = "RGBA"
|
||||
self.info["sizes"] = self.icns.itersizes()
|
||||
self.best_size = self.icns.bestsize()
|
||||
self.size = (
|
||||
self.best_size[0] * self.best_size[2],
|
||||
self.best_size[1] * self.best_size[2],
|
||||
)
|
||||
|
||||
@property
|
||||
def size(self) -> tuple[int, int]:
|
||||
return self._size
|
||||
|
||||
@size.setter
|
||||
def size(self, value: tuple[int, int]) -> None:
|
||||
# Check that a matching size exists,
|
||||
# or that there is a scale that would create a size that matches
|
||||
for size in self.info["sizes"]:
|
||||
simple_size = size[0] * size[2], size[1] * size[2]
|
||||
scale = simple_size[0] // value[0]
|
||||
if simple_size[1] / value[1] == scale:
|
||||
self._size = value
|
||||
return
|
||||
msg = "This is not one of the allowed sizes of this image"
|
||||
raise ValueError(msg)
|
||||
|
||||
def load(self, scale: int | None = None) -> Image.core.PixelAccess | None:
|
||||
if scale is not None:
|
||||
width, height = self.size[:2]
|
||||
self.size = width * scale, height * scale
|
||||
self.best_size = width, height, scale
|
||||
|
||||
px = Image.Image.load(self)
|
||||
if self._im is not None and self.im.size == self.size:
|
||||
# Already loaded
|
||||
return px
|
||||
self.load_prepare()
|
||||
# This is likely NOT the best way to do it, but whatever.
|
||||
im = self.icns.getimage(self.best_size)
|
||||
|
||||
# If this is a PNG or JPEG 2000, it won't be loaded yet
|
||||
px = im.load()
|
||||
|
||||
self.im = im.im
|
||||
self._mode = im.mode
|
||||
self.size = im.size
|
||||
|
||||
return px
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
"""
|
||||
Saves the image as a series of PNG files,
|
||||
that are then combined into a .icns file.
|
||||
"""
|
||||
if hasattr(fp, "flush"):
|
||||
fp.flush()
|
||||
|
||||
sizes = {
|
||||
b"ic07": 128,
|
||||
b"ic08": 256,
|
||||
b"ic09": 512,
|
||||
b"ic10": 1024,
|
||||
b"ic11": 32,
|
||||
b"ic12": 64,
|
||||
b"ic13": 256,
|
||||
b"ic14": 512,
|
||||
}
|
||||
provided_images = {im.width: im for im in im.encoderinfo.get("append_images", [])}
|
||||
size_streams = {}
|
||||
for size in set(sizes.values()):
|
||||
image = (
|
||||
provided_images[size]
|
||||
if size in provided_images
|
||||
else im.resize((size, size))
|
||||
)
|
||||
|
||||
temp = io.BytesIO()
|
||||
image.save(temp, "png")
|
||||
size_streams[size] = temp.getvalue()
|
||||
|
||||
entries = []
|
||||
for type, size in sizes.items():
|
||||
stream = size_streams[size]
|
||||
entries.append((type, HEADERSIZE + len(stream), stream))
|
||||
|
||||
# Header
|
||||
fp.write(MAGIC)
|
||||
file_length = HEADERSIZE # Header
|
||||
file_length += HEADERSIZE + 8 * len(entries) # TOC
|
||||
file_length += sum(entry[1] for entry in entries)
|
||||
fp.write(struct.pack(">i", file_length))
|
||||
|
||||
# TOC
|
||||
fp.write(b"TOC ")
|
||||
fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE))
|
||||
for entry in entries:
|
||||
fp.write(entry[0])
|
||||
fp.write(struct.pack(">i", entry[1]))
|
||||
|
||||
# Data
|
||||
for entry in entries:
|
||||
fp.write(entry[0])
|
||||
fp.write(struct.pack(">i", entry[1]))
|
||||
fp.write(entry[2])
|
||||
|
||||
if hasattr(fp, "flush"):
|
||||
fp.flush()
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(MAGIC)
|
||||
|
||||
|
||||
Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept)
|
||||
Image.register_extension(IcnsImageFile.format, ".icns")
|
||||
|
||||
Image.register_save(IcnsImageFile.format, _save)
|
||||
Image.register_mime(IcnsImageFile.format, "image/icns")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Syntax: python3 IcnsImagePlugin.py [file]")
|
||||
sys.exit()
|
||||
|
||||
with open(sys.argv[1], "rb") as fp:
|
||||
imf = IcnsImageFile(fp)
|
||||
for size in imf.info["sizes"]:
|
||||
width, height, scale = imf.size = size
|
||||
imf.save(f"out-{width}-{height}-{scale}.png")
|
||||
with Image.open(sys.argv[1]) as im:
|
||||
im.save("out.png")
|
||||
if sys.platform == "windows":
|
||||
os.startfile("out.png")
|
||||
@ -0,0 +1,381 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# Windows Icon support for PIL
|
||||
#
|
||||
# History:
|
||||
# 96-05-27 fl Created
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
# This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
|
||||
# <casadebender@gmail.com>.
|
||||
# https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
|
||||
#
|
||||
# Icon format references:
|
||||
# * https://en.wikipedia.org/wiki/ICO_(file_format)
|
||||
# * https://msdn.microsoft.com/en-us/library/ms997538.aspx
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from io import BytesIO
|
||||
from math import ceil, log
|
||||
from typing import IO, NamedTuple
|
||||
|
||||
from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._binary import o16le as o16
|
||||
from ._binary import o32le as o32
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
_MAGIC = b"\0\0\1\0"
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
fp.write(_MAGIC) # (2+2)
|
||||
bmp = im.encoderinfo.get("bitmap_format") == "bmp"
|
||||
sizes = im.encoderinfo.get(
|
||||
"sizes",
|
||||
[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
||||
)
|
||||
frames = []
|
||||
provided_ims = [im] + im.encoderinfo.get("append_images", [])
|
||||
width, height = im.size
|
||||
for size in sorted(set(sizes)):
|
||||
if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
|
||||
continue
|
||||
|
||||
for provided_im in provided_ims:
|
||||
if provided_im.size != size:
|
||||
continue
|
||||
frames.append(provided_im)
|
||||
if bmp:
|
||||
bits = BmpImagePlugin.SAVE[provided_im.mode][1]
|
||||
bits_used = [bits]
|
||||
for other_im in provided_ims:
|
||||
if other_im.size != size:
|
||||
continue
|
||||
bits = BmpImagePlugin.SAVE[other_im.mode][1]
|
||||
if bits not in bits_used:
|
||||
# Another image has been supplied for this size
|
||||
# with a different bit depth
|
||||
frames.append(other_im)
|
||||
bits_used.append(bits)
|
||||
break
|
||||
else:
|
||||
# TODO: invent a more convenient method for proportional scalings
|
||||
frame = provided_im.copy()
|
||||
frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
|
||||
frames.append(frame)
|
||||
fp.write(o16(len(frames))) # idCount(2)
|
||||
offset = fp.tell() + len(frames) * 16
|
||||
for frame in frames:
|
||||
width, height = frame.size
|
||||
# 0 means 256
|
||||
fp.write(o8(width if width < 256 else 0)) # bWidth(1)
|
||||
fp.write(o8(height if height < 256 else 0)) # bHeight(1)
|
||||
|
||||
bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0)
|
||||
fp.write(o8(colors)) # bColorCount(1)
|
||||
fp.write(b"\0") # bReserved(1)
|
||||
fp.write(b"\0\0") # wPlanes(2)
|
||||
fp.write(o16(bits)) # wBitCount(2)
|
||||
|
||||
image_io = BytesIO()
|
||||
if bmp:
|
||||
frame.save(image_io, "dib")
|
||||
|
||||
if bits != 32:
|
||||
and_mask = Image.new("1", size)
|
||||
ImageFile._save(
|
||||
and_mask,
|
||||
image_io,
|
||||
[ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
|
||||
)
|
||||
else:
|
||||
frame.save(image_io, "png")
|
||||
image_io.seek(0)
|
||||
image_bytes = image_io.read()
|
||||
if bmp:
|
||||
image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
|
||||
bytes_len = len(image_bytes)
|
||||
fp.write(o32(bytes_len)) # dwBytesInRes(4)
|
||||
fp.write(o32(offset)) # dwImageOffset(4)
|
||||
current = fp.tell()
|
||||
fp.seek(offset)
|
||||
fp.write(image_bytes)
|
||||
offset = offset + bytes_len
|
||||
fp.seek(current)
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(_MAGIC)
|
||||
|
||||
|
||||
class IconHeader(NamedTuple):
|
||||
width: int
|
||||
height: int
|
||||
nb_color: int
|
||||
reserved: int
|
||||
planes: int
|
||||
bpp: int
|
||||
size: int
|
||||
offset: int
|
||||
dim: tuple[int, int]
|
||||
square: int
|
||||
color_depth: int
|
||||
|
||||
|
||||
class IcoFile:
|
||||
def __init__(self, buf: IO[bytes]) -> None:
|
||||
"""
|
||||
Parse image from file-like object containing ico file data
|
||||
"""
|
||||
|
||||
# check magic
|
||||
s = buf.read(6)
|
||||
if not _accept(s):
|
||||
msg = "not an ICO file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self.buf = buf
|
||||
self.entry = []
|
||||
|
||||
# Number of items in file
|
||||
self.nb_items = i16(s, 4)
|
||||
|
||||
# Get headers for each item
|
||||
for i in range(self.nb_items):
|
||||
s = buf.read(16)
|
||||
|
||||
# See Wikipedia
|
||||
width = s[0] or 256
|
||||
height = s[1] or 256
|
||||
|
||||
# No. of colors in image (0 if >=8bpp)
|
||||
nb_color = s[2]
|
||||
bpp = i16(s, 6)
|
||||
icon_header = IconHeader(
|
||||
width=width,
|
||||
height=height,
|
||||
nb_color=nb_color,
|
||||
reserved=s[3],
|
||||
planes=i16(s, 4),
|
||||
bpp=i16(s, 6),
|
||||
size=i32(s, 8),
|
||||
offset=i32(s, 12),
|
||||
dim=(width, height),
|
||||
square=width * height,
|
||||
# See Wikipedia notes about color depth.
|
||||
# We need this just to differ images with equal sizes
|
||||
color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256,
|
||||
)
|
||||
|
||||
self.entry.append(icon_header)
|
||||
|
||||
self.entry = sorted(self.entry, key=lambda x: x.color_depth)
|
||||
# ICO images are usually squares
|
||||
self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
|
||||
|
||||
def sizes(self) -> set[tuple[int, int]]:
|
||||
"""
|
||||
Get a set of all available icon sizes and color depths.
|
||||
"""
|
||||
return {(h.width, h.height) for h in self.entry}
|
||||
|
||||
def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int:
|
||||
for i, h in enumerate(self.entry):
|
||||
if size == h.dim and (bpp is False or bpp == h.color_depth):
|
||||
return i
|
||||
return 0
|
||||
|
||||
def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image:
|
||||
"""
|
||||
Get an image from the icon
|
||||
"""
|
||||
return self.frame(self.getentryindex(size, bpp))
|
||||
|
||||
def frame(self, idx: int) -> Image.Image:
|
||||
"""
|
||||
Get an image from frame idx
|
||||
"""
|
||||
|
||||
header = self.entry[idx]
|
||||
|
||||
self.buf.seek(header.offset)
|
||||
data = self.buf.read(8)
|
||||
self.buf.seek(header.offset)
|
||||
|
||||
im: Image.Image
|
||||
if data[:8] == PngImagePlugin._MAGIC:
|
||||
# png frame
|
||||
im = PngImagePlugin.PngImageFile(self.buf)
|
||||
Image._decompression_bomb_check(im.size)
|
||||
else:
|
||||
# XOR + AND mask bmp frame
|
||||
im = BmpImagePlugin.DibImageFile(self.buf)
|
||||
Image._decompression_bomb_check(im.size)
|
||||
|
||||
# change tile dimension to only encompass XOR image
|
||||
im._size = (im.size[0], int(im.size[1] / 2))
|
||||
d, e, o, a = im.tile[0]
|
||||
im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a)
|
||||
|
||||
# figure out where AND mask image starts
|
||||
if header.bpp == 32:
|
||||
# 32-bit color depth icon image allows semitransparent areas
|
||||
# PIL's DIB format ignores transparency bits, recover them.
|
||||
# The DIB is packed in BGRX byte order where X is the alpha
|
||||
# channel.
|
||||
|
||||
# Back up to start of bmp data
|
||||
self.buf.seek(o)
|
||||
# extract every 4th byte (eg. 3,7,11,15,...)
|
||||
alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4]
|
||||
|
||||
# convert to an 8bpp grayscale image
|
||||
try:
|
||||
mask = Image.frombuffer(
|
||||
"L", # 8bpp
|
||||
im.size, # (w, h)
|
||||
alpha_bytes, # source chars
|
||||
"raw", # raw decoder
|
||||
("L", 0, -1), # 8bpp inverted, unpadded, reversed
|
||||
)
|
||||
except ValueError:
|
||||
if ImageFile.LOAD_TRUNCATED_IMAGES:
|
||||
mask = None
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# get AND image from end of bitmap
|
||||
w = im.size[0]
|
||||
if (w % 32) > 0:
|
||||
# bitmap row data is aligned to word boundaries
|
||||
w += 32 - (im.size[0] % 32)
|
||||
|
||||
# the total mask data is
|
||||
# padded row size * height / bits per char
|
||||
|
||||
total_bytes = int((w * im.size[1]) / 8)
|
||||
and_mask_offset = header.offset + header.size - total_bytes
|
||||
|
||||
self.buf.seek(and_mask_offset)
|
||||
mask_data = self.buf.read(total_bytes)
|
||||
|
||||
# convert raw data to image
|
||||
try:
|
||||
mask = Image.frombuffer(
|
||||
"1", # 1 bpp
|
||||
im.size, # (w, h)
|
||||
mask_data, # source chars
|
||||
"raw", # raw decoder
|
||||
("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed
|
||||
)
|
||||
except ValueError:
|
||||
if ImageFile.LOAD_TRUNCATED_IMAGES:
|
||||
mask = None
|
||||
else:
|
||||
raise
|
||||
|
||||
# now we have two images, im is XOR image and mask is AND image
|
||||
|
||||
# apply mask image as alpha channel
|
||||
if mask:
|
||||
im = im.convert("RGBA")
|
||||
im.putalpha(mask)
|
||||
|
||||
return im
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for Windows Icon files.
|
||||
|
||||
|
||||
class IcoImageFile(ImageFile.ImageFile):
|
||||
"""
|
||||
PIL read-only image support for Microsoft Windows .ico files.
|
||||
|
||||
By default the largest resolution image in the file will be loaded. This
|
||||
can be changed by altering the 'size' attribute before calling 'load'.
|
||||
|
||||
The info dictionary has a key 'sizes' that is a list of the sizes available
|
||||
in the icon file.
|
||||
|
||||
Handles classic, XP and Vista icon formats.
|
||||
|
||||
When saving, PNG compression is used. Support for this was only added in
|
||||
Windows Vista. If you are unable to view the icon in Windows, convert the
|
||||
image to "RGBA" mode before saving.
|
||||
|
||||
This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
|
||||
<casadebender@gmail.com>.
|
||||
https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
|
||||
"""
|
||||
|
||||
format = "ICO"
|
||||
format_description = "Windows Icon"
|
||||
|
||||
def _open(self) -> None:
|
||||
self.ico = IcoFile(self.fp)
|
||||
self.info["sizes"] = self.ico.sizes()
|
||||
self.size = self.ico.entry[0].dim
|
||||
self.load()
|
||||
|
||||
@property
|
||||
def size(self) -> tuple[int, int]:
|
||||
return self._size
|
||||
|
||||
@size.setter
|
||||
def size(self, value: tuple[int, int]) -> None:
|
||||
if value not in self.info["sizes"]:
|
||||
msg = "This is not one of the allowed sizes of this image"
|
||||
raise ValueError(msg)
|
||||
self._size = value
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if self._im is not None and self.im.size == self.size:
|
||||
# Already loaded
|
||||
return Image.Image.load(self)
|
||||
im = self.ico.getimage(self.size)
|
||||
# if tile is PNG, it won't really be loaded yet
|
||||
im.load()
|
||||
self.im = im.im
|
||||
self._mode = im.mode
|
||||
if im.palette:
|
||||
self.palette = im.palette
|
||||
if im.size != self.size:
|
||||
warnings.warn("Image was not the expected size")
|
||||
|
||||
index = self.ico.getentryindex(self.size)
|
||||
sizes = list(self.info["sizes"])
|
||||
sizes[index] = im.size
|
||||
self.info["sizes"] = set(sizes)
|
||||
|
||||
self.size = im.size
|
||||
return Image.Image.load(self)
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
# Flag the ImageFile.Parser so that it
|
||||
# just does all the decode at the end.
|
||||
pass
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
Image.register_open(IcoImageFile.format, IcoImageFile, _accept)
|
||||
Image.register_save(IcoImageFile.format, _save)
|
||||
Image.register_extension(IcoImageFile.format, ".ico")
|
||||
|
||||
Image.register_mime(IcoImageFile.format, "image/x-icon")
|
||||
@ -0,0 +1,389 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# IFUNC IM file handling for PIL
|
||||
#
|
||||
# history:
|
||||
# 1995-09-01 fl Created.
|
||||
# 1997-01-03 fl Save palette images
|
||||
# 1997-01-08 fl Added sequence support
|
||||
# 1997-01-23 fl Added P and RGB save support
|
||||
# 1997-05-31 fl Read floating point images
|
||||
# 1997-06-22 fl Save floating point images
|
||||
# 1997-08-27 fl Read and save 1-bit images
|
||||
# 1998-06-25 fl Added support for RGB+LUT images
|
||||
# 1998-07-02 fl Added support for YCC images
|
||||
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
||||
# 1998-12-29 fl Added I;16 support
|
||||
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.7)
|
||||
# 2003-09-26 fl Added LA/PA support
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||
# Copyright (c) 1995-2001 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import IO, Any
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._util import DeferredError
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Standard tags
|
||||
|
||||
COMMENT = "Comment"
|
||||
DATE = "Date"
|
||||
EQUIPMENT = "Digitalization equipment"
|
||||
FRAMES = "File size (no of images)"
|
||||
LUT = "Lut"
|
||||
NAME = "Name"
|
||||
SCALE = "Scale (x,y)"
|
||||
SIZE = "Image size (x*y)"
|
||||
MODE = "Image type"
|
||||
|
||||
TAGS = {
|
||||
COMMENT: 0,
|
||||
DATE: 0,
|
||||
EQUIPMENT: 0,
|
||||
FRAMES: 0,
|
||||
LUT: 0,
|
||||
NAME: 0,
|
||||
SCALE: 0,
|
||||
SIZE: 0,
|
||||
MODE: 0,
|
||||
}
|
||||
|
||||
OPEN = {
|
||||
# ifunc93/p3cfunc formats
|
||||
"0 1 image": ("1", "1"),
|
||||
"L 1 image": ("1", "1"),
|
||||
"Greyscale image": ("L", "L"),
|
||||
"Grayscale image": ("L", "L"),
|
||||
"RGB image": ("RGB", "RGB;L"),
|
||||
"RLB image": ("RGB", "RLB"),
|
||||
"RYB image": ("RGB", "RLB"),
|
||||
"B1 image": ("1", "1"),
|
||||
"B2 image": ("P", "P;2"),
|
||||
"B4 image": ("P", "P;4"),
|
||||
"X 24 image": ("RGB", "RGB"),
|
||||
"L 32 S image": ("I", "I;32"),
|
||||
"L 32 F image": ("F", "F;32"),
|
||||
# old p3cfunc formats
|
||||
"RGB3 image": ("RGB", "RGB;T"),
|
||||
"RYB3 image": ("RGB", "RYB;T"),
|
||||
# extensions
|
||||
"LA image": ("LA", "LA;L"),
|
||||
"PA image": ("LA", "PA;L"),
|
||||
"RGBA image": ("RGBA", "RGBA;L"),
|
||||
"RGBX image": ("RGB", "RGBX;L"),
|
||||
"CMYK image": ("CMYK", "CMYK;L"),
|
||||
"YCC image": ("YCbCr", "YCbCr;L"),
|
||||
}
|
||||
|
||||
# ifunc95 extensions
|
||||
for i in ["8", "8S", "16", "16S", "32", "32F"]:
|
||||
OPEN[f"L {i} image"] = ("F", f"F;{i}")
|
||||
OPEN[f"L*{i} image"] = ("F", f"F;{i}")
|
||||
for i in ["16", "16L", "16B"]:
|
||||
OPEN[f"L {i} image"] = (f"I;{i}", f"I;{i}")
|
||||
OPEN[f"L*{i} image"] = (f"I;{i}", f"I;{i}")
|
||||
for i in ["32S"]:
|
||||
OPEN[f"L {i} image"] = ("I", f"I;{i}")
|
||||
OPEN[f"L*{i} image"] = ("I", f"I;{i}")
|
||||
for j in range(2, 33):
|
||||
OPEN[f"L*{j} image"] = ("F", f"F;{j}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Read IM directory
|
||||
|
||||
split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$")
|
||||
|
||||
|
||||
def number(s: Any) -> float:
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return float(s)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the IFUNC IM file format.
|
||||
|
||||
|
||||
class ImImageFile(ImageFile.ImageFile):
|
||||
format = "IM"
|
||||
format_description = "IFUNC Image Memory"
|
||||
_close_exclusive_fp_after_loading = False
|
||||
|
||||
def _open(self) -> None:
|
||||
# Quick rejection: if there's not an LF among the first
|
||||
# 100 bytes, this is (probably) not a text header.
|
||||
|
||||
if b"\n" not in self.fp.read(100):
|
||||
msg = "not an IM file"
|
||||
raise SyntaxError(msg)
|
||||
self.fp.seek(0)
|
||||
|
||||
n = 0
|
||||
|
||||
# Default values
|
||||
self.info[MODE] = "L"
|
||||
self.info[SIZE] = (512, 512)
|
||||
self.info[FRAMES] = 1
|
||||
|
||||
self.rawmode = "L"
|
||||
|
||||
while True:
|
||||
s = self.fp.read(1)
|
||||
|
||||
# Some versions of IFUNC uses \n\r instead of \r\n...
|
||||
if s == b"\r":
|
||||
continue
|
||||
|
||||
if not s or s == b"\0" or s == b"\x1a":
|
||||
break
|
||||
|
||||
# FIXME: this may read whole file if not a text file
|
||||
s = s + self.fp.readline()
|
||||
|
||||
if len(s) > 100:
|
||||
msg = "not an IM file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if s.endswith(b"\r\n"):
|
||||
s = s[:-2]
|
||||
elif s.endswith(b"\n"):
|
||||
s = s[:-1]
|
||||
|
||||
try:
|
||||
m = split.match(s)
|
||||
except re.error as e:
|
||||
msg = "not an IM file"
|
||||
raise SyntaxError(msg) from e
|
||||
|
||||
if m:
|
||||
k, v = m.group(1, 2)
|
||||
|
||||
# Don't know if this is the correct encoding,
|
||||
# but a decent guess (I guess)
|
||||
k = k.decode("latin-1", "replace")
|
||||
v = v.decode("latin-1", "replace")
|
||||
|
||||
# Convert value as appropriate
|
||||
if k in [FRAMES, SCALE, SIZE]:
|
||||
v = v.replace("*", ",")
|
||||
v = tuple(map(number, v.split(",")))
|
||||
if len(v) == 1:
|
||||
v = v[0]
|
||||
elif k == MODE and v in OPEN:
|
||||
v, self.rawmode = OPEN[v]
|
||||
|
||||
# Add to dictionary. Note that COMMENT tags are
|
||||
# combined into a list of strings.
|
||||
if k == COMMENT:
|
||||
if k in self.info:
|
||||
self.info[k].append(v)
|
||||
else:
|
||||
self.info[k] = [v]
|
||||
else:
|
||||
self.info[k] = v
|
||||
|
||||
if k in TAGS:
|
||||
n += 1
|
||||
|
||||
else:
|
||||
msg = f"Syntax error in IM header: {s.decode('ascii', 'replace')}"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if not n:
|
||||
msg = "Not an IM file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# Basic attributes
|
||||
self._size = self.info[SIZE]
|
||||
self._mode = self.info[MODE]
|
||||
|
||||
# Skip forward to start of image data
|
||||
while s and not s.startswith(b"\x1a"):
|
||||
s = self.fp.read(1)
|
||||
if not s:
|
||||
msg = "File truncated"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if LUT in self.info:
|
||||
# convert lookup table to palette or lut attribute
|
||||
palette = self.fp.read(768)
|
||||
greyscale = 1 # greyscale palette
|
||||
linear = 1 # linear greyscale palette
|
||||
for i in range(256):
|
||||
if palette[i] == palette[i + 256] == palette[i + 512]:
|
||||
if palette[i] != i:
|
||||
linear = 0
|
||||
else:
|
||||
greyscale = 0
|
||||
if self.mode in ["L", "LA", "P", "PA"]:
|
||||
if greyscale:
|
||||
if not linear:
|
||||
self.lut = list(palette[:256])
|
||||
else:
|
||||
if self.mode in ["L", "P"]:
|
||||
self._mode = self.rawmode = "P"
|
||||
elif self.mode in ["LA", "PA"]:
|
||||
self._mode = "PA"
|
||||
self.rawmode = "PA;L"
|
||||
self.palette = ImagePalette.raw("RGB;L", palette)
|
||||
elif self.mode == "RGB":
|
||||
if not greyscale or not linear:
|
||||
self.lut = list(palette)
|
||||
|
||||
self.frame = 0
|
||||
|
||||
self.__offset = offs = self.fp.tell()
|
||||
|
||||
self._fp = self.fp # FIXME: hack
|
||||
|
||||
if self.rawmode.startswith("F;"):
|
||||
# ifunc95 formats
|
||||
try:
|
||||
# use bit decoder (if necessary)
|
||||
bits = int(self.rawmode[2:])
|
||||
if bits not in [8, 16, 32]:
|
||||
self.tile = [
|
||||
ImageFile._Tile(
|
||||
"bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1)
|
||||
)
|
||||
]
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if self.rawmode in ["RGB;T", "RYB;T"]:
|
||||
# Old LabEye/3PC files. Would be very surprised if anyone
|
||||
# ever stumbled upon such a file ;-)
|
||||
size = self.size[0] * self.size[1]
|
||||
self.tile = [
|
||||
ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)),
|
||||
ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)),
|
||||
ImageFile._Tile(
|
||||
"raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)
|
||||
),
|
||||
]
|
||||
else:
|
||||
# LabEye/IFUNC files
|
||||
self.tile = [
|
||||
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
|
||||
]
|
||||
|
||||
@property
|
||||
def n_frames(self) -> int:
|
||||
return self.info[FRAMES]
|
||||
|
||||
@property
|
||||
def is_animated(self) -> bool:
|
||||
return self.info[FRAMES] > 1
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
if isinstance(self._fp, DeferredError):
|
||||
raise self._fp.ex
|
||||
|
||||
self.frame = frame
|
||||
|
||||
if self.mode == "1":
|
||||
bits = 1
|
||||
else:
|
||||
bits = 8 * len(self.mode)
|
||||
|
||||
size = ((self.size[0] * bits + 7) // 8) * self.size[1]
|
||||
offs = self.__offset + frame * size
|
||||
|
||||
self.fp = self._fp
|
||||
|
||||
self.tile = [
|
||||
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
|
||||
]
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.frame
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Save IM files
|
||||
|
||||
|
||||
SAVE = {
|
||||
# mode: (im type, raw mode)
|
||||
"1": ("0 1", "1"),
|
||||
"L": ("Greyscale", "L"),
|
||||
"LA": ("LA", "LA;L"),
|
||||
"P": ("Greyscale", "P"),
|
||||
"PA": ("LA", "PA;L"),
|
||||
"I": ("L 32S", "I;32S"),
|
||||
"I;16": ("L 16", "I;16"),
|
||||
"I;16L": ("L 16L", "I;16L"),
|
||||
"I;16B": ("L 16B", "I;16B"),
|
||||
"F": ("L 32F", "F;32F"),
|
||||
"RGB": ("RGB", "RGB;L"),
|
||||
"RGBA": ("RGBA", "RGBA;L"),
|
||||
"RGBX": ("RGBX", "RGBX;L"),
|
||||
"CMYK": ("CMYK", "CMYK;L"),
|
||||
"YCbCr": ("YCC", "YCbCr;L"),
|
||||
}
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
try:
|
||||
image_type, rawmode = SAVE[im.mode]
|
||||
except KeyError as e:
|
||||
msg = f"Cannot save {im.mode} images as IM"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
frames = im.encoderinfo.get("frames", 1)
|
||||
|
||||
fp.write(f"Image type: {image_type} image\r\n".encode("ascii"))
|
||||
if filename:
|
||||
# Each line must be 100 characters or less,
|
||||
# or: SyntaxError("not an IM file")
|
||||
# 8 characters are used for "Name: " and "\r\n"
|
||||
# Keep just the filename, ditch the potentially overlong path
|
||||
if isinstance(filename, bytes):
|
||||
filename = filename.decode("ascii")
|
||||
name, ext = os.path.splitext(os.path.basename(filename))
|
||||
name = "".join([name[: 92 - len(ext)], ext])
|
||||
|
||||
fp.write(f"Name: {name}\r\n".encode("ascii"))
|
||||
fp.write(f"Image size (x*y): {im.size[0]}*{im.size[1]}\r\n".encode("ascii"))
|
||||
fp.write(f"File size (no of images): {frames}\r\n".encode("ascii"))
|
||||
if im.mode in ["P", "PA"]:
|
||||
fp.write(b"Lut: 1\r\n")
|
||||
fp.write(b"\000" * (511 - fp.tell()) + b"\032")
|
||||
if im.mode in ["P", "PA"]:
|
||||
im_palette = im.im.getpalette("RGB", "RGB;L")
|
||||
colors = len(im_palette) // 3
|
||||
palette = b""
|
||||
for i in range(3):
|
||||
palette += im_palette[colors * i : colors * (i + 1)]
|
||||
palette += b"\x00" * (256 - colors)
|
||||
fp.write(palette) # 768 bytes
|
||||
ImageFile._save(
|
||||
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
|
||||
Image.register_open(ImImageFile.format, ImImageFile)
|
||||
Image.register_save(ImImageFile.format, _save)
|
||||
|
||||
Image.register_extension(ImImageFile.format, ".im")
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,311 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# standard channel operations
|
||||
#
|
||||
# History:
|
||||
# 1996-03-24 fl Created
|
||||
# 1996-08-13 fl Added logical operations (for "1" images)
|
||||
# 2000-10-12 fl Added offset method (from Image.py)
|
||||
#
|
||||
# Copyright (c) 1997-2000 by Secret Labs AB
|
||||
# Copyright (c) 1996-2000 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image
|
||||
|
||||
|
||||
def constant(image: Image.Image, value: int) -> Image.Image:
|
||||
"""Fill a channel with a given gray level.
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
return Image.new("L", image.size, value)
|
||||
|
||||
|
||||
def duplicate(image: Image.Image) -> Image.Image:
|
||||
"""Copy a channel. Alias for :py:meth:`PIL.Image.Image.copy`.
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
return image.copy()
|
||||
|
||||
|
||||
def invert(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Invert an image (channel). ::
|
||||
|
||||
out = MAX - image
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image.load()
|
||||
return image._new(image.im.chop_invert())
|
||||
|
||||
|
||||
def lighter(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Compares the two images, pixel by pixel, and returns a new image containing
|
||||
the lighter values. ::
|
||||
|
||||
out = max(image1, image2)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_lighter(image2.im))
|
||||
|
||||
|
||||
def darker(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Compares the two images, pixel by pixel, and returns a new image containing
|
||||
the darker values. ::
|
||||
|
||||
out = min(image1, image2)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_darker(image2.im))
|
||||
|
||||
|
||||
def difference(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Returns the absolute value of the pixel-by-pixel difference between the two
|
||||
images. ::
|
||||
|
||||
out = abs(image1 - image2)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_difference(image2.im))
|
||||
|
||||
|
||||
def multiply(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Superimposes two images on top of each other.
|
||||
|
||||
If you multiply an image with a solid black image, the result is black. If
|
||||
you multiply with a solid white image, the image is unaffected. ::
|
||||
|
||||
out = image1 * image2 / MAX
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_multiply(image2.im))
|
||||
|
||||
|
||||
def screen(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Superimposes two inverted images on top of each other. ::
|
||||
|
||||
out = MAX - ((MAX - image1) * (MAX - image2) / MAX)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_screen(image2.im))
|
||||
|
||||
|
||||
def soft_light(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Superimposes two images on top of each other using the Soft Light algorithm
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_soft_light(image2.im))
|
||||
|
||||
|
||||
def hard_light(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Superimposes two images on top of each other using the Hard Light algorithm
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_hard_light(image2.im))
|
||||
|
||||
|
||||
def overlay(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Superimposes two images on top of each other using the Overlay algorithm
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_overlay(image2.im))
|
||||
|
||||
|
||||
def add(
|
||||
image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Adds two images, dividing the result by scale and adding the
|
||||
offset. If omitted, scale defaults to 1.0, and offset to 0.0. ::
|
||||
|
||||
out = ((image1 + image2) / scale + offset)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_add(image2.im, scale, offset))
|
||||
|
||||
|
||||
def subtract(
|
||||
image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Subtracts two images, dividing the result by scale and adding the offset.
|
||||
If omitted, scale defaults to 1.0, and offset to 0.0. ::
|
||||
|
||||
out = ((image1 - image2) / scale + offset)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_subtract(image2.im, scale, offset))
|
||||
|
||||
|
||||
def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""Add two images, without clipping the result. ::
|
||||
|
||||
out = ((image1 + image2) % MAX)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_add_modulo(image2.im))
|
||||
|
||||
|
||||
def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""Subtract two images, without clipping the result. ::
|
||||
|
||||
out = ((image1 - image2) % MAX)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_subtract_modulo(image2.im))
|
||||
|
||||
|
||||
def logical_and(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""Logical AND between two images.
|
||||
|
||||
Both of the images must have mode "1". If you would like to perform a
|
||||
logical AND on an image with a mode other than "1", try
|
||||
:py:meth:`~PIL.ImageChops.multiply` instead, using a black-and-white mask
|
||||
as the second image. ::
|
||||
|
||||
out = ((image1 and image2) % MAX)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_and(image2.im))
|
||||
|
||||
|
||||
def logical_or(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""Logical OR between two images.
|
||||
|
||||
Both of the images must have mode "1". ::
|
||||
|
||||
out = ((image1 or image2) % MAX)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_or(image2.im))
|
||||
|
||||
|
||||
def logical_xor(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
||||
"""Logical XOR between two images.
|
||||
|
||||
Both of the images must have mode "1". ::
|
||||
|
||||
out = ((bool(image1) != bool(image2)) % MAX)
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
image1.load()
|
||||
image2.load()
|
||||
return image1._new(image1.im.chop_xor(image2.im))
|
||||
|
||||
|
||||
def blend(image1: Image.Image, image2: Image.Image, alpha: float) -> Image.Image:
|
||||
"""Blend images using constant transparency weight. Alias for
|
||||
:py:func:`PIL.Image.blend`.
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
return Image.blend(image1, image2, alpha)
|
||||
|
||||
|
||||
def composite(
|
||||
image1: Image.Image, image2: Image.Image, mask: Image.Image
|
||||
) -> Image.Image:
|
||||
"""Create composite using transparency mask. Alias for
|
||||
:py:func:`PIL.Image.composite`.
|
||||
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
return Image.composite(image1, image2, mask)
|
||||
|
||||
|
||||
def offset(image: Image.Image, xoffset: int, yoffset: int | None = None) -> Image.Image:
|
||||
"""Returns a copy of the image where data has been offset by the given
|
||||
distances. Data wraps around the edges. If ``yoffset`` is omitted, it
|
||||
is assumed to be equal to ``xoffset``.
|
||||
|
||||
:param image: Input image.
|
||||
:param xoffset: The horizontal distance.
|
||||
:param yoffset: The vertical distance. If omitted, both
|
||||
distances are set to the same value.
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
|
||||
if yoffset is None:
|
||||
yoffset = xoffset
|
||||
image.load()
|
||||
return image._new(image.im.offset(xoffset, yoffset))
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,320 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# map CSS3-style colour description strings to RGB
|
||||
#
|
||||
# History:
|
||||
# 2002-10-24 fl Added support for CSS-style color strings
|
||||
# 2002-12-15 fl Added RGBA support
|
||||
# 2004-03-27 fl Fixed remaining int() problems for Python 1.5.2
|
||||
# 2004-07-19 fl Fixed gray/grey spelling issues
|
||||
# 2009-03-05 fl Fixed rounding error in grayscale calculation
|
||||
#
|
||||
# Copyright (c) 2002-2004 by Secret Labs AB
|
||||
# Copyright (c) 2002-2004 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from . import Image
|
||||
|
||||
|
||||
@lru_cache
|
||||
def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]:
|
||||
"""
|
||||
Convert a color string to an RGB or RGBA tuple. If the string cannot be
|
||||
parsed, this function raises a :py:exc:`ValueError` exception.
|
||||
|
||||
.. versionadded:: 1.1.4
|
||||
|
||||
:param color: A color string
|
||||
:return: ``(red, green, blue[, alpha])``
|
||||
"""
|
||||
if len(color) > 100:
|
||||
msg = "color specifier is too long"
|
||||
raise ValueError(msg)
|
||||
color = color.lower()
|
||||
|
||||
rgb = colormap.get(color, None)
|
||||
if rgb:
|
||||
if isinstance(rgb, tuple):
|
||||
return rgb
|
||||
rgb_tuple = getrgb(rgb)
|
||||
assert len(rgb_tuple) == 3
|
||||
colormap[color] = rgb_tuple
|
||||
return rgb_tuple
|
||||
|
||||
# check for known string formats
|
||||
if re.match("#[a-f0-9]{3}$", color):
|
||||
return int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16)
|
||||
|
||||
if re.match("#[a-f0-9]{4}$", color):
|
||||
return (
|
||||
int(color[1] * 2, 16),
|
||||
int(color[2] * 2, 16),
|
||||
int(color[3] * 2, 16),
|
||||
int(color[4] * 2, 16),
|
||||
)
|
||||
|
||||
if re.match("#[a-f0-9]{6}$", color):
|
||||
return int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)
|
||||
|
||||
if re.match("#[a-f0-9]{8}$", color):
|
||||
return (
|
||||
int(color[1:3], 16),
|
||||
int(color[3:5], 16),
|
||||
int(color[5:7], 16),
|
||||
int(color[7:9], 16),
|
||||
)
|
||||
|
||||
m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color)
|
||||
if m:
|
||||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
|
||||
m = re.match(r"rgb\(\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$", color)
|
||||
if m:
|
||||
return (
|
||||
int((int(m.group(1)) * 255) / 100.0 + 0.5),
|
||||
int((int(m.group(2)) * 255) / 100.0 + 0.5),
|
||||
int((int(m.group(3)) * 255) / 100.0 + 0.5),
|
||||
)
|
||||
|
||||
m = re.match(
|
||||
r"hsl\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color
|
||||
)
|
||||
if m:
|
||||
from colorsys import hls_to_rgb
|
||||
|
||||
rgb_floats = hls_to_rgb(
|
||||
float(m.group(1)) / 360.0,
|
||||
float(m.group(3)) / 100.0,
|
||||
float(m.group(2)) / 100.0,
|
||||
)
|
||||
return (
|
||||
int(rgb_floats[0] * 255 + 0.5),
|
||||
int(rgb_floats[1] * 255 + 0.5),
|
||||
int(rgb_floats[2] * 255 + 0.5),
|
||||
)
|
||||
|
||||
m = re.match(
|
||||
r"hs[bv]\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color
|
||||
)
|
||||
if m:
|
||||
from colorsys import hsv_to_rgb
|
||||
|
||||
rgb_floats = hsv_to_rgb(
|
||||
float(m.group(1)) / 360.0,
|
||||
float(m.group(2)) / 100.0,
|
||||
float(m.group(3)) / 100.0,
|
||||
)
|
||||
return (
|
||||
int(rgb_floats[0] * 255 + 0.5),
|
||||
int(rgb_floats[1] * 255 + 0.5),
|
||||
int(rgb_floats[2] * 255 + 0.5),
|
||||
)
|
||||
|
||||
m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color)
|
||||
if m:
|
||||
return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
|
||||
msg = f"unknown color specifier: {repr(color)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def getcolor(color: str, mode: str) -> int | tuple[int, ...]:
|
||||
"""
|
||||
Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if
|
||||
``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is
|
||||
not color or a palette image, converts the RGB value to a grayscale value.
|
||||
If the string cannot be parsed, this function raises a :py:exc:`ValueError`
|
||||
exception.
|
||||
|
||||
.. versionadded:: 1.1.4
|
||||
|
||||
:param color: A color string
|
||||
:param mode: Convert result to this mode
|
||||
:return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])``
|
||||
"""
|
||||
# same as getrgb, but converts the result to the given mode
|
||||
rgb, alpha = getrgb(color), 255
|
||||
if len(rgb) == 4:
|
||||
alpha = rgb[3]
|
||||
rgb = rgb[:3]
|
||||
|
||||
if mode == "HSV":
|
||||
from colorsys import rgb_to_hsv
|
||||
|
||||
r, g, b = rgb
|
||||
h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255)
|
||||
return int(h * 255), int(s * 255), int(v * 255)
|
||||
elif Image.getmodebase(mode) == "L":
|
||||
r, g, b = rgb
|
||||
# ITU-R Recommendation 601-2 for nonlinear RGB
|
||||
# scaled to 24 bits to match the convert's implementation.
|
||||
graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16
|
||||
if mode[-1] == "A":
|
||||
return graylevel, alpha
|
||||
return graylevel
|
||||
elif mode[-1] == "A":
|
||||
return rgb + (alpha,)
|
||||
return rgb
|
||||
|
||||
|
||||
colormap: dict[str, str | tuple[int, int, int]] = {
|
||||
# X11 colour table from https://drafts.csswg.org/css-color-4/, with
|
||||
# gray/grey spelling issues fixed. This is a superset of HTML 4.0
|
||||
# colour names used in CSS 1.
|
||||
"aliceblue": "#f0f8ff",
|
||||
"antiquewhite": "#faebd7",
|
||||
"aqua": "#00ffff",
|
||||
"aquamarine": "#7fffd4",
|
||||
"azure": "#f0ffff",
|
||||
"beige": "#f5f5dc",
|
||||
"bisque": "#ffe4c4",
|
||||
"black": "#000000",
|
||||
"blanchedalmond": "#ffebcd",
|
||||
"blue": "#0000ff",
|
||||
"blueviolet": "#8a2be2",
|
||||
"brown": "#a52a2a",
|
||||
"burlywood": "#deb887",
|
||||
"cadetblue": "#5f9ea0",
|
||||
"chartreuse": "#7fff00",
|
||||
"chocolate": "#d2691e",
|
||||
"coral": "#ff7f50",
|
||||
"cornflowerblue": "#6495ed",
|
||||
"cornsilk": "#fff8dc",
|
||||
"crimson": "#dc143c",
|
||||
"cyan": "#00ffff",
|
||||
"darkblue": "#00008b",
|
||||
"darkcyan": "#008b8b",
|
||||
"darkgoldenrod": "#b8860b",
|
||||
"darkgray": "#a9a9a9",
|
||||
"darkgrey": "#a9a9a9",
|
||||
"darkgreen": "#006400",
|
||||
"darkkhaki": "#bdb76b",
|
||||
"darkmagenta": "#8b008b",
|
||||
"darkolivegreen": "#556b2f",
|
||||
"darkorange": "#ff8c00",
|
||||
"darkorchid": "#9932cc",
|
||||
"darkred": "#8b0000",
|
||||
"darksalmon": "#e9967a",
|
||||
"darkseagreen": "#8fbc8f",
|
||||
"darkslateblue": "#483d8b",
|
||||
"darkslategray": "#2f4f4f",
|
||||
"darkslategrey": "#2f4f4f",
|
||||
"darkturquoise": "#00ced1",
|
||||
"darkviolet": "#9400d3",
|
||||
"deeppink": "#ff1493",
|
||||
"deepskyblue": "#00bfff",
|
||||
"dimgray": "#696969",
|
||||
"dimgrey": "#696969",
|
||||
"dodgerblue": "#1e90ff",
|
||||
"firebrick": "#b22222",
|
||||
"floralwhite": "#fffaf0",
|
||||
"forestgreen": "#228b22",
|
||||
"fuchsia": "#ff00ff",
|
||||
"gainsboro": "#dcdcdc",
|
||||
"ghostwhite": "#f8f8ff",
|
||||
"gold": "#ffd700",
|
||||
"goldenrod": "#daa520",
|
||||
"gray": "#808080",
|
||||
"grey": "#808080",
|
||||
"green": "#008000",
|
||||
"greenyellow": "#adff2f",
|
||||
"honeydew": "#f0fff0",
|
||||
"hotpink": "#ff69b4",
|
||||
"indianred": "#cd5c5c",
|
||||
"indigo": "#4b0082",
|
||||
"ivory": "#fffff0",
|
||||
"khaki": "#f0e68c",
|
||||
"lavender": "#e6e6fa",
|
||||
"lavenderblush": "#fff0f5",
|
||||
"lawngreen": "#7cfc00",
|
||||
"lemonchiffon": "#fffacd",
|
||||
"lightblue": "#add8e6",
|
||||
"lightcoral": "#f08080",
|
||||
"lightcyan": "#e0ffff",
|
||||
"lightgoldenrodyellow": "#fafad2",
|
||||
"lightgreen": "#90ee90",
|
||||
"lightgray": "#d3d3d3",
|
||||
"lightgrey": "#d3d3d3",
|
||||
"lightpink": "#ffb6c1",
|
||||
"lightsalmon": "#ffa07a",
|
||||
"lightseagreen": "#20b2aa",
|
||||
"lightskyblue": "#87cefa",
|
||||
"lightslategray": "#778899",
|
||||
"lightslategrey": "#778899",
|
||||
"lightsteelblue": "#b0c4de",
|
||||
"lightyellow": "#ffffe0",
|
||||
"lime": "#00ff00",
|
||||
"limegreen": "#32cd32",
|
||||
"linen": "#faf0e6",
|
||||
"magenta": "#ff00ff",
|
||||
"maroon": "#800000",
|
||||
"mediumaquamarine": "#66cdaa",
|
||||
"mediumblue": "#0000cd",
|
||||
"mediumorchid": "#ba55d3",
|
||||
"mediumpurple": "#9370db",
|
||||
"mediumseagreen": "#3cb371",
|
||||
"mediumslateblue": "#7b68ee",
|
||||
"mediumspringgreen": "#00fa9a",
|
||||
"mediumturquoise": "#48d1cc",
|
||||
"mediumvioletred": "#c71585",
|
||||
"midnightblue": "#191970",
|
||||
"mintcream": "#f5fffa",
|
||||
"mistyrose": "#ffe4e1",
|
||||
"moccasin": "#ffe4b5",
|
||||
"navajowhite": "#ffdead",
|
||||
"navy": "#000080",
|
||||
"oldlace": "#fdf5e6",
|
||||
"olive": "#808000",
|
||||
"olivedrab": "#6b8e23",
|
||||
"orange": "#ffa500",
|
||||
"orangered": "#ff4500",
|
||||
"orchid": "#da70d6",
|
||||
"palegoldenrod": "#eee8aa",
|
||||
"palegreen": "#98fb98",
|
||||
"paleturquoise": "#afeeee",
|
||||
"palevioletred": "#db7093",
|
||||
"papayawhip": "#ffefd5",
|
||||
"peachpuff": "#ffdab9",
|
||||
"peru": "#cd853f",
|
||||
"pink": "#ffc0cb",
|
||||
"plum": "#dda0dd",
|
||||
"powderblue": "#b0e0e6",
|
||||
"purple": "#800080",
|
||||
"rebeccapurple": "#663399",
|
||||
"red": "#ff0000",
|
||||
"rosybrown": "#bc8f8f",
|
||||
"royalblue": "#4169e1",
|
||||
"saddlebrown": "#8b4513",
|
||||
"salmon": "#fa8072",
|
||||
"sandybrown": "#f4a460",
|
||||
"seagreen": "#2e8b57",
|
||||
"seashell": "#fff5ee",
|
||||
"sienna": "#a0522d",
|
||||
"silver": "#c0c0c0",
|
||||
"skyblue": "#87ceeb",
|
||||
"slateblue": "#6a5acd",
|
||||
"slategray": "#708090",
|
||||
"slategrey": "#708090",
|
||||
"snow": "#fffafa",
|
||||
"springgreen": "#00ff7f",
|
||||
"steelblue": "#4682b4",
|
||||
"tan": "#d2b48c",
|
||||
"teal": "#008080",
|
||||
"thistle": "#d8bfd8",
|
||||
"tomato": "#ff6347",
|
||||
"turquoise": "#40e0d0",
|
||||
"violet": "#ee82ee",
|
||||
"wheat": "#f5deb3",
|
||||
"white": "#ffffff",
|
||||
"whitesmoke": "#f5f5f5",
|
||||
"yellow": "#ffff00",
|
||||
"yellowgreen": "#9acd32",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,243 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# WCK-style drawing interface operations
|
||||
#
|
||||
# History:
|
||||
# 2003-12-07 fl created
|
||||
# 2005-05-15 fl updated; added to PIL as ImageDraw2
|
||||
# 2005-05-15 fl added text support
|
||||
# 2005-05-20 fl added arc/chord/pieslice support
|
||||
#
|
||||
# Copyright (c) 2003-2005 by Secret Labs AB
|
||||
# Copyright (c) 2003-2005 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
|
||||
"""
|
||||
(Experimental) WCK-style drawing interface operations
|
||||
|
||||
.. seealso:: :py:mod:`PIL.ImageDraw`
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, AnyStr, BinaryIO
|
||||
|
||||
from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath
|
||||
from ._typing import Coords, StrOrBytesPath
|
||||
|
||||
|
||||
class Pen:
|
||||
"""Stores an outline color and width."""
|
||||
|
||||
def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None:
|
||||
self.color = ImageColor.getrgb(color)
|
||||
self.width = width
|
||||
|
||||
|
||||
class Brush:
|
||||
"""Stores a fill color"""
|
||||
|
||||
def __init__(self, color: str, opacity: int = 255) -> None:
|
||||
self.color = ImageColor.getrgb(color)
|
||||
|
||||
|
||||
class Font:
|
||||
"""Stores a TrueType font and color"""
|
||||
|
||||
def __init__(
|
||||
self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12
|
||||
) -> None:
|
||||
# FIXME: add support for bitmap fonts
|
||||
self.color = ImageColor.getrgb(color)
|
||||
self.font = ImageFont.truetype(file, size)
|
||||
|
||||
|
||||
class Draw:
|
||||
"""
|
||||
(Experimental) WCK-style drawing interface
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image: Image.Image | str,
|
||||
size: tuple[int, int] | list[int] | None = None,
|
||||
color: float | tuple[float, ...] | str | None = None,
|
||||
) -> None:
|
||||
if isinstance(image, str):
|
||||
if size is None:
|
||||
msg = "If image argument is mode string, size must be a list or tuple"
|
||||
raise ValueError(msg)
|
||||
image = Image.new(image, size, color)
|
||||
self.draw = ImageDraw.Draw(image)
|
||||
self.image = image
|
||||
self.transform: tuple[float, float, float, float, float, float] | None = None
|
||||
|
||||
def flush(self) -> Image.Image:
|
||||
return self.image
|
||||
|
||||
def render(
|
||||
self,
|
||||
op: str,
|
||||
xy: Coords,
|
||||
pen: Pen | Brush | None,
|
||||
brush: Brush | Pen | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
# handle color arguments
|
||||
outline = fill = None
|
||||
width = 1
|
||||
if isinstance(pen, Pen):
|
||||
outline = pen.color
|
||||
width = pen.width
|
||||
elif isinstance(brush, Pen):
|
||||
outline = brush.color
|
||||
width = brush.width
|
||||
if isinstance(brush, Brush):
|
||||
fill = brush.color
|
||||
elif isinstance(pen, Brush):
|
||||
fill = pen.color
|
||||
# handle transformation
|
||||
if self.transform:
|
||||
path = ImagePath.Path(xy)
|
||||
path.transform(self.transform)
|
||||
xy = path
|
||||
# render the item
|
||||
if op in ("arc", "line"):
|
||||
kwargs.setdefault("fill", outline)
|
||||
else:
|
||||
kwargs.setdefault("fill", fill)
|
||||
kwargs.setdefault("outline", outline)
|
||||
if op == "line":
|
||||
kwargs.setdefault("width", width)
|
||||
getattr(self.draw, op)(xy, **kwargs)
|
||||
|
||||
def settransform(self, offset: tuple[float, float]) -> None:
|
||||
"""Sets a transformation offset."""
|
||||
(xoffset, yoffset) = offset
|
||||
self.transform = (1, 0, xoffset, 0, 1, yoffset)
|
||||
|
||||
def arc(
|
||||
self,
|
||||
xy: Coords,
|
||||
pen: Pen | Brush | None,
|
||||
start: float,
|
||||
end: float,
|
||||
*options: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Draws an arc (a portion of a circle outline) between the start and end
|
||||
angles, inside the given bounding box.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.arc`
|
||||
"""
|
||||
self.render("arc", xy, pen, *options, start=start, end=end)
|
||||
|
||||
def chord(
|
||||
self,
|
||||
xy: Coords,
|
||||
pen: Pen | Brush | None,
|
||||
start: float,
|
||||
end: float,
|
||||
*options: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Same as :py:meth:`~PIL.ImageDraw2.Draw.arc`, but connects the end points
|
||||
with a straight line.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.chord`
|
||||
"""
|
||||
self.render("chord", xy, pen, *options, start=start, end=end)
|
||||
|
||||
def ellipse(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
||||
"""
|
||||
Draws an ellipse inside the given bounding box.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.ellipse`
|
||||
"""
|
||||
self.render("ellipse", xy, pen, *options)
|
||||
|
||||
def line(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
||||
"""
|
||||
Draws a line between the coordinates in the ``xy`` list.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.line`
|
||||
"""
|
||||
self.render("line", xy, pen, *options)
|
||||
|
||||
def pieslice(
|
||||
self,
|
||||
xy: Coords,
|
||||
pen: Pen | Brush | None,
|
||||
start: float,
|
||||
end: float,
|
||||
*options: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Same as arc, but also draws straight lines between the end points and the
|
||||
center of the bounding box.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.pieslice`
|
||||
"""
|
||||
self.render("pieslice", xy, pen, *options, start=start, end=end)
|
||||
|
||||
def polygon(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
||||
"""
|
||||
Draws a polygon.
|
||||
|
||||
The polygon outline consists of straight lines between the given
|
||||
coordinates, plus a straight line between the last and the first
|
||||
coordinate.
|
||||
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.polygon`
|
||||
"""
|
||||
self.render("polygon", xy, pen, *options)
|
||||
|
||||
def rectangle(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
||||
"""
|
||||
Draws a rectangle.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.rectangle`
|
||||
"""
|
||||
self.render("rectangle", xy, pen, *options)
|
||||
|
||||
def text(self, xy: tuple[float, float], text: AnyStr, font: Font) -> None:
|
||||
"""
|
||||
Draws the string at the given position.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.text`
|
||||
"""
|
||||
if self.transform:
|
||||
path = ImagePath.Path(xy)
|
||||
path.transform(self.transform)
|
||||
xy = path
|
||||
self.draw.text(xy, text, font=font.font, fill=font.color)
|
||||
|
||||
def textbbox(
|
||||
self, xy: tuple[float, float], text: AnyStr, font: Font
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""
|
||||
Returns bounding box (in pixels) of given text.
|
||||
|
||||
:return: ``(left, top, right, bottom)`` bounding box
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox`
|
||||
"""
|
||||
if self.transform:
|
||||
path = ImagePath.Path(xy)
|
||||
path.transform(self.transform)
|
||||
xy = path
|
||||
return self.draw.textbbox(xy, text, font=font.font)
|
||||
|
||||
def textlength(self, text: AnyStr, font: Font) -> float:
|
||||
"""
|
||||
Returns length (in pixels) of given text.
|
||||
This is the amount by which following text should be offset.
|
||||
|
||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textlength`
|
||||
"""
|
||||
return self.draw.textlength(text, font=font.font)
|
||||
@ -0,0 +1,113 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# image enhancement classes
|
||||
#
|
||||
# For a background, see "Image Processing By Interpolation and
|
||||
# Extrapolation", Paul Haeberli and Douglas Voorhies. Available
|
||||
# at http://www.graficaobscura.com/interp/index.html
|
||||
#
|
||||
# History:
|
||||
# 1996-03-23 fl Created
|
||||
# 2009-06-16 fl Fixed mean calculation
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image, ImageFilter, ImageStat
|
||||
|
||||
|
||||
class _Enhance:
|
||||
image: Image.Image
|
||||
degenerate: Image.Image
|
||||
|
||||
def enhance(self, factor: float) -> Image.Image:
|
||||
"""
|
||||
Returns an enhanced image.
|
||||
|
||||
:param factor: A floating point value controlling the enhancement.
|
||||
Factor 1.0 always returns a copy of the original image,
|
||||
lower factors mean less color (brightness, contrast,
|
||||
etc), and higher values more. There are no restrictions
|
||||
on this value.
|
||||
:rtype: :py:class:`~PIL.Image.Image`
|
||||
"""
|
||||
return Image.blend(self.degenerate, self.image, factor)
|
||||
|
||||
|
||||
class Color(_Enhance):
|
||||
"""Adjust image color balance.
|
||||
|
||||
This class can be used to adjust the colour balance of an image, in
|
||||
a manner similar to the controls on a colour TV set. An enhancement
|
||||
factor of 0.0 gives a black and white image. A factor of 1.0 gives
|
||||
the original image.
|
||||
"""
|
||||
|
||||
def __init__(self, image: Image.Image) -> None:
|
||||
self.image = image
|
||||
self.intermediate_mode = "L"
|
||||
if "A" in image.getbands():
|
||||
self.intermediate_mode = "LA"
|
||||
|
||||
if self.intermediate_mode != image.mode:
|
||||
image = image.convert(self.intermediate_mode).convert(image.mode)
|
||||
self.degenerate = image
|
||||
|
||||
|
||||
class Contrast(_Enhance):
|
||||
"""Adjust image contrast.
|
||||
|
||||
This class can be used to control the contrast of an image, similar
|
||||
to the contrast control on a TV set. An enhancement factor of 0.0
|
||||
gives a solid gray image. A factor of 1.0 gives the original image.
|
||||
"""
|
||||
|
||||
def __init__(self, image: Image.Image) -> None:
|
||||
self.image = image
|
||||
if image.mode != "L":
|
||||
image = image.convert("L")
|
||||
mean = int(ImageStat.Stat(image).mean[0] + 0.5)
|
||||
self.degenerate = Image.new("L", image.size, mean)
|
||||
if self.degenerate.mode != self.image.mode:
|
||||
self.degenerate = self.degenerate.convert(self.image.mode)
|
||||
|
||||
if "A" in self.image.getbands():
|
||||
self.degenerate.putalpha(self.image.getchannel("A"))
|
||||
|
||||
|
||||
class Brightness(_Enhance):
|
||||
"""Adjust image brightness.
|
||||
|
||||
This class can be used to control the brightness of an image. An
|
||||
enhancement factor of 0.0 gives a black image. A factor of 1.0 gives the
|
||||
original image.
|
||||
"""
|
||||
|
||||
def __init__(self, image: Image.Image) -> None:
|
||||
self.image = image
|
||||
self.degenerate = Image.new(image.mode, image.size, 0)
|
||||
|
||||
if "A" in image.getbands():
|
||||
self.degenerate.putalpha(image.getchannel("A"))
|
||||
|
||||
|
||||
class Sharpness(_Enhance):
|
||||
"""Adjust image sharpness.
|
||||
|
||||
This class can be used to adjust the sharpness of an image. An
|
||||
enhancement factor of 0.0 gives a blurred image, a factor of 1.0 gives the
|
||||
original image, and a factor of 2.0 gives a sharpened image.
|
||||
"""
|
||||
|
||||
def __init__(self, image: Image.Image) -> None:
|
||||
self.image = image
|
||||
self.degenerate = image.filter(ImageFilter.SMOOTH)
|
||||
|
||||
if "A" in image.getbands():
|
||||
self.degenerate.putalpha(image.getchannel("A"))
|
||||
@ -0,0 +1,926 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# base class for image file handlers
|
||||
#
|
||||
# history:
|
||||
# 1995-09-09 fl Created
|
||||
# 1996-03-11 fl Fixed load mechanism.
|
||||
# 1996-04-15 fl Added pcx/xbm decoders.
|
||||
# 1996-04-30 fl Added encoders.
|
||||
# 1996-12-14 fl Added load helpers
|
||||
# 1997-01-11 fl Use encode_to_file where possible
|
||||
# 1997-08-27 fl Flush output in _save
|
||||
# 1998-03-05 fl Use memory mapping for some modes
|
||||
# 1999-02-04 fl Use memory mapping also for "I;16" and "I;16B"
|
||||
# 1999-05-31 fl Added image parser
|
||||
# 2000-10-12 fl Set readonly flag on memory-mapped images
|
||||
# 2002-03-20 fl Use better messages for common decoder errors
|
||||
# 2003-04-21 fl Fall back on mmap/map_buffer if map is not available
|
||||
# 2003-10-30 fl Added StubImageFile class
|
||||
# 2004-02-25 fl Made incremental parser more robust
|
||||
#
|
||||
# Copyright (c) 1997-2004 by Secret Labs AB
|
||||
# Copyright (c) 1995-2004 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import io
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
from typing import IO, Any, NamedTuple, cast
|
||||
|
||||
from . import ExifTags, Image
|
||||
from ._util import DeferredError, is_path
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from ._typing import StrOrBytesPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAXBLOCK = 65536
|
||||
"""
|
||||
By default, Pillow processes image data in blocks. This helps to prevent excessive use
|
||||
of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
|
||||
|
||||
When reading an image, this is the number of bytes to read at once.
|
||||
|
||||
When writing an image, this is the number of bytes to write at once.
|
||||
If the image width times 4 is greater, then that will be used instead.
|
||||
Plugins may also set a greater number.
|
||||
|
||||
User code may set this to another number.
|
||||
"""
|
||||
|
||||
SAFEBLOCK = 1024 * 1024
|
||||
|
||||
LOAD_TRUNCATED_IMAGES = False
|
||||
"""Whether or not to load truncated image files. User code may change this."""
|
||||
|
||||
ERRORS = {
|
||||
-1: "image buffer overrun error",
|
||||
-2: "decoding error",
|
||||
-3: "unknown error",
|
||||
-8: "bad configuration",
|
||||
-9: "out of memory error",
|
||||
}
|
||||
"""
|
||||
Dict of known error codes returned from :meth:`.PyDecoder.decode`,
|
||||
:meth:`.PyEncoder.encode` :meth:`.PyEncoder.encode_to_pyfd` and
|
||||
:meth:`.PyEncoder.encode_to_file`.
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
|
||||
def _get_oserror(error: int, *, encoder: bool) -> OSError:
|
||||
try:
|
||||
msg = Image.core.getcodecstatus(error)
|
||||
except AttributeError:
|
||||
msg = ERRORS.get(error)
|
||||
if not msg:
|
||||
msg = f"{'encoder' if encoder else 'decoder'} error {error}"
|
||||
msg += f" when {'writing' if encoder else 'reading'} image file"
|
||||
return OSError(msg)
|
||||
|
||||
|
||||
def _tilesort(t: _Tile) -> int:
|
||||
# sort on offset
|
||||
return t[2]
|
||||
|
||||
|
||||
class _Tile(NamedTuple):
|
||||
codec_name: str
|
||||
extents: tuple[int, int, int, int] | None
|
||||
offset: int = 0
|
||||
args: tuple[Any, ...] | str | None = None
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# ImageFile base class
|
||||
|
||||
|
||||
class ImageFile(Image.Image):
|
||||
"""Base class for image file format handlers."""
|
||||
|
||||
def __init__(
|
||||
self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._min_frame = 0
|
||||
|
||||
self.custom_mimetype: str | None = None
|
||||
|
||||
self.tile: list[_Tile] = []
|
||||
""" A list of tile descriptors """
|
||||
|
||||
self.readonly = 1 # until we know better
|
||||
|
||||
self.decoderconfig: tuple[Any, ...] = ()
|
||||
self.decodermaxblock = MAXBLOCK
|
||||
|
||||
if is_path(fp):
|
||||
# filename
|
||||
self.fp = open(fp, "rb")
|
||||
self.filename = os.fspath(fp)
|
||||
self._exclusive_fp = True
|
||||
else:
|
||||
# stream
|
||||
self.fp = cast(IO[bytes], fp)
|
||||
self.filename = filename if filename is not None else ""
|
||||
# can be overridden
|
||||
self._exclusive_fp = False
|
||||
|
||||
try:
|
||||
try:
|
||||
self._open()
|
||||
except (
|
||||
IndexError, # end of data
|
||||
TypeError, # end of data (ord)
|
||||
KeyError, # unsupported mode
|
||||
EOFError, # got header but not the first frame
|
||||
struct.error,
|
||||
) as v:
|
||||
raise SyntaxError(v) from v
|
||||
|
||||
if not self.mode or self.size[0] <= 0 or self.size[1] <= 0:
|
||||
msg = "not identified by this driver"
|
||||
raise SyntaxError(msg)
|
||||
except BaseException:
|
||||
# close the file only if we have opened it this constructor
|
||||
if self._exclusive_fp:
|
||||
self.fp.close()
|
||||
raise
|
||||
|
||||
def _open(self) -> None:
|
||||
pass
|
||||
|
||||
def _close_fp(self):
|
||||
if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError):
|
||||
if self._fp != self.fp:
|
||||
self._fp.close()
|
||||
self._fp = DeferredError(ValueError("Operation on closed image"))
|
||||
if self.fp:
|
||||
self.fp.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Closes the file pointer, if possible.
|
||||
|
||||
This operation will destroy the image core and release its memory.
|
||||
The image data will be unusable afterward.
|
||||
|
||||
This function is required to close images that have multiple frames or
|
||||
have not had their file read and closed by the
|
||||
:py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for
|
||||
more information.
|
||||
"""
|
||||
try:
|
||||
self._close_fp()
|
||||
self.fp = None
|
||||
except Exception as msg:
|
||||
logger.debug("Error closing: %s", msg)
|
||||
|
||||
super().close()
|
||||
|
||||
def get_child_images(self) -> list[ImageFile]:
|
||||
child_images = []
|
||||
exif = self.getexif()
|
||||
ifds = []
|
||||
if ExifTags.Base.SubIFDs in exif:
|
||||
subifd_offsets = exif[ExifTags.Base.SubIFDs]
|
||||
if subifd_offsets:
|
||||
if not isinstance(subifd_offsets, tuple):
|
||||
subifd_offsets = (subifd_offsets,)
|
||||
for subifd_offset in subifd_offsets:
|
||||
ifds.append((exif._get_ifd_dict(subifd_offset), subifd_offset))
|
||||
ifd1 = exif.get_ifd(ExifTags.IFD.IFD1)
|
||||
if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset):
|
||||
assert exif._info is not None
|
||||
ifds.append((ifd1, exif._info.next))
|
||||
|
||||
offset = None
|
||||
for ifd, ifd_offset in ifds:
|
||||
assert self.fp is not None
|
||||
current_offset = self.fp.tell()
|
||||
if offset is None:
|
||||
offset = current_offset
|
||||
|
||||
fp = self.fp
|
||||
if ifd is not None:
|
||||
thumbnail_offset = ifd.get(ExifTags.Base.JpegIFOffset)
|
||||
if thumbnail_offset is not None:
|
||||
thumbnail_offset += getattr(self, "_exif_offset", 0)
|
||||
self.fp.seek(thumbnail_offset)
|
||||
|
||||
length = ifd.get(ExifTags.Base.JpegIFByteCount)
|
||||
assert isinstance(length, int)
|
||||
data = self.fp.read(length)
|
||||
fp = io.BytesIO(data)
|
||||
|
||||
with Image.open(fp) as im:
|
||||
from . import TiffImagePlugin
|
||||
|
||||
if thumbnail_offset is None and isinstance(
|
||||
im, TiffImagePlugin.TiffImageFile
|
||||
):
|
||||
im._frame_pos = [ifd_offset]
|
||||
im._seek(0)
|
||||
im.load()
|
||||
child_images.append(im)
|
||||
|
||||
if offset is not None:
|
||||
assert self.fp is not None
|
||||
self.fp.seek(offset)
|
||||
return child_images
|
||||
|
||||
def get_format_mimetype(self) -> str | None:
|
||||
if self.custom_mimetype:
|
||||
return self.custom_mimetype
|
||||
if self.format is not None:
|
||||
return Image.MIME.get(self.format.upper())
|
||||
return None
|
||||
|
||||
def __getstate__(self) -> list[Any]:
|
||||
return super().__getstate__() + [self.filename]
|
||||
|
||||
def __setstate__(self, state: list[Any]) -> None:
|
||||
self.tile = []
|
||||
if len(state) > 5:
|
||||
self.filename = state[5]
|
||||
super().__setstate__(state)
|
||||
|
||||
def verify(self) -> None:
|
||||
"""Check file integrity"""
|
||||
|
||||
# raise exception if something's wrong. must be called
|
||||
# directly after open, and closes file when finished.
|
||||
if self._exclusive_fp:
|
||||
self.fp.close()
|
||||
self.fp = None
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
"""Load image data based on tile list"""
|
||||
|
||||
if not self.tile and self._im is None:
|
||||
msg = "cannot load this image"
|
||||
raise OSError(msg)
|
||||
|
||||
pixel = Image.Image.load(self)
|
||||
if not self.tile:
|
||||
return pixel
|
||||
|
||||
self.map: mmap.mmap | None = None
|
||||
use_mmap = self.filename and len(self.tile) == 1
|
||||
|
||||
readonly = 0
|
||||
|
||||
# look for read/seek overrides
|
||||
if hasattr(self, "load_read"):
|
||||
read = self.load_read
|
||||
# don't use mmap if there are custom read/seek functions
|
||||
use_mmap = False
|
||||
else:
|
||||
read = self.fp.read
|
||||
|
||||
if hasattr(self, "load_seek"):
|
||||
seek = self.load_seek
|
||||
use_mmap = False
|
||||
else:
|
||||
seek = self.fp.seek
|
||||
|
||||
if use_mmap:
|
||||
# try memory mapping
|
||||
decoder_name, extents, offset, args = self.tile[0]
|
||||
if isinstance(args, str):
|
||||
args = (args, 0, 1)
|
||||
if (
|
||||
decoder_name == "raw"
|
||||
and isinstance(args, tuple)
|
||||
and len(args) >= 3
|
||||
and args[0] == self.mode
|
||||
and args[0] in Image._MAPMODES
|
||||
):
|
||||
if offset < 0:
|
||||
msg = "Tile offset cannot be negative"
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
# use mmap, if possible
|
||||
import mmap
|
||||
|
||||
with open(self.filename) as fp:
|
||||
self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
if offset + self.size[1] * args[1] > self.map.size():
|
||||
msg = "buffer is not large enough"
|
||||
raise OSError(msg)
|
||||
self.im = Image.core.map_buffer(
|
||||
self.map, self.size, decoder_name, offset, args
|
||||
)
|
||||
readonly = 1
|
||||
# After trashing self.im,
|
||||
# we might need to reload the palette data.
|
||||
if self.palette:
|
||||
self.palette.dirty = 1
|
||||
except (AttributeError, OSError, ImportError):
|
||||
self.map = None
|
||||
|
||||
self.load_prepare()
|
||||
err_code = -3 # initialize to unknown error
|
||||
if not self.map:
|
||||
# sort tiles in file order
|
||||
self.tile.sort(key=_tilesort)
|
||||
|
||||
# FIXME: This is a hack to handle TIFF's JpegTables tag.
|
||||
prefix = getattr(self, "tile_prefix", b"")
|
||||
|
||||
# Remove consecutive duplicates that only differ by their offset
|
||||
self.tile = [
|
||||
list(tiles)[-1]
|
||||
for _, tiles in itertools.groupby(
|
||||
self.tile, lambda tile: (tile[0], tile[1], tile[3])
|
||||
)
|
||||
]
|
||||
for i, (decoder_name, extents, offset, args) in enumerate(self.tile):
|
||||
seek(offset)
|
||||
decoder = Image._getdecoder(
|
||||
self.mode, decoder_name, args, self.decoderconfig
|
||||
)
|
||||
try:
|
||||
decoder.setimage(self.im, extents)
|
||||
if decoder.pulls_fd:
|
||||
decoder.setfd(self.fp)
|
||||
err_code = decoder.decode(b"")[1]
|
||||
else:
|
||||
b = prefix
|
||||
while True:
|
||||
read_bytes = self.decodermaxblock
|
||||
if i + 1 < len(self.tile):
|
||||
next_offset = self.tile[i + 1].offset
|
||||
if next_offset > offset:
|
||||
read_bytes = next_offset - offset
|
||||
try:
|
||||
s = read(read_bytes)
|
||||
except (IndexError, struct.error) as e:
|
||||
# truncated png/gif
|
||||
if LOAD_TRUNCATED_IMAGES:
|
||||
break
|
||||
else:
|
||||
msg = "image file is truncated"
|
||||
raise OSError(msg) from e
|
||||
|
||||
if not s: # truncated jpeg
|
||||
if LOAD_TRUNCATED_IMAGES:
|
||||
break
|
||||
else:
|
||||
msg = (
|
||||
"image file is truncated "
|
||||
f"({len(b)} bytes not processed)"
|
||||
)
|
||||
raise OSError(msg)
|
||||
|
||||
b = b + s
|
||||
n, err_code = decoder.decode(b)
|
||||
if n < 0:
|
||||
break
|
||||
b = b[n:]
|
||||
finally:
|
||||
# Need to cleanup here to prevent leaks
|
||||
decoder.cleanup()
|
||||
|
||||
self.tile = []
|
||||
self.readonly = readonly
|
||||
|
||||
self.load_end()
|
||||
|
||||
if self._exclusive_fp and self._close_exclusive_fp_after_loading:
|
||||
self.fp.close()
|
||||
self.fp = None
|
||||
|
||||
if not self.map and not LOAD_TRUNCATED_IMAGES and err_code < 0:
|
||||
# still raised if decoder fails to return anything
|
||||
raise _get_oserror(err_code, encoder=False)
|
||||
|
||||
return Image.Image.load(self)
|
||||
|
||||
def load_prepare(self) -> None:
|
||||
# create image memory if necessary
|
||||
if self._im is None:
|
||||
self.im = Image.core.new(self.mode, self.size)
|
||||
# create palette (optional)
|
||||
if self.mode == "P":
|
||||
Image.Image.load(self)
|
||||
|
||||
def load_end(self) -> None:
|
||||
# may be overridden
|
||||
pass
|
||||
|
||||
# may be defined for contained formats
|
||||
# def load_seek(self, pos: int) -> None:
|
||||
# pass
|
||||
|
||||
# may be defined for blocked formats (e.g. PNG)
|
||||
# def load_read(self, read_bytes: int) -> bytes:
|
||||
# pass
|
||||
|
||||
def _seek_check(self, frame: int) -> bool:
|
||||
if (
|
||||
frame < self._min_frame
|
||||
# Only check upper limit on frames if additional seek operations
|
||||
# are not required to do so
|
||||
or (
|
||||
not (hasattr(self, "_n_frames") and self._n_frames is None)
|
||||
and frame >= getattr(self, "n_frames") + self._min_frame
|
||||
)
|
||||
):
|
||||
msg = "attempt to seek outside sequence"
|
||||
raise EOFError(msg)
|
||||
|
||||
return self.tell() != frame
|
||||
|
||||
|
||||
class StubHandler(abc.ABC):
|
||||
def open(self, im: StubImageFile) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def load(self, im: StubImageFile) -> Image.Image:
|
||||
pass
|
||||
|
||||
|
||||
class StubImageFile(ImageFile, metaclass=abc.ABCMeta):
|
||||
"""
|
||||
Base class for stub image loaders.
|
||||
|
||||
A stub loader is an image loader that can identify files of a
|
||||
certain format, but relies on external code to load the file.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _open(self) -> None:
|
||||
pass
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
loader = self._load()
|
||||
if loader is None:
|
||||
msg = f"cannot find loader for this {self.format} file"
|
||||
raise OSError(msg)
|
||||
image = loader.load(self)
|
||||
assert image is not None
|
||||
# become the other object (!)
|
||||
self.__class__ = image.__class__ # type: ignore[assignment]
|
||||
self.__dict__ = image.__dict__
|
||||
return image.load()
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load(self) -> StubHandler | None:
|
||||
"""(Hook) Find actual image loader."""
|
||||
pass
|
||||
|
||||
|
||||
class Parser:
|
||||
"""
|
||||
Incremental image parser. This class implements the standard
|
||||
feed/close consumer interface.
|
||||
"""
|
||||
|
||||
incremental = None
|
||||
image: Image.Image | None = None
|
||||
data: bytes | None = None
|
||||
decoder: Image.core.ImagingDecoder | PyDecoder | None = None
|
||||
offset = 0
|
||||
finished = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
(Consumer) Reset the parser. Note that you can only call this
|
||||
method immediately after you've created a parser; parser
|
||||
instances cannot be reused.
|
||||
"""
|
||||
assert self.data is None, "cannot reuse parsers"
|
||||
|
||||
def feed(self, data: bytes) -> None:
|
||||
"""
|
||||
(Consumer) Feed data to the parser.
|
||||
|
||||
:param data: A string buffer.
|
||||
:exception OSError: If the parser failed to parse the image file.
|
||||
"""
|
||||
# collect data
|
||||
|
||||
if self.finished:
|
||||
return
|
||||
|
||||
if self.data is None:
|
||||
self.data = data
|
||||
else:
|
||||
self.data = self.data + data
|
||||
|
||||
# parse what we have
|
||||
if self.decoder:
|
||||
if self.offset > 0:
|
||||
# skip header
|
||||
skip = min(len(self.data), self.offset)
|
||||
self.data = self.data[skip:]
|
||||
self.offset = self.offset - skip
|
||||
if self.offset > 0 or not self.data:
|
||||
return
|
||||
|
||||
n, e = self.decoder.decode(self.data)
|
||||
|
||||
if n < 0:
|
||||
# end of stream
|
||||
self.data = None
|
||||
self.finished = 1
|
||||
if e < 0:
|
||||
# decoding error
|
||||
self.image = None
|
||||
raise _get_oserror(e, encoder=False)
|
||||
else:
|
||||
# end of image
|
||||
return
|
||||
self.data = self.data[n:]
|
||||
|
||||
elif self.image:
|
||||
# if we end up here with no decoder, this file cannot
|
||||
# be incrementally parsed. wait until we've gotten all
|
||||
# available data
|
||||
pass
|
||||
|
||||
else:
|
||||
# attempt to open this file
|
||||
try:
|
||||
with io.BytesIO(self.data) as fp:
|
||||
im = Image.open(fp)
|
||||
except OSError:
|
||||
pass # not enough data
|
||||
else:
|
||||
flag = hasattr(im, "load_seek") or hasattr(im, "load_read")
|
||||
if flag or len(im.tile) != 1:
|
||||
# custom load code, or multiple tiles
|
||||
self.decode = None
|
||||
else:
|
||||
# initialize decoder
|
||||
im.load_prepare()
|
||||
d, e, o, a = im.tile[0]
|
||||
im.tile = []
|
||||
self.decoder = Image._getdecoder(im.mode, d, a, im.decoderconfig)
|
||||
self.decoder.setimage(im.im, e)
|
||||
|
||||
# calculate decoder offset
|
||||
self.offset = o
|
||||
if self.offset <= len(self.data):
|
||||
self.data = self.data[self.offset :]
|
||||
self.offset = 0
|
||||
|
||||
self.image = im
|
||||
|
||||
def __enter__(self) -> Parser:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> Image.Image:
|
||||
"""
|
||||
(Consumer) Close the stream.
|
||||
|
||||
:returns: An image object.
|
||||
:exception OSError: If the parser failed to parse the image file either
|
||||
because it cannot be identified or cannot be
|
||||
decoded.
|
||||
"""
|
||||
# finish decoding
|
||||
if self.decoder:
|
||||
# get rid of what's left in the buffers
|
||||
self.feed(b"")
|
||||
self.data = self.decoder = None
|
||||
if not self.finished:
|
||||
msg = "image was incomplete"
|
||||
raise OSError(msg)
|
||||
if not self.image:
|
||||
msg = "cannot parse this image"
|
||||
raise OSError(msg)
|
||||
if self.data:
|
||||
# incremental parsing not possible; reopen the file
|
||||
# not that we have all data
|
||||
with io.BytesIO(self.data) as fp:
|
||||
try:
|
||||
self.image = Image.open(fp)
|
||||
finally:
|
||||
self.image.load()
|
||||
return self.image
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None:
|
||||
"""Helper to save image based on tile list
|
||||
|
||||
:param im: Image object.
|
||||
:param fp: File object.
|
||||
:param tile: Tile list.
|
||||
:param bufsize: Optional buffer size
|
||||
"""
|
||||
|
||||
im.load()
|
||||
if not hasattr(im, "encoderconfig"):
|
||||
im.encoderconfig = ()
|
||||
tile.sort(key=_tilesort)
|
||||
# FIXME: make MAXBLOCK a configuration parameter
|
||||
# It would be great if we could have the encoder specify what it needs
|
||||
# But, it would need at least the image size in most cases. RawEncode is
|
||||
# a tricky case.
|
||||
bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4) # see RawEncode.c
|
||||
try:
|
||||
fh = fp.fileno()
|
||||
fp.flush()
|
||||
_encode_tile(im, fp, tile, bufsize, fh)
|
||||
except (AttributeError, io.UnsupportedOperation) as exc:
|
||||
_encode_tile(im, fp, tile, bufsize, None, exc)
|
||||
if hasattr(fp, "flush"):
|
||||
fp.flush()
|
||||
|
||||
|
||||
def _encode_tile(
|
||||
im: Image.Image,
|
||||
fp: IO[bytes],
|
||||
tile: list[_Tile],
|
||||
bufsize: int,
|
||||
fh: int | None,
|
||||
exc: BaseException | None = None,
|
||||
) -> None:
|
||||
for encoder_name, extents, offset, args in tile:
|
||||
if offset > 0:
|
||||
fp.seek(offset)
|
||||
encoder = Image._getencoder(im.mode, encoder_name, args, im.encoderconfig)
|
||||
try:
|
||||
encoder.setimage(im.im, extents)
|
||||
if encoder.pushes_fd:
|
||||
encoder.setfd(fp)
|
||||
errcode = encoder.encode_to_pyfd()[1]
|
||||
else:
|
||||
if exc:
|
||||
# compress to Python file-compatible object
|
||||
while True:
|
||||
errcode, data = encoder.encode(bufsize)[1:]
|
||||
fp.write(data)
|
||||
if errcode:
|
||||
break
|
||||
else:
|
||||
# slight speedup: compress to real file object
|
||||
assert fh is not None
|
||||
errcode = encoder.encode_to_file(fh, bufsize)
|
||||
if errcode < 0:
|
||||
raise _get_oserror(errcode, encoder=True) from exc
|
||||
finally:
|
||||
encoder.cleanup()
|
||||
|
||||
|
||||
def _safe_read(fp: IO[bytes], size: int) -> bytes:
|
||||
"""
|
||||
Reads large blocks in a safe way. Unlike fp.read(n), this function
|
||||
doesn't trust the user. If the requested size is larger than
|
||||
SAFEBLOCK, the file is read block by block.
|
||||
|
||||
:param fp: File handle. Must implement a <b>read</b> method.
|
||||
:param size: Number of bytes to read.
|
||||
:returns: A string containing <i>size</i> bytes of data.
|
||||
|
||||
Raises an OSError if the file is truncated and the read cannot be completed
|
||||
|
||||
"""
|
||||
if size <= 0:
|
||||
return b""
|
||||
if size <= SAFEBLOCK:
|
||||
data = fp.read(size)
|
||||
if len(data) < size:
|
||||
msg = "Truncated File Read"
|
||||
raise OSError(msg)
|
||||
return data
|
||||
blocks: list[bytes] = []
|
||||
remaining_size = size
|
||||
while remaining_size > 0:
|
||||
block = fp.read(min(remaining_size, SAFEBLOCK))
|
||||
if not block:
|
||||
break
|
||||
blocks.append(block)
|
||||
remaining_size -= len(block)
|
||||
if sum(len(block) for block in blocks) < size:
|
||||
msg = "Truncated File Read"
|
||||
raise OSError(msg)
|
||||
return b"".join(blocks)
|
||||
|
||||
|
||||
class PyCodecState:
|
||||
def __init__(self) -> None:
|
||||
self.xsize = 0
|
||||
self.ysize = 0
|
||||
self.xoff = 0
|
||||
self.yoff = 0
|
||||
|
||||
def extents(self) -> tuple[int, int, int, int]:
|
||||
return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize
|
||||
|
||||
|
||||
class PyCodec:
|
||||
fd: IO[bytes] | None
|
||||
|
||||
def __init__(self, mode: str, *args: Any) -> None:
|
||||
self.im: Image.core.ImagingCore | None = None
|
||||
self.state = PyCodecState()
|
||||
self.fd = None
|
||||
self.mode = mode
|
||||
self.init(args)
|
||||
|
||||
def init(self, args: tuple[Any, ...]) -> None:
|
||||
"""
|
||||
Override to perform codec specific initialization
|
||||
|
||||
:param args: Tuple of arg items from the tile entry
|
||||
:returns: None
|
||||
"""
|
||||
self.args = args
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Override to perform codec specific cleanup
|
||||
|
||||
:returns: None
|
||||
"""
|
||||
pass
|
||||
|
||||
def setfd(self, fd: IO[bytes]) -> None:
|
||||
"""
|
||||
Called from ImageFile to set the Python file-like object
|
||||
|
||||
:param fd: A Python file-like object
|
||||
:returns: None
|
||||
"""
|
||||
self.fd = fd
|
||||
|
||||
def setimage(
|
||||
self,
|
||||
im: Image.core.ImagingCore,
|
||||
extents: tuple[int, int, int, int] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Called from ImageFile to set the core output image for the codec
|
||||
|
||||
:param im: A core image object
|
||||
:param extents: a 4 tuple of (x0, y0, x1, y1) defining the rectangle
|
||||
for this tile
|
||||
:returns: None
|
||||
"""
|
||||
|
||||
# following c code
|
||||
self.im = im
|
||||
|
||||
if extents:
|
||||
(x0, y0, x1, y1) = extents
|
||||
else:
|
||||
(x0, y0, x1, y1) = (0, 0, 0, 0)
|
||||
|
||||
if x0 == 0 and x1 == 0:
|
||||
self.state.xsize, self.state.ysize = self.im.size
|
||||
else:
|
||||
self.state.xoff = x0
|
||||
self.state.yoff = y0
|
||||
self.state.xsize = x1 - x0
|
||||
self.state.ysize = y1 - y0
|
||||
|
||||
if self.state.xsize <= 0 or self.state.ysize <= 0:
|
||||
msg = "Size cannot be negative"
|
||||
raise ValueError(msg)
|
||||
|
||||
if (
|
||||
self.state.xsize + self.state.xoff > self.im.size[0]
|
||||
or self.state.ysize + self.state.yoff > self.im.size[1]
|
||||
):
|
||||
msg = "Tile cannot extend outside image"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
class PyDecoder(PyCodec):
|
||||
"""
|
||||
Python implementation of a format decoder. Override this class and
|
||||
add the decoding logic in the :meth:`decode` method.
|
||||
|
||||
See :ref:`Writing Your Own File Codec in Python<file-codecs-py>`
|
||||
"""
|
||||
|
||||
_pulls_fd = False
|
||||
|
||||
@property
|
||||
def pulls_fd(self) -> bool:
|
||||
return self._pulls_fd
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
"""
|
||||
Override to perform the decoding process.
|
||||
|
||||
:param buffer: A bytes object with the data to be decoded.
|
||||
:returns: A tuple of ``(bytes consumed, errcode)``.
|
||||
If finished with decoding return -1 for the bytes consumed.
|
||||
Err codes are from :data:`.ImageFile.ERRORS`.
|
||||
"""
|
||||
msg = "unavailable in base decoder"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def set_as_raw(
|
||||
self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = ()
|
||||
) -> None:
|
||||
"""
|
||||
Convenience method to set the internal image from a stream of raw data
|
||||
|
||||
:param data: Bytes to be set
|
||||
:param rawmode: The rawmode to be used for the decoder.
|
||||
If not specified, it will default to the mode of the image
|
||||
:param extra: Extra arguments for the decoder.
|
||||
:returns: None
|
||||
"""
|
||||
|
||||
if not rawmode:
|
||||
rawmode = self.mode
|
||||
d = Image._getdecoder(self.mode, "raw", rawmode, extra)
|
||||
assert self.im is not None
|
||||
d.setimage(self.im, self.state.extents())
|
||||
s = d.decode(data)
|
||||
|
||||
if s[0] >= 0:
|
||||
msg = "not enough image data"
|
||||
raise ValueError(msg)
|
||||
if s[1] != 0:
|
||||
msg = "cannot decode image data"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
class PyEncoder(PyCodec):
|
||||
"""
|
||||
Python implementation of a format encoder. Override this class and
|
||||
add the decoding logic in the :meth:`encode` method.
|
||||
|
||||
See :ref:`Writing Your Own File Codec in Python<file-codecs-py>`
|
||||
"""
|
||||
|
||||
_pushes_fd = False
|
||||
|
||||
@property
|
||||
def pushes_fd(self) -> bool:
|
||||
return self._pushes_fd
|
||||
|
||||
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
|
||||
"""
|
||||
Override to perform the encoding process.
|
||||
|
||||
:param bufsize: Buffer size.
|
||||
:returns: A tuple of ``(bytes encoded, errcode, bytes)``.
|
||||
If finished with encoding return 1 for the error code.
|
||||
Err codes are from :data:`.ImageFile.ERRORS`.
|
||||
"""
|
||||
msg = "unavailable in base encoder"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def encode_to_pyfd(self) -> tuple[int, int]:
|
||||
"""
|
||||
If ``pushes_fd`` is ``True``, then this method will be used,
|
||||
and ``encode()`` will only be called once.
|
||||
|
||||
:returns: A tuple of ``(bytes consumed, errcode)``.
|
||||
Err codes are from :data:`.ImageFile.ERRORS`.
|
||||
"""
|
||||
if not self.pushes_fd:
|
||||
return 0, -8 # bad configuration
|
||||
bytes_consumed, errcode, data = self.encode(0)
|
||||
if data:
|
||||
assert self.fd is not None
|
||||
self.fd.write(data)
|
||||
return bytes_consumed, errcode
|
||||
|
||||
def encode_to_file(self, fh: int, bufsize: int) -> int:
|
||||
"""
|
||||
:param fh: File handle.
|
||||
:param bufsize: Buffer size.
|
||||
|
||||
:returns: If finished successfully, return 0.
|
||||
Otherwise, return an error code. Err codes are from
|
||||
:data:`.ImageFile.ERRORS`.
|
||||
"""
|
||||
errcode = 0
|
||||
while errcode == 0:
|
||||
status, errcode, buf = self.encode(bufsize)
|
||||
if status > 0:
|
||||
os.write(fh, buf[status:])
|
||||
return errcode
|
||||
@ -0,0 +1,607 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# standard filters
|
||||
#
|
||||
# History:
|
||||
# 1995-11-27 fl Created
|
||||
# 2002-06-08 fl Added rank and mode filters
|
||||
# 2003-09-15 fl Fixed rank calculation in rank filter; added expand call
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||
# Copyright (c) 1995-2002 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import functools
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from . import _imaging
|
||||
from ._typing import NumpyArray
|
||||
|
||||
|
||||
class Filter(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
pass
|
||||
|
||||
|
||||
class MultibandFilter(Filter):
|
||||
pass
|
||||
|
||||
|
||||
class BuiltinFilter(MultibandFilter):
|
||||
filterargs: tuple[Any, ...]
|
||||
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
if image.mode == "P":
|
||||
msg = "cannot filter palette images"
|
||||
raise ValueError(msg)
|
||||
return image.filter(*self.filterargs)
|
||||
|
||||
|
||||
class Kernel(BuiltinFilter):
|
||||
"""
|
||||
Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating
|
||||
point kernels.
|
||||
|
||||
Kernels can only be applied to "L" and "RGB" images.
|
||||
|
||||
:param size: Kernel size, given as (width, height). This must be (3,3) or (5,5).
|
||||
:param kernel: A sequence containing kernel weights. The kernel will be flipped
|
||||
vertically before being applied to the image.
|
||||
:param scale: Scale factor. If given, the result for each pixel is divided by this
|
||||
value. The default is the sum of the kernel weights.
|
||||
:param offset: Offset. If given, this value is added to the result, after it has
|
||||
been divided by the scale factor.
|
||||
"""
|
||||
|
||||
name = "Kernel"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
size: tuple[int, int],
|
||||
kernel: Sequence[float],
|
||||
scale: float | None = None,
|
||||
offset: float = 0,
|
||||
) -> None:
|
||||
if scale is None:
|
||||
# default scale is sum of kernel
|
||||
scale = functools.reduce(lambda a, b: a + b, kernel)
|
||||
if size[0] * size[1] != len(kernel):
|
||||
msg = "not enough coefficients in kernel"
|
||||
raise ValueError(msg)
|
||||
self.filterargs = size, scale, offset, kernel
|
||||
|
||||
|
||||
class RankFilter(Filter):
|
||||
"""
|
||||
Create a rank filter. The rank filter sorts all pixels in
|
||||
a window of the given size, and returns the ``rank``'th value.
|
||||
|
||||
:param size: The kernel size, in pixels.
|
||||
:param rank: What pixel value to pick. Use 0 for a min filter,
|
||||
``size * size / 2`` for a median filter, ``size * size - 1``
|
||||
for a max filter, etc.
|
||||
"""
|
||||
|
||||
name = "Rank"
|
||||
|
||||
def __init__(self, size: int, rank: int) -> None:
|
||||
self.size = size
|
||||
self.rank = rank
|
||||
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
if image.mode == "P":
|
||||
msg = "cannot filter palette images"
|
||||
raise ValueError(msg)
|
||||
image = image.expand(self.size // 2, self.size // 2)
|
||||
return image.rankfilter(self.size, self.rank)
|
||||
|
||||
|
||||
class MedianFilter(RankFilter):
|
||||
"""
|
||||
Create a median filter. Picks the median pixel value in a window with the
|
||||
given size.
|
||||
|
||||
:param size: The kernel size, in pixels.
|
||||
"""
|
||||
|
||||
name = "Median"
|
||||
|
||||
def __init__(self, size: int = 3) -> None:
|
||||
self.size = size
|
||||
self.rank = size * size // 2
|
||||
|
||||
|
||||
class MinFilter(RankFilter):
|
||||
"""
|
||||
Create a min filter. Picks the lowest pixel value in a window with the
|
||||
given size.
|
||||
|
||||
:param size: The kernel size, in pixels.
|
||||
"""
|
||||
|
||||
name = "Min"
|
||||
|
||||
def __init__(self, size: int = 3) -> None:
|
||||
self.size = size
|
||||
self.rank = 0
|
||||
|
||||
|
||||
class MaxFilter(RankFilter):
|
||||
"""
|
||||
Create a max filter. Picks the largest pixel value in a window with the
|
||||
given size.
|
||||
|
||||
:param size: The kernel size, in pixels.
|
||||
"""
|
||||
|
||||
name = "Max"
|
||||
|
||||
def __init__(self, size: int = 3) -> None:
|
||||
self.size = size
|
||||
self.rank = size * size - 1
|
||||
|
||||
|
||||
class ModeFilter(Filter):
|
||||
"""
|
||||
Create a mode filter. Picks the most frequent pixel value in a box with the
|
||||
given size. Pixel values that occur only once or twice are ignored; if no
|
||||
pixel value occurs more than twice, the original pixel value is preserved.
|
||||
|
||||
:param size: The kernel size, in pixels.
|
||||
"""
|
||||
|
||||
name = "Mode"
|
||||
|
||||
def __init__(self, size: int = 3) -> None:
|
||||
self.size = size
|
||||
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
return image.modefilter(self.size)
|
||||
|
||||
|
||||
class GaussianBlur(MultibandFilter):
|
||||
"""Blurs the image with a sequence of extended box filters, which
|
||||
approximates a Gaussian kernel. For details on accuracy see
|
||||
<https://www.mia.uni-saarland.de/Publications/gwosdek-ssvm11.pdf>
|
||||
|
||||
:param radius: Standard deviation of the Gaussian kernel. Either a sequence of two
|
||||
numbers for x and y, or a single number for both.
|
||||
"""
|
||||
|
||||
name = "GaussianBlur"
|
||||
|
||||
def __init__(self, radius: float | Sequence[float] = 2) -> None:
|
||||
self.radius = radius
|
||||
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
xy = self.radius
|
||||
if isinstance(xy, (int, float)):
|
||||
xy = (xy, xy)
|
||||
if xy == (0, 0):
|
||||
return image.copy()
|
||||
return image.gaussian_blur(xy)
|
||||
|
||||
|
||||
class BoxBlur(MultibandFilter):
|
||||
"""Blurs the image by setting each pixel to the average value of the pixels
|
||||
in a square box extending radius pixels in each direction.
|
||||
Supports float radius of arbitrary size. Uses an optimized implementation
|
||||
which runs in linear time relative to the size of the image
|
||||
for any radius value.
|
||||
|
||||
:param radius: Size of the box in a direction. Either a sequence of two numbers for
|
||||
x and y, or a single number for both.
|
||||
|
||||
Radius 0 does not blur, returns an identical image.
|
||||
Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total.
|
||||
"""
|
||||
|
||||
name = "BoxBlur"
|
||||
|
||||
def __init__(self, radius: float | Sequence[float]) -> None:
|
||||
xy = radius if isinstance(radius, (tuple, list)) else (radius, radius)
|
||||
if xy[0] < 0 or xy[1] < 0:
|
||||
msg = "radius must be >= 0"
|
||||
raise ValueError(msg)
|
||||
self.radius = radius
|
||||
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
xy = self.radius
|
||||
if isinstance(xy, (int, float)):
|
||||
xy = (xy, xy)
|
||||
if xy == (0, 0):
|
||||
return image.copy()
|
||||
return image.box_blur(xy)
|
||||
|
||||
|
||||
class UnsharpMask(MultibandFilter):
|
||||
"""Unsharp mask filter.
|
||||
|
||||
See Wikipedia's entry on `digital unsharp masking`_ for an explanation of
|
||||
the parameters.
|
||||
|
||||
:param radius: Blur Radius
|
||||
:param percent: Unsharp strength, in percent
|
||||
:param threshold: Threshold controls the minimum brightness change that
|
||||
will be sharpened
|
||||
|
||||
.. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking
|
||||
|
||||
"""
|
||||
|
||||
name = "UnsharpMask"
|
||||
|
||||
def __init__(
|
||||
self, radius: float = 2, percent: int = 150, threshold: int = 3
|
||||
) -> None:
|
||||
self.radius = radius
|
||||
self.percent = percent
|
||||
self.threshold = threshold
|
||||
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
return image.unsharp_mask(self.radius, self.percent, self.threshold)
|
||||
|
||||
|
||||
class BLUR(BuiltinFilter):
|
||||
name = "Blur"
|
||||
# fmt: off
|
||||
filterargs = (5, 5), 16, 0, (
|
||||
1, 1, 1, 1, 1,
|
||||
1, 0, 0, 0, 1,
|
||||
1, 0, 0, 0, 1,
|
||||
1, 0, 0, 0, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class CONTOUR(BuiltinFilter):
|
||||
name = "Contour"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 1, 255, (
|
||||
-1, -1, -1,
|
||||
-1, 8, -1,
|
||||
-1, -1, -1,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class DETAIL(BuiltinFilter):
|
||||
name = "Detail"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 6, 0, (
|
||||
0, -1, 0,
|
||||
-1, 10, -1,
|
||||
0, -1, 0,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class EDGE_ENHANCE(BuiltinFilter):
|
||||
name = "Edge-enhance"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 2, 0, (
|
||||
-1, -1, -1,
|
||||
-1, 10, -1,
|
||||
-1, -1, -1,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class EDGE_ENHANCE_MORE(BuiltinFilter):
|
||||
name = "Edge-enhance More"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 1, 0, (
|
||||
-1, -1, -1,
|
||||
-1, 9, -1,
|
||||
-1, -1, -1,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class EMBOSS(BuiltinFilter):
|
||||
name = "Emboss"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 1, 128, (
|
||||
-1, 0, 0,
|
||||
0, 1, 0,
|
||||
0, 0, 0,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class FIND_EDGES(BuiltinFilter):
|
||||
name = "Find Edges"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 1, 0, (
|
||||
-1, -1, -1,
|
||||
-1, 8, -1,
|
||||
-1, -1, -1,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class SHARPEN(BuiltinFilter):
|
||||
name = "Sharpen"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 16, 0, (
|
||||
-2, -2, -2,
|
||||
-2, 32, -2,
|
||||
-2, -2, -2,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class SMOOTH(BuiltinFilter):
|
||||
name = "Smooth"
|
||||
# fmt: off
|
||||
filterargs = (3, 3), 13, 0, (
|
||||
1, 1, 1,
|
||||
1, 5, 1,
|
||||
1, 1, 1,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class SMOOTH_MORE(BuiltinFilter):
|
||||
name = "Smooth More"
|
||||
# fmt: off
|
||||
filterargs = (5, 5), 100, 0, (
|
||||
1, 1, 1, 1, 1,
|
||||
1, 5, 5, 5, 1,
|
||||
1, 5, 44, 5, 1,
|
||||
1, 5, 5, 5, 1,
|
||||
1, 1, 1, 1, 1,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class Color3DLUT(MultibandFilter):
|
||||
"""Three-dimensional color lookup table.
|
||||
|
||||
Transforms 3-channel pixels using the values of the channels as coordinates
|
||||
in the 3D lookup table and interpolating the nearest elements.
|
||||
|
||||
This method allows you to apply almost any color transformation
|
||||
in constant time by using pre-calculated decimated tables.
|
||||
|
||||
.. versionadded:: 5.2.0
|
||||
|
||||
:param size: Size of the table. One int or tuple of (int, int, int).
|
||||
Minimal size in any dimension is 2, maximum is 65.
|
||||
:param table: Flat lookup table. A list of ``channels * size**3``
|
||||
float elements or a list of ``size**3`` channels-sized
|
||||
tuples with floats. Channels are changed first,
|
||||
then first dimension, then second, then third.
|
||||
Value 0.0 corresponds lowest value of output, 1.0 highest.
|
||||
:param channels: Number of channels in the table. Could be 3 or 4.
|
||||
Default is 3.
|
||||
:param target_mode: A mode for the result image. Should have not less
|
||||
than ``channels`` channels. Default is ``None``,
|
||||
which means that mode wouldn't be changed.
|
||||
"""
|
||||
|
||||
name = "Color 3D LUT"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
size: int | tuple[int, int, int],
|
||||
table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray,
|
||||
channels: int = 3,
|
||||
target_mode: str | None = None,
|
||||
**kwargs: bool,
|
||||
) -> None:
|
||||
if channels not in (3, 4):
|
||||
msg = "Only 3 or 4 output channels are supported"
|
||||
raise ValueError(msg)
|
||||
self.size = size = self._check_size(size)
|
||||
self.channels = channels
|
||||
self.mode = target_mode
|
||||
|
||||
# Hidden flag `_copy_table=False` could be used to avoid extra copying
|
||||
# of the table if the table is specially made for the constructor.
|
||||
copy_table = kwargs.get("_copy_table", True)
|
||||
items = size[0] * size[1] * size[2]
|
||||
wrong_size = False
|
||||
|
||||
numpy: ModuleType | None = None
|
||||
if hasattr(table, "shape"):
|
||||
try:
|
||||
import numpy
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if numpy and isinstance(table, numpy.ndarray):
|
||||
numpy_table: NumpyArray = table
|
||||
if copy_table:
|
||||
numpy_table = numpy_table.copy()
|
||||
|
||||
if numpy_table.shape in [
|
||||
(items * channels,),
|
||||
(items, channels),
|
||||
(size[2], size[1], size[0], channels),
|
||||
]:
|
||||
table = numpy_table.reshape(items * channels)
|
||||
else:
|
||||
wrong_size = True
|
||||
|
||||
else:
|
||||
if copy_table:
|
||||
table = list(table)
|
||||
|
||||
# Convert to a flat list
|
||||
if table and isinstance(table[0], (list, tuple)):
|
||||
raw_table = cast(Sequence[Sequence[int]], table)
|
||||
flat_table: list[int] = []
|
||||
for pixel in raw_table:
|
||||
if len(pixel) != channels:
|
||||
msg = (
|
||||
"The elements of the table should "
|
||||
f"have a length of {channels}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
flat_table.extend(pixel)
|
||||
table = flat_table
|
||||
|
||||
if wrong_size or len(table) != items * channels:
|
||||
msg = (
|
||||
"The table should have either channels * size**3 float items "
|
||||
"or size**3 items of channels-sized tuples with floats. "
|
||||
f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. "
|
||||
f"Actual length: {len(table)}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
self.table = table
|
||||
|
||||
@staticmethod
|
||||
def _check_size(size: Any) -> tuple[int, int, int]:
|
||||
try:
|
||||
_, _, _ = size
|
||||
except ValueError as e:
|
||||
msg = "Size should be either an integer or a tuple of three integers."
|
||||
raise ValueError(msg) from e
|
||||
except TypeError:
|
||||
size = (size, size, size)
|
||||
size = tuple(int(x) for x in size)
|
||||
for size_1d in size:
|
||||
if not 2 <= size_1d <= 65:
|
||||
msg = "Size should be in [2, 65] range."
|
||||
raise ValueError(msg)
|
||||
return size
|
||||
|
||||
@classmethod
|
||||
def generate(
|
||||
cls,
|
||||
size: int | tuple[int, int, int],
|
||||
callback: Callable[[float, float, float], tuple[float, ...]],
|
||||
channels: int = 3,
|
||||
target_mode: str | None = None,
|
||||
) -> Color3DLUT:
|
||||
"""Generates new LUT using provided callback.
|
||||
|
||||
:param size: Size of the table. Passed to the constructor.
|
||||
:param callback: Function with three parameters which correspond
|
||||
three color channels. Will be called ``size**3``
|
||||
times with values from 0.0 to 1.0 and should return
|
||||
a tuple with ``channels`` elements.
|
||||
:param channels: The number of channels which should return callback.
|
||||
:param target_mode: Passed to the constructor of the resulting
|
||||
lookup table.
|
||||
"""
|
||||
size_1d, size_2d, size_3d = cls._check_size(size)
|
||||
if channels not in (3, 4):
|
||||
msg = "Only 3 or 4 output channels are supported"
|
||||
raise ValueError(msg)
|
||||
|
||||
table: list[float] = [0] * (size_1d * size_2d * size_3d * channels)
|
||||
idx_out = 0
|
||||
for b in range(size_3d):
|
||||
for g in range(size_2d):
|
||||
for r in range(size_1d):
|
||||
table[idx_out : idx_out + channels] = callback(
|
||||
r / (size_1d - 1), g / (size_2d - 1), b / (size_3d - 1)
|
||||
)
|
||||
idx_out += channels
|
||||
|
||||
return cls(
|
||||
(size_1d, size_2d, size_3d),
|
||||
table,
|
||||
channels=channels,
|
||||
target_mode=target_mode,
|
||||
_copy_table=False,
|
||||
)
|
||||
|
||||
def transform(
|
||||
self,
|
||||
callback: Callable[..., tuple[float, ...]],
|
||||
with_normals: bool = False,
|
||||
channels: int | None = None,
|
||||
target_mode: str | None = None,
|
||||
) -> Color3DLUT:
|
||||
"""Transforms the table values using provided callback and returns
|
||||
a new LUT with altered values.
|
||||
|
||||
:param callback: A function which takes old lookup table values
|
||||
and returns a new set of values. The number
|
||||
of arguments which function should take is
|
||||
``self.channels`` or ``3 + self.channels``
|
||||
if ``with_normals`` flag is set.
|
||||
Should return a tuple of ``self.channels`` or
|
||||
``channels`` elements if it is set.
|
||||
:param with_normals: If true, ``callback`` will be called with
|
||||
coordinates in the color cube as the first
|
||||
three arguments. Otherwise, ``callback``
|
||||
will be called only with actual color values.
|
||||
:param channels: The number of channels in the resulting lookup table.
|
||||
:param target_mode: Passed to the constructor of the resulting
|
||||
lookup table.
|
||||
"""
|
||||
if channels not in (None, 3, 4):
|
||||
msg = "Only 3 or 4 output channels are supported"
|
||||
raise ValueError(msg)
|
||||
ch_in = self.channels
|
||||
ch_out = channels or ch_in
|
||||
size_1d, size_2d, size_3d = self.size
|
||||
|
||||
table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out)
|
||||
idx_in = 0
|
||||
idx_out = 0
|
||||
for b in range(size_3d):
|
||||
for g in range(size_2d):
|
||||
for r in range(size_1d):
|
||||
values = self.table[idx_in : idx_in + ch_in]
|
||||
if with_normals:
|
||||
values = callback(
|
||||
r / (size_1d - 1),
|
||||
g / (size_2d - 1),
|
||||
b / (size_3d - 1),
|
||||
*values,
|
||||
)
|
||||
else:
|
||||
values = callback(*values)
|
||||
table[idx_out : idx_out + ch_out] = values
|
||||
idx_in += ch_in
|
||||
idx_out += ch_out
|
||||
|
||||
return type(self)(
|
||||
self.size,
|
||||
table,
|
||||
channels=ch_out,
|
||||
target_mode=target_mode or self.mode,
|
||||
_copy_table=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
r = [
|
||||
f"{self.__class__.__name__} from {self.table.__class__.__name__}",
|
||||
"size={:d}x{:d}x{:d}".format(*self.size),
|
||||
f"channels={self.channels:d}",
|
||||
]
|
||||
if self.mode:
|
||||
r.append(f"target_mode={self.mode}")
|
||||
return "<{}>".format(" ".join(r))
|
||||
|
||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
||||
from . import Image
|
||||
|
||||
return image.color_lut_3d(
|
||||
self.mode or image.mode,
|
||||
Image.Resampling.BILINEAR,
|
||||
self.channels,
|
||||
self.size,
|
||||
self.table,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,196 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# screen grabber
|
||||
#
|
||||
# History:
|
||||
# 2001-04-26 fl created
|
||||
# 2001-09-17 fl use builtin driver, if present
|
||||
# 2002-11-19 fl added grabclipboard support
|
||||
#
|
||||
# Copyright (c) 2001-2002 by Secret Labs AB
|
||||
# Copyright (c) 2001-2002 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from . import Image
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from . import ImageWin
|
||||
|
||||
|
||||
def grab(
|
||||
bbox: tuple[int, int, int, int] | None = None,
|
||||
include_layered_windows: bool = False,
|
||||
all_screens: bool = False,
|
||||
xdisplay: str | None = None,
|
||||
window: int | ImageWin.HWND | None = None,
|
||||
) -> Image.Image:
|
||||
im: Image.Image
|
||||
if xdisplay is None:
|
||||
if sys.platform == "darwin":
|
||||
fh, filepath = tempfile.mkstemp(".png")
|
||||
os.close(fh)
|
||||
args = ["screencapture"]
|
||||
if bbox:
|
||||
left, top, right, bottom = bbox
|
||||
args += ["-R", f"{left},{top},{right-left},{bottom-top}"]
|
||||
subprocess.call(args + ["-x", filepath])
|
||||
im = Image.open(filepath)
|
||||
im.load()
|
||||
os.unlink(filepath)
|
||||
if bbox:
|
||||
im_resized = im.resize((right - left, bottom - top))
|
||||
im.close()
|
||||
return im_resized
|
||||
return im
|
||||
elif sys.platform == "win32":
|
||||
if window is not None:
|
||||
all_screens = -1
|
||||
offset, size, data = Image.core.grabscreen_win32(
|
||||
include_layered_windows,
|
||||
all_screens,
|
||||
int(window) if window is not None else 0,
|
||||
)
|
||||
im = Image.frombytes(
|
||||
"RGB",
|
||||
size,
|
||||
data,
|
||||
# RGB, 32-bit line padding, origin lower left corner
|
||||
"raw",
|
||||
"BGR",
|
||||
(size[0] * 3 + 3) & -4,
|
||||
-1,
|
||||
)
|
||||
if bbox:
|
||||
x0, y0 = offset
|
||||
left, top, right, bottom = bbox
|
||||
im = im.crop((left - x0, top - y0, right - x0, bottom - y0))
|
||||
return im
|
||||
# Cast to Optional[str] needed for Windows and macOS.
|
||||
display_name: str | None = xdisplay
|
||||
try:
|
||||
if not Image.core.HAVE_XCB:
|
||||
msg = "Pillow was built without XCB support"
|
||||
raise OSError(msg)
|
||||
size, data = Image.core.grabscreen_x11(display_name)
|
||||
except OSError:
|
||||
if display_name is None and sys.platform not in ("darwin", "win32"):
|
||||
if shutil.which("gnome-screenshot"):
|
||||
args = ["gnome-screenshot", "-f"]
|
||||
elif shutil.which("grim"):
|
||||
args = ["grim"]
|
||||
elif shutil.which("spectacle"):
|
||||
args = ["spectacle", "-n", "-b", "-f", "-o"]
|
||||
else:
|
||||
raise
|
||||
fh, filepath = tempfile.mkstemp(".png")
|
||||
os.close(fh)
|
||||
subprocess.call(args + [filepath])
|
||||
im = Image.open(filepath)
|
||||
im.load()
|
||||
os.unlink(filepath)
|
||||
if bbox:
|
||||
im_cropped = im.crop(bbox)
|
||||
im.close()
|
||||
return im_cropped
|
||||
return im
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1)
|
||||
if bbox:
|
||||
im = im.crop(bbox)
|
||||
return im
|
||||
|
||||
|
||||
def grabclipboard() -> Image.Image | list[str] | None:
|
||||
if sys.platform == "darwin":
|
||||
p = subprocess.run(
|
||||
["osascript", "-e", "get the clipboard as «class PNGf»"],
|
||||
capture_output=True,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
return None
|
||||
|
||||
import binascii
|
||||
|
||||
data = io.BytesIO(binascii.unhexlify(p.stdout[11:-3]))
|
||||
return Image.open(data)
|
||||
elif sys.platform == "win32":
|
||||
fmt, data = Image.core.grabclipboard_win32()
|
||||
if fmt == "file": # CF_HDROP
|
||||
import struct
|
||||
|
||||
o = struct.unpack_from("I", data)[0]
|
||||
if data[16] == 0:
|
||||
files = data[o:].decode("mbcs").split("\0")
|
||||
else:
|
||||
files = data[o:].decode("utf-16le").split("\0")
|
||||
return files[: files.index("")]
|
||||
if isinstance(data, bytes):
|
||||
data = io.BytesIO(data)
|
||||
if fmt == "png":
|
||||
from . import PngImagePlugin
|
||||
|
||||
return PngImagePlugin.PngImageFile(data)
|
||||
elif fmt == "DIB":
|
||||
from . import BmpImagePlugin
|
||||
|
||||
return BmpImagePlugin.DibImageFile(data)
|
||||
return None
|
||||
else:
|
||||
if os.getenv("WAYLAND_DISPLAY"):
|
||||
session_type = "wayland"
|
||||
elif os.getenv("DISPLAY"):
|
||||
session_type = "x11"
|
||||
else: # Session type check failed
|
||||
session_type = None
|
||||
|
||||
if shutil.which("wl-paste") and session_type in ("wayland", None):
|
||||
args = ["wl-paste", "-t", "image"]
|
||||
elif shutil.which("xclip") and session_type in ("x11", None):
|
||||
args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]
|
||||
else:
|
||||
msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
p = subprocess.run(args, capture_output=True)
|
||||
if p.returncode != 0:
|
||||
err = p.stderr
|
||||
for silent_error in [
|
||||
# wl-paste, when the clipboard is empty
|
||||
b"Nothing is copied",
|
||||
# Ubuntu/Debian wl-paste, when the clipboard is empty
|
||||
b"No selection",
|
||||
# Ubuntu/Debian wl-paste, when an image isn't available
|
||||
b"No suitable type of content copied",
|
||||
# wl-paste or Ubuntu/Debian xclip, when an image isn't available
|
||||
b" not available",
|
||||
# xclip, when an image isn't available
|
||||
b"cannot convert ",
|
||||
# xclip, when the clipboard isn't initialized
|
||||
b"xclip: Error: There is no owner for the ",
|
||||
]:
|
||||
if silent_error in err:
|
||||
return None
|
||||
msg = f"{args[0]} error"
|
||||
if err:
|
||||
msg += f": {err.strip().decode()}"
|
||||
raise ChildProcessError(msg)
|
||||
|
||||
data = io.BytesIO(p.stdout)
|
||||
im = Image.open(data)
|
||||
im.load()
|
||||
return im
|
||||
@ -0,0 +1,314 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# a simple math add-on for the Python Imaging Library
|
||||
#
|
||||
# History:
|
||||
# 1999-02-15 fl Original PIL Plus release
|
||||
# 2005-05-05 fl Simplified and cleaned up for PIL 1.1.6
|
||||
# 2005-09-12 fl Fixed int() and float() for Python 2.4.1
|
||||
#
|
||||
# Copyright (c) 1999-2005 by Secret Labs AB
|
||||
# Copyright (c) 2005 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
|
||||
from . import Image, _imagingmath
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import CodeType
|
||||
from typing import Any
|
||||
|
||||
|
||||
class _Operand:
|
||||
"""Wraps an image operand, providing standard operators"""
|
||||
|
||||
def __init__(self, im: Image.Image):
|
||||
self.im = im
|
||||
|
||||
def __fixup(self, im1: _Operand | float) -> Image.Image:
|
||||
# convert image to suitable mode
|
||||
if isinstance(im1, _Operand):
|
||||
# argument was an image.
|
||||
if im1.im.mode in ("1", "L"):
|
||||
return im1.im.convert("I")
|
||||
elif im1.im.mode in ("I", "F"):
|
||||
return im1.im
|
||||
else:
|
||||
msg = f"unsupported mode: {im1.im.mode}"
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
# argument was a constant
|
||||
if isinstance(im1, (int, float)) and self.im.mode in ("1", "L", "I"):
|
||||
return Image.new("I", self.im.size, im1)
|
||||
else:
|
||||
return Image.new("F", self.im.size, im1)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
op: str,
|
||||
im1: _Operand | float,
|
||||
im2: _Operand | float | None = None,
|
||||
mode: str | None = None,
|
||||
) -> _Operand:
|
||||
im_1 = self.__fixup(im1)
|
||||
if im2 is None:
|
||||
# unary operation
|
||||
out = Image.new(mode or im_1.mode, im_1.size, None)
|
||||
try:
|
||||
op = getattr(_imagingmath, f"{op}_{im_1.mode}")
|
||||
except AttributeError as e:
|
||||
msg = f"bad operand type for '{op}'"
|
||||
raise TypeError(msg) from e
|
||||
_imagingmath.unop(op, out.getim(), im_1.getim())
|
||||
else:
|
||||
# binary operation
|
||||
im_2 = self.__fixup(im2)
|
||||
if im_1.mode != im_2.mode:
|
||||
# convert both arguments to floating point
|
||||
if im_1.mode != "F":
|
||||
im_1 = im_1.convert("F")
|
||||
if im_2.mode != "F":
|
||||
im_2 = im_2.convert("F")
|
||||
if im_1.size != im_2.size:
|
||||
# crop both arguments to a common size
|
||||
size = (
|
||||
min(im_1.size[0], im_2.size[0]),
|
||||
min(im_1.size[1], im_2.size[1]),
|
||||
)
|
||||
if im_1.size != size:
|
||||
im_1 = im_1.crop((0, 0) + size)
|
||||
if im_2.size != size:
|
||||
im_2 = im_2.crop((0, 0) + size)
|
||||
out = Image.new(mode or im_1.mode, im_1.size, None)
|
||||
try:
|
||||
op = getattr(_imagingmath, f"{op}_{im_1.mode}")
|
||||
except AttributeError as e:
|
||||
msg = f"bad operand type for '{op}'"
|
||||
raise TypeError(msg) from e
|
||||
_imagingmath.binop(op, out.getim(), im_1.getim(), im_2.getim())
|
||||
return _Operand(out)
|
||||
|
||||
# unary operators
|
||||
def __bool__(self) -> bool:
|
||||
# an image is "true" if it contains at least one non-zero pixel
|
||||
return self.im.getbbox() is not None
|
||||
|
||||
def __abs__(self) -> _Operand:
|
||||
return self.apply("abs", self)
|
||||
|
||||
def __pos__(self) -> _Operand:
|
||||
return self
|
||||
|
||||
def __neg__(self) -> _Operand:
|
||||
return self.apply("neg", self)
|
||||
|
||||
# binary operators
|
||||
def __add__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("add", self, other)
|
||||
|
||||
def __radd__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("add", other, self)
|
||||
|
||||
def __sub__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("sub", self, other)
|
||||
|
||||
def __rsub__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("sub", other, self)
|
||||
|
||||
def __mul__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("mul", self, other)
|
||||
|
||||
def __rmul__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("mul", other, self)
|
||||
|
||||
def __truediv__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("div", self, other)
|
||||
|
||||
def __rtruediv__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("div", other, self)
|
||||
|
||||
def __mod__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("mod", self, other)
|
||||
|
||||
def __rmod__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("mod", other, self)
|
||||
|
||||
def __pow__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("pow", self, other)
|
||||
|
||||
def __rpow__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("pow", other, self)
|
||||
|
||||
# bitwise
|
||||
def __invert__(self) -> _Operand:
|
||||
return self.apply("invert", self)
|
||||
|
||||
def __and__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("and", self, other)
|
||||
|
||||
def __rand__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("and", other, self)
|
||||
|
||||
def __or__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("or", self, other)
|
||||
|
||||
def __ror__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("or", other, self)
|
||||
|
||||
def __xor__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("xor", self, other)
|
||||
|
||||
def __rxor__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("xor", other, self)
|
||||
|
||||
def __lshift__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("lshift", self, other)
|
||||
|
||||
def __rshift__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("rshift", self, other)
|
||||
|
||||
# logical
|
||||
def __eq__(self, other: _Operand | float) -> _Operand: # type: ignore[override]
|
||||
return self.apply("eq", self, other)
|
||||
|
||||
def __ne__(self, other: _Operand | float) -> _Operand: # type: ignore[override]
|
||||
return self.apply("ne", self, other)
|
||||
|
||||
def __lt__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("lt", self, other)
|
||||
|
||||
def __le__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("le", self, other)
|
||||
|
||||
def __gt__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("gt", self, other)
|
||||
|
||||
def __ge__(self, other: _Operand | float) -> _Operand:
|
||||
return self.apply("ge", self, other)
|
||||
|
||||
|
||||
# conversions
|
||||
def imagemath_int(self: _Operand) -> _Operand:
|
||||
return _Operand(self.im.convert("I"))
|
||||
|
||||
|
||||
def imagemath_float(self: _Operand) -> _Operand:
|
||||
return _Operand(self.im.convert("F"))
|
||||
|
||||
|
||||
# logical
|
||||
def imagemath_equal(self: _Operand, other: _Operand | float | None) -> _Operand:
|
||||
return self.apply("eq", self, other, mode="I")
|
||||
|
||||
|
||||
def imagemath_notequal(self: _Operand, other: _Operand | float | None) -> _Operand:
|
||||
return self.apply("ne", self, other, mode="I")
|
||||
|
||||
|
||||
def imagemath_min(self: _Operand, other: _Operand | float | None) -> _Operand:
|
||||
return self.apply("min", self, other)
|
||||
|
||||
|
||||
def imagemath_max(self: _Operand, other: _Operand | float | None) -> _Operand:
|
||||
return self.apply("max", self, other)
|
||||
|
||||
|
||||
def imagemath_convert(self: _Operand, mode: str) -> _Operand:
|
||||
return _Operand(self.im.convert(mode))
|
||||
|
||||
|
||||
ops = {
|
||||
"int": imagemath_int,
|
||||
"float": imagemath_float,
|
||||
"equal": imagemath_equal,
|
||||
"notequal": imagemath_notequal,
|
||||
"min": imagemath_min,
|
||||
"max": imagemath_max,
|
||||
"convert": imagemath_convert,
|
||||
}
|
||||
|
||||
|
||||
def lambda_eval(expression: Callable[[dict[str, Any]], Any], **kw: Any) -> Any:
|
||||
"""
|
||||
Returns the result of an image function.
|
||||
|
||||
:py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band
|
||||
images, use the :py:meth:`~PIL.Image.Image.split` method or
|
||||
:py:func:`~PIL.Image.merge` function.
|
||||
|
||||
:param expression: A function that receives a dictionary.
|
||||
:param **kw: Values to add to the function's dictionary.
|
||||
:return: The expression result. This is usually an image object, but can
|
||||
also be an integer, a floating point value, or a pixel tuple,
|
||||
depending on the expression.
|
||||
"""
|
||||
|
||||
args: dict[str, Any] = ops.copy()
|
||||
args.update(kw)
|
||||
for k, v in args.items():
|
||||
if isinstance(v, Image.Image):
|
||||
args[k] = _Operand(v)
|
||||
|
||||
out = expression(args)
|
||||
try:
|
||||
return out.im
|
||||
except AttributeError:
|
||||
return out
|
||||
|
||||
|
||||
def unsafe_eval(expression: str, **kw: Any) -> Any:
|
||||
"""
|
||||
Evaluates an image expression. This uses Python's ``eval()`` function to process
|
||||
the expression string, and carries the security risks of doing so. It is not
|
||||
recommended to process expressions without considering this.
|
||||
:py:meth:`~lambda_eval` is a more secure alternative.
|
||||
|
||||
:py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band
|
||||
images, use the :py:meth:`~PIL.Image.Image.split` method or
|
||||
:py:func:`~PIL.Image.merge` function.
|
||||
|
||||
:param expression: A string containing a Python-style expression.
|
||||
:param **kw: Values to add to the evaluation context.
|
||||
:return: The evaluated expression. This is usually an image object, but can
|
||||
also be an integer, a floating point value, or a pixel tuple,
|
||||
depending on the expression.
|
||||
"""
|
||||
|
||||
# build execution namespace
|
||||
args: dict[str, Any] = ops.copy()
|
||||
for k in kw:
|
||||
if "__" in k or hasattr(builtins, k):
|
||||
msg = f"'{k}' not allowed"
|
||||
raise ValueError(msg)
|
||||
|
||||
args.update(kw)
|
||||
for k, v in args.items():
|
||||
if isinstance(v, Image.Image):
|
||||
args[k] = _Operand(v)
|
||||
|
||||
compiled_code = compile(expression, "<string>", "eval")
|
||||
|
||||
def scan(code: CodeType) -> None:
|
||||
for const in code.co_consts:
|
||||
if type(const) is type(compiled_code):
|
||||
scan(const)
|
||||
|
||||
for name in code.co_names:
|
||||
if name not in args and name != "abs":
|
||||
msg = f"'{name}' not allowed"
|
||||
raise ValueError(msg)
|
||||
|
||||
scan(compiled_code)
|
||||
out = builtins.eval(expression, {"__builtins": {"abs": abs}}, args)
|
||||
try:
|
||||
return out.im
|
||||
except AttributeError:
|
||||
return out
|
||||
@ -0,0 +1,85 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# standard mode descriptors
|
||||
#
|
||||
# History:
|
||||
# 2006-03-20 fl Added
|
||||
#
|
||||
# Copyright (c) 2006 by Secret Labs AB.
|
||||
# Copyright (c) 2006 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class ModeDescriptor(NamedTuple):
|
||||
"""Wrapper for mode strings."""
|
||||
|
||||
mode: str
|
||||
bands: tuple[str, ...]
|
||||
basemode: str
|
||||
basetype: str
|
||||
typestr: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.mode
|
||||
|
||||
|
||||
@lru_cache
|
||||
def getmode(mode: str) -> ModeDescriptor:
|
||||
"""Gets a mode descriptor for the given mode."""
|
||||
endian = "<" if sys.byteorder == "little" else ">"
|
||||
|
||||
modes = {
|
||||
# core modes
|
||||
# Bits need to be extended to bytes
|
||||
"1": ("L", "L", ("1",), "|b1"),
|
||||
"L": ("L", "L", ("L",), "|u1"),
|
||||
"I": ("L", "I", ("I",), f"{endian}i4"),
|
||||
"F": ("L", "F", ("F",), f"{endian}f4"),
|
||||
"P": ("P", "L", ("P",), "|u1"),
|
||||
"RGB": ("RGB", "L", ("R", "G", "B"), "|u1"),
|
||||
"RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"),
|
||||
"RGBA": ("RGB", "L", ("R", "G", "B", "A"), "|u1"),
|
||||
"CMYK": ("RGB", "L", ("C", "M", "Y", "K"), "|u1"),
|
||||
"YCbCr": ("RGB", "L", ("Y", "Cb", "Cr"), "|u1"),
|
||||
# UNDONE - unsigned |u1i1i1
|
||||
"LAB": ("RGB", "L", ("L", "A", "B"), "|u1"),
|
||||
"HSV": ("RGB", "L", ("H", "S", "V"), "|u1"),
|
||||
# extra experimental modes
|
||||
"RGBa": ("RGB", "L", ("R", "G", "B", "a"), "|u1"),
|
||||
"LA": ("L", "L", ("L", "A"), "|u1"),
|
||||
"La": ("L", "L", ("L", "a"), "|u1"),
|
||||
"PA": ("RGB", "L", ("P", "A"), "|u1"),
|
||||
}
|
||||
if mode in modes:
|
||||
base_mode, base_type, bands, type_str = modes[mode]
|
||||
return ModeDescriptor(mode, bands, base_mode, base_type, type_str)
|
||||
|
||||
mapping_modes = {
|
||||
# I;16 == I;16L, and I;32 == I;32L
|
||||
"I;16": "<u2",
|
||||
"I;16S": "<i2",
|
||||
"I;16L": "<u2",
|
||||
"I;16LS": "<i2",
|
||||
"I;16B": ">u2",
|
||||
"I;16BS": ">i2",
|
||||
"I;16N": f"{endian}u2",
|
||||
"I;16NS": f"{endian}i2",
|
||||
"I;32": "<u4",
|
||||
"I;32B": ">u4",
|
||||
"I;32L": "<u4",
|
||||
"I;32S": "<i4",
|
||||
"I;32BS": ">i4",
|
||||
"I;32LS": "<i4",
|
||||
}
|
||||
|
||||
type_str = mapping_modes[mode]
|
||||
return ModeDescriptor(mode, ("I",), "L", "L", type_str)
|
||||
@ -0,0 +1,265 @@
|
||||
# A binary morphology add-on for the Python Imaging Library
|
||||
#
|
||||
# History:
|
||||
# 2014-06-04 Initial version.
|
||||
#
|
||||
# Copyright (c) 2014 Dov Grobgeld <dov.grobgeld@gmail.com>
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from . import Image, _imagingmorph
|
||||
|
||||
LUT_SIZE = 1 << 9
|
||||
|
||||
# fmt: off
|
||||
ROTATION_MATRIX = [
|
||||
6, 3, 0,
|
||||
7, 4, 1,
|
||||
8, 5, 2,
|
||||
]
|
||||
MIRROR_MATRIX = [
|
||||
2, 1, 0,
|
||||
5, 4, 3,
|
||||
8, 7, 6,
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
||||
class LutBuilder:
|
||||
"""A class for building a MorphLut from a descriptive language
|
||||
|
||||
The input patterns is a list of a strings sequences like these::
|
||||
|
||||
4:(...
|
||||
.1.
|
||||
111)->1
|
||||
|
||||
(whitespaces including linebreaks are ignored). The option 4
|
||||
describes a series of symmetry operations (in this case a
|
||||
4-rotation), the pattern is described by:
|
||||
|
||||
- . or X - Ignore
|
||||
- 1 - Pixel is on
|
||||
- 0 - Pixel is off
|
||||
|
||||
The result of the operation is described after "->" string.
|
||||
|
||||
The default is to return the current pixel value, which is
|
||||
returned if no other match is found.
|
||||
|
||||
Operations:
|
||||
|
||||
- 4 - 4 way rotation
|
||||
- N - Negate
|
||||
- 1 - Dummy op for no other operation (an op must always be given)
|
||||
- M - Mirroring
|
||||
|
||||
Example::
|
||||
|
||||
lb = LutBuilder(patterns = ["4:(... .1. 111)->1"])
|
||||
lut = lb.build_lut()
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, patterns: list[str] | None = None, op_name: str | None = None
|
||||
) -> None:
|
||||
if patterns is not None:
|
||||
self.patterns = patterns
|
||||
else:
|
||||
self.patterns = []
|
||||
self.lut: bytearray | None = None
|
||||
if op_name is not None:
|
||||
known_patterns = {
|
||||
"corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"],
|
||||
"dilation4": ["4:(... .0. .1.)->1"],
|
||||
"dilation8": ["4:(... .0. .1.)->1", "4:(... .0. ..1)->1"],
|
||||
"erosion4": ["4:(... .1. .0.)->0"],
|
||||
"erosion8": ["4:(... .1. .0.)->0", "4:(... .1. ..0)->0"],
|
||||
"edge": [
|
||||
"1:(... ... ...)->0",
|
||||
"4:(.0. .1. ...)->1",
|
||||
"4:(01. .1. ...)->1",
|
||||
],
|
||||
}
|
||||
if op_name not in known_patterns:
|
||||
msg = f"Unknown pattern {op_name}!"
|
||||
raise Exception(msg)
|
||||
|
||||
self.patterns = known_patterns[op_name]
|
||||
|
||||
def add_patterns(self, patterns: list[str]) -> None:
|
||||
self.patterns += patterns
|
||||
|
||||
def build_default_lut(self) -> None:
|
||||
symbols = [0, 1]
|
||||
m = 1 << 4 # pos of current pixel
|
||||
self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE))
|
||||
|
||||
def get_lut(self) -> bytearray | None:
|
||||
return self.lut
|
||||
|
||||
def _string_permute(self, pattern: str, permutation: list[int]) -> str:
|
||||
"""string_permute takes a pattern and a permutation and returns the
|
||||
string permuted according to the permutation list.
|
||||
"""
|
||||
assert len(permutation) == 9
|
||||
return "".join(pattern[p] for p in permutation)
|
||||
|
||||
def _pattern_permute(
|
||||
self, basic_pattern: str, options: str, basic_result: int
|
||||
) -> list[tuple[str, int]]:
|
||||
"""pattern_permute takes a basic pattern and its result and clones
|
||||
the pattern according to the modifications described in the $options
|
||||
parameter. It returns a list of all cloned patterns."""
|
||||
patterns = [(basic_pattern, basic_result)]
|
||||
|
||||
# rotations
|
||||
if "4" in options:
|
||||
res = patterns[-1][1]
|
||||
for i in range(4):
|
||||
patterns.append(
|
||||
(self._string_permute(patterns[-1][0], ROTATION_MATRIX), res)
|
||||
)
|
||||
# mirror
|
||||
if "M" in options:
|
||||
n = len(patterns)
|
||||
for pattern, res in patterns[:n]:
|
||||
patterns.append((self._string_permute(pattern, MIRROR_MATRIX), res))
|
||||
|
||||
# negate
|
||||
if "N" in options:
|
||||
n = len(patterns)
|
||||
for pattern, res in patterns[:n]:
|
||||
# Swap 0 and 1
|
||||
pattern = pattern.replace("0", "Z").replace("1", "0").replace("Z", "1")
|
||||
res = 1 - int(res)
|
||||
patterns.append((pattern, res))
|
||||
|
||||
return patterns
|
||||
|
||||
def build_lut(self) -> bytearray:
|
||||
"""Compile all patterns into a morphology lut.
|
||||
|
||||
TBD :Build based on (file) morphlut:modify_lut
|
||||
"""
|
||||
self.build_default_lut()
|
||||
assert self.lut is not None
|
||||
patterns = []
|
||||
|
||||
# Parse and create symmetries of the patterns strings
|
||||
for p in self.patterns:
|
||||
m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
|
||||
if not m:
|
||||
msg = 'Syntax error in pattern "' + p + '"'
|
||||
raise Exception(msg)
|
||||
options = m.group(1)
|
||||
pattern = m.group(2)
|
||||
result = int(m.group(3))
|
||||
|
||||
# Get rid of spaces
|
||||
pattern = pattern.replace(" ", "").replace("\n", "")
|
||||
|
||||
patterns += self._pattern_permute(pattern, options, result)
|
||||
|
||||
# compile the patterns into regular expressions for speed
|
||||
compiled_patterns = []
|
||||
for pattern in patterns:
|
||||
p = pattern[0].replace(".", "X").replace("X", "[01]")
|
||||
compiled_patterns.append((re.compile(p), pattern[1]))
|
||||
|
||||
# Step through table and find patterns that match.
|
||||
# Note that all the patterns are searched. The last one
|
||||
# caught overrides
|
||||
for i in range(LUT_SIZE):
|
||||
# Build the bit pattern
|
||||
bitpattern = bin(i)[2:]
|
||||
bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1]
|
||||
|
||||
for pattern, r in compiled_patterns:
|
||||
if pattern.match(bitpattern):
|
||||
self.lut[i] = [0, 1][r]
|
||||
|
||||
return self.lut
|
||||
|
||||
|
||||
class MorphOp:
|
||||
"""A class for binary morphological operators"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lut: bytearray | None = None,
|
||||
op_name: str | None = None,
|
||||
patterns: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Create a binary morphological operator"""
|
||||
self.lut = lut
|
||||
if op_name is not None:
|
||||
self.lut = LutBuilder(op_name=op_name).build_lut()
|
||||
elif patterns is not None:
|
||||
self.lut = LutBuilder(patterns=patterns).build_lut()
|
||||
|
||||
def apply(self, image: Image.Image) -> tuple[int, Image.Image]:
|
||||
"""Run a single morphological operation on an image
|
||||
|
||||
Returns a tuple of the number of changed pixels and the
|
||||
morphed image"""
|
||||
if self.lut is None:
|
||||
msg = "No operator loaded"
|
||||
raise Exception(msg)
|
||||
|
||||
if image.mode != "L":
|
||||
msg = "Image mode must be L"
|
||||
raise ValueError(msg)
|
||||
outimage = Image.new(image.mode, image.size, None)
|
||||
count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim())
|
||||
return count, outimage
|
||||
|
||||
def match(self, image: Image.Image) -> list[tuple[int, int]]:
|
||||
"""Get a list of coordinates matching the morphological operation on
|
||||
an image.
|
||||
|
||||
Returns a list of tuples of (x,y) coordinates
|
||||
of all matching pixels. See :ref:`coordinate-system`."""
|
||||
if self.lut is None:
|
||||
msg = "No operator loaded"
|
||||
raise Exception(msg)
|
||||
|
||||
if image.mode != "L":
|
||||
msg = "Image mode must be L"
|
||||
raise ValueError(msg)
|
||||
return _imagingmorph.match(bytes(self.lut), image.getim())
|
||||
|
||||
def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]:
|
||||
"""Get a list of all turned on pixels in a binary image
|
||||
|
||||
Returns a list of tuples of (x,y) coordinates
|
||||
of all matching pixels. See :ref:`coordinate-system`."""
|
||||
|
||||
if image.mode != "L":
|
||||
msg = "Image mode must be L"
|
||||
raise ValueError(msg)
|
||||
return _imagingmorph.get_on_pixels(image.getim())
|
||||
|
||||
def load_lut(self, filename: str) -> None:
|
||||
"""Load an operator from an mrl file"""
|
||||
with open(filename, "rb") as f:
|
||||
self.lut = bytearray(f.read())
|
||||
|
||||
if len(self.lut) != LUT_SIZE:
|
||||
self.lut = None
|
||||
msg = "Wrong size operator file!"
|
||||
raise Exception(msg)
|
||||
|
||||
def save_lut(self, filename: str) -> None:
|
||||
"""Save an operator to an mrl file"""
|
||||
if self.lut is None:
|
||||
msg = "No operator loaded"
|
||||
raise Exception(msg)
|
||||
with open(filename, "wb") as f:
|
||||
f.write(self.lut)
|
||||
|
||||
def set_lut(self, lut: bytearray | None) -> None:
|
||||
"""Set the lut from an external source"""
|
||||
self.lut = lut
|
||||
@ -0,0 +1,746 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# standard image operations
|
||||
#
|
||||
# History:
|
||||
# 2001-10-20 fl Created
|
||||
# 2001-10-23 fl Added autocontrast operator
|
||||
# 2001-12-18 fl Added Kevin's fit operator
|
||||
# 2004-03-14 fl Fixed potential division by zero in equalize
|
||||
# 2005-05-05 fl Fixed equalize for low number of values
|
||||
#
|
||||
# Copyright (c) 2001-2004 by Secret Labs AB
|
||||
# Copyright (c) 2001-2004 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import operator
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal, Protocol, cast, overload
|
||||
|
||||
from . import ExifTags, Image, ImagePalette
|
||||
|
||||
#
|
||||
# helpers
|
||||
|
||||
|
||||
def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]:
|
||||
if isinstance(border, tuple):
|
||||
if len(border) == 2:
|
||||
left, top = right, bottom = border
|
||||
elif len(border) == 4:
|
||||
left, top, right, bottom = border
|
||||
else:
|
||||
left = top = right = bottom = border
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]:
|
||||
if isinstance(color, str):
|
||||
from . import ImageColor
|
||||
|
||||
color = ImageColor.getcolor(color, mode)
|
||||
return color
|
||||
|
||||
|
||||
def _lut(image: Image.Image, lut: list[int]) -> Image.Image:
|
||||
if image.mode == "P":
|
||||
# FIXME: apply to lookup table, not image data
|
||||
msg = "mode P support coming soon"
|
||||
raise NotImplementedError(msg)
|
||||
elif image.mode in ("L", "RGB"):
|
||||
if image.mode == "RGB" and len(lut) == 256:
|
||||
lut = lut + lut + lut
|
||||
return image.point(lut)
|
||||
else:
|
||||
msg = f"not supported for mode {image.mode}"
|
||||
raise OSError(msg)
|
||||
|
||||
|
||||
#
|
||||
# actions
|
||||
|
||||
|
||||
def autocontrast(
|
||||
image: Image.Image,
|
||||
cutoff: float | tuple[float, float] = 0,
|
||||
ignore: int | Sequence[int] | None = None,
|
||||
mask: Image.Image | None = None,
|
||||
preserve_tone: bool = False,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Maximize (normalize) image contrast. This function calculates a
|
||||
histogram of the input image (or mask region), removes ``cutoff`` percent of the
|
||||
lightest and darkest pixels from the histogram, and remaps the image
|
||||
so that the darkest pixel becomes black (0), and the lightest
|
||||
becomes white (255).
|
||||
|
||||
:param image: The image to process.
|
||||
:param cutoff: The percent to cut off from the histogram on the low and
|
||||
high ends. Either a tuple of (low, high), or a single
|
||||
number for both.
|
||||
:param ignore: The background pixel value (use None for no background).
|
||||
:param mask: Histogram used in contrast operation is computed using pixels
|
||||
within the mask. If no mask is given the entire image is used
|
||||
for histogram computation.
|
||||
:param preserve_tone: Preserve image tone in Photoshop-like style autocontrast.
|
||||
|
||||
.. versionadded:: 8.2.0
|
||||
|
||||
:return: An image.
|
||||
"""
|
||||
if preserve_tone:
|
||||
histogram = image.convert("L").histogram(mask)
|
||||
else:
|
||||
histogram = image.histogram(mask)
|
||||
|
||||
lut = []
|
||||
for layer in range(0, len(histogram), 256):
|
||||
h = histogram[layer : layer + 256]
|
||||
if ignore is not None:
|
||||
# get rid of outliers
|
||||
if isinstance(ignore, int):
|
||||
h[ignore] = 0
|
||||
else:
|
||||
for ix in ignore:
|
||||
h[ix] = 0
|
||||
if cutoff:
|
||||
# cut off pixels from both ends of the histogram
|
||||
if not isinstance(cutoff, tuple):
|
||||
cutoff = (cutoff, cutoff)
|
||||
# get number of pixels
|
||||
n = 0
|
||||
for ix in range(256):
|
||||
n = n + h[ix]
|
||||
# remove cutoff% pixels from the low end
|
||||
cut = int(n * cutoff[0] // 100)
|
||||
for lo in range(256):
|
||||
if cut > h[lo]:
|
||||
cut = cut - h[lo]
|
||||
h[lo] = 0
|
||||
else:
|
||||
h[lo] -= cut
|
||||
cut = 0
|
||||
if cut <= 0:
|
||||
break
|
||||
# remove cutoff% samples from the high end
|
||||
cut = int(n * cutoff[1] // 100)
|
||||
for hi in range(255, -1, -1):
|
||||
if cut > h[hi]:
|
||||
cut = cut - h[hi]
|
||||
h[hi] = 0
|
||||
else:
|
||||
h[hi] -= cut
|
||||
cut = 0
|
||||
if cut <= 0:
|
||||
break
|
||||
# find lowest/highest samples after preprocessing
|
||||
for lo in range(256):
|
||||
if h[lo]:
|
||||
break
|
||||
for hi in range(255, -1, -1):
|
||||
if h[hi]:
|
||||
break
|
||||
if hi <= lo:
|
||||
# don't bother
|
||||
lut.extend(list(range(256)))
|
||||
else:
|
||||
scale = 255.0 / (hi - lo)
|
||||
offset = -lo * scale
|
||||
for ix in range(256):
|
||||
ix = int(ix * scale + offset)
|
||||
if ix < 0:
|
||||
ix = 0
|
||||
elif ix > 255:
|
||||
ix = 255
|
||||
lut.append(ix)
|
||||
return _lut(image, lut)
|
||||
|
||||
|
||||
def colorize(
|
||||
image: Image.Image,
|
||||
black: str | tuple[int, ...],
|
||||
white: str | tuple[int, ...],
|
||||
mid: str | int | tuple[int, ...] | None = None,
|
||||
blackpoint: int = 0,
|
||||
whitepoint: int = 255,
|
||||
midpoint: int = 127,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Colorize grayscale image.
|
||||
This function calculates a color wedge which maps all black pixels in
|
||||
the source image to the first color and all white pixels to the
|
||||
second color. If ``mid`` is specified, it uses three-color mapping.
|
||||
The ``black`` and ``white`` arguments should be RGB tuples or color names;
|
||||
optionally you can use three-color mapping by also specifying ``mid``.
|
||||
Mapping positions for any of the colors can be specified
|
||||
(e.g. ``blackpoint``), where these parameters are the integer
|
||||
value corresponding to where the corresponding color should be mapped.
|
||||
These parameters must have logical order, such that
|
||||
``blackpoint <= midpoint <= whitepoint`` (if ``mid`` is specified).
|
||||
|
||||
:param image: The image to colorize.
|
||||
:param black: The color to use for black input pixels.
|
||||
:param white: The color to use for white input pixels.
|
||||
:param mid: The color to use for midtone input pixels.
|
||||
:param blackpoint: an int value [0, 255] for the black mapping.
|
||||
:param whitepoint: an int value [0, 255] for the white mapping.
|
||||
:param midpoint: an int value [0, 255] for the midtone mapping.
|
||||
:return: An image.
|
||||
"""
|
||||
|
||||
# Initial asserts
|
||||
assert image.mode == "L"
|
||||
if mid is None:
|
||||
assert 0 <= blackpoint <= whitepoint <= 255
|
||||
else:
|
||||
assert 0 <= blackpoint <= midpoint <= whitepoint <= 255
|
||||
|
||||
# Define colors from arguments
|
||||
rgb_black = cast(Sequence[int], _color(black, "RGB"))
|
||||
rgb_white = cast(Sequence[int], _color(white, "RGB"))
|
||||
rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None
|
||||
|
||||
# Empty lists for the mapping
|
||||
red = []
|
||||
green = []
|
||||
blue = []
|
||||
|
||||
# Create the low-end values
|
||||
for i in range(blackpoint):
|
||||
red.append(rgb_black[0])
|
||||
green.append(rgb_black[1])
|
||||
blue.append(rgb_black[2])
|
||||
|
||||
# Create the mapping (2-color)
|
||||
if rgb_mid is None:
|
||||
range_map = range(whitepoint - blackpoint)
|
||||
|
||||
for i in range_map:
|
||||
red.append(
|
||||
rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map)
|
||||
)
|
||||
green.append(
|
||||
rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map)
|
||||
)
|
||||
blue.append(
|
||||
rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map)
|
||||
)
|
||||
|
||||
# Create the mapping (3-color)
|
||||
else:
|
||||
range_map1 = range(midpoint - blackpoint)
|
||||
range_map2 = range(whitepoint - midpoint)
|
||||
|
||||
for i in range_map1:
|
||||
red.append(
|
||||
rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1)
|
||||
)
|
||||
green.append(
|
||||
rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1)
|
||||
)
|
||||
blue.append(
|
||||
rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1)
|
||||
)
|
||||
for i in range_map2:
|
||||
red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2))
|
||||
green.append(
|
||||
rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2)
|
||||
)
|
||||
blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2))
|
||||
|
||||
# Create the high-end values
|
||||
for i in range(256 - whitepoint):
|
||||
red.append(rgb_white[0])
|
||||
green.append(rgb_white[1])
|
||||
blue.append(rgb_white[2])
|
||||
|
||||
# Return converted image
|
||||
image = image.convert("RGB")
|
||||
return _lut(image, red + green + blue)
|
||||
|
||||
|
||||
def contain(
|
||||
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Returns a resized version of the image, set to the maximum width and height
|
||||
within the requested size, while maintaining the original aspect ratio.
|
||||
|
||||
:param image: The image to resize.
|
||||
:param size: The requested output size in pixels, given as a
|
||||
(width, height) tuple.
|
||||
:param method: Resampling method to use. Default is
|
||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
||||
See :ref:`concept-filters`.
|
||||
:return: An image.
|
||||
"""
|
||||
|
||||
im_ratio = image.width / image.height
|
||||
dest_ratio = size[0] / size[1]
|
||||
|
||||
if im_ratio != dest_ratio:
|
||||
if im_ratio > dest_ratio:
|
||||
new_height = round(image.height / image.width * size[0])
|
||||
if new_height != size[1]:
|
||||
size = (size[0], new_height)
|
||||
else:
|
||||
new_width = round(image.width / image.height * size[1])
|
||||
if new_width != size[0]:
|
||||
size = (new_width, size[1])
|
||||
return image.resize(size, resample=method)
|
||||
|
||||
|
||||
def cover(
|
||||
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Returns a resized version of the image, so that the requested size is
|
||||
covered, while maintaining the original aspect ratio.
|
||||
|
||||
:param image: The image to resize.
|
||||
:param size: The requested output size in pixels, given as a
|
||||
(width, height) tuple.
|
||||
:param method: Resampling method to use. Default is
|
||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
||||
See :ref:`concept-filters`.
|
||||
:return: An image.
|
||||
"""
|
||||
|
||||
im_ratio = image.width / image.height
|
||||
dest_ratio = size[0] / size[1]
|
||||
|
||||
if im_ratio != dest_ratio:
|
||||
if im_ratio < dest_ratio:
|
||||
new_height = round(image.height / image.width * size[0])
|
||||
if new_height != size[1]:
|
||||
size = (size[0], new_height)
|
||||
else:
|
||||
new_width = round(image.width / image.height * size[1])
|
||||
if new_width != size[0]:
|
||||
size = (new_width, size[1])
|
||||
return image.resize(size, resample=method)
|
||||
|
||||
|
||||
def pad(
|
||||
image: Image.Image,
|
||||
size: tuple[int, int],
|
||||
method: int = Image.Resampling.BICUBIC,
|
||||
color: str | int | tuple[int, ...] | None = None,
|
||||
centering: tuple[float, float] = (0.5, 0.5),
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Returns a resized and padded version of the image, expanded to fill the
|
||||
requested aspect ratio and size.
|
||||
|
||||
:param image: The image to resize and crop.
|
||||
:param size: The requested output size in pixels, given as a
|
||||
(width, height) tuple.
|
||||
:param method: Resampling method to use. Default is
|
||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
||||
See :ref:`concept-filters`.
|
||||
:param color: The background color of the padded image.
|
||||
:param centering: Control the position of the original image within the
|
||||
padded version.
|
||||
|
||||
(0.5, 0.5) will keep the image centered
|
||||
(0, 0) will keep the image aligned to the top left
|
||||
(1, 1) will keep the image aligned to the bottom
|
||||
right
|
||||
:return: An image.
|
||||
"""
|
||||
|
||||
resized = contain(image, size, method)
|
||||
if resized.size == size:
|
||||
out = resized
|
||||
else:
|
||||
out = Image.new(image.mode, size, color)
|
||||
if resized.palette:
|
||||
palette = resized.getpalette()
|
||||
if palette is not None:
|
||||
out.putpalette(palette)
|
||||
if resized.width != size[0]:
|
||||
x = round((size[0] - resized.width) * max(0, min(centering[0], 1)))
|
||||
out.paste(resized, (x, 0))
|
||||
else:
|
||||
y = round((size[1] - resized.height) * max(0, min(centering[1], 1)))
|
||||
out.paste(resized, (0, y))
|
||||
return out
|
||||
|
||||
|
||||
def crop(image: Image.Image, border: int = 0) -> Image.Image:
|
||||
"""
|
||||
Remove border from image. The same amount of pixels are removed
|
||||
from all four sides. This function works on all image modes.
|
||||
|
||||
.. seealso:: :py:meth:`~PIL.Image.Image.crop`
|
||||
|
||||
:param image: The image to crop.
|
||||
:param border: The number of pixels to remove.
|
||||
:return: An image.
|
||||
"""
|
||||
left, top, right, bottom = _border(border)
|
||||
return image.crop((left, top, image.size[0] - right, image.size[1] - bottom))
|
||||
|
||||
|
||||
def scale(
|
||||
image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Returns a rescaled image by a specific factor given in parameter.
|
||||
A factor greater than 1 expands the image, between 0 and 1 contracts the
|
||||
image.
|
||||
|
||||
:param image: The image to rescale.
|
||||
:param factor: The expansion factor, as a float.
|
||||
:param resample: Resampling method to use. Default is
|
||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
||||
See :ref:`concept-filters`.
|
||||
:returns: An :py:class:`~PIL.Image.Image` object.
|
||||
"""
|
||||
if factor == 1:
|
||||
return image.copy()
|
||||
elif factor <= 0:
|
||||
msg = "the factor must be greater than 0"
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
size = (round(factor * image.width), round(factor * image.height))
|
||||
return image.resize(size, resample)
|
||||
|
||||
|
||||
class SupportsGetMesh(Protocol):
|
||||
"""
|
||||
An object that supports the ``getmesh`` method, taking an image as an
|
||||
argument, and returning a list of tuples. Each tuple contains two tuples,
|
||||
the source box as a tuple of 4 integers, and a tuple of 8 integers for the
|
||||
final quadrilateral, in order of top left, bottom left, bottom right, top
|
||||
right.
|
||||
"""
|
||||
|
||||
def getmesh(
|
||||
self, image: Image.Image
|
||||
) -> list[
|
||||
tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]]
|
||||
]: ...
|
||||
|
||||
|
||||
def deform(
|
||||
image: Image.Image,
|
||||
deformer: SupportsGetMesh,
|
||||
resample: int = Image.Resampling.BILINEAR,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Deform the image.
|
||||
|
||||
:param image: The image to deform.
|
||||
:param deformer: A deformer object. Any object that implements a
|
||||
``getmesh`` method can be used.
|
||||
:param resample: An optional resampling filter. Same values possible as
|
||||
in the PIL.Image.transform function.
|
||||
:return: An image.
|
||||
"""
|
||||
return image.transform(
|
||||
image.size, Image.Transform.MESH, deformer.getmesh(image), resample
|
||||
)
|
||||
|
||||
|
||||
def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image:
|
||||
"""
|
||||
Equalize the image histogram. This function applies a non-linear
|
||||
mapping to the input image, in order to create a uniform
|
||||
distribution of grayscale values in the output image.
|
||||
|
||||
:param image: The image to equalize.
|
||||
:param mask: An optional mask. If given, only the pixels selected by
|
||||
the mask are included in the analysis.
|
||||
:return: An image.
|
||||
"""
|
||||
if image.mode == "P":
|
||||
image = image.convert("RGB")
|
||||
h = image.histogram(mask)
|
||||
lut = []
|
||||
for b in range(0, len(h), 256):
|
||||
histo = [_f for _f in h[b : b + 256] if _f]
|
||||
if len(histo) <= 1:
|
||||
lut.extend(list(range(256)))
|
||||
else:
|
||||
step = (functools.reduce(operator.add, histo) - histo[-1]) // 255
|
||||
if not step:
|
||||
lut.extend(list(range(256)))
|
||||
else:
|
||||
n = step // 2
|
||||
for i in range(256):
|
||||
lut.append(n // step)
|
||||
n = n + h[i + b]
|
||||
return _lut(image, lut)
|
||||
|
||||
|
||||
def expand(
|
||||
image: Image.Image,
|
||||
border: int | tuple[int, ...] = 0,
|
||||
fill: str | int | tuple[int, ...] = 0,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Add border to the image
|
||||
|
||||
:param image: The image to expand.
|
||||
:param border: Border width, in pixels.
|
||||
:param fill: Pixel fill value (a color value). Default is 0 (black).
|
||||
:return: An image.
|
||||
"""
|
||||
left, top, right, bottom = _border(border)
|
||||
width = left + image.size[0] + right
|
||||
height = top + image.size[1] + bottom
|
||||
color = _color(fill, image.mode)
|
||||
if image.palette:
|
||||
mode = image.palette.mode
|
||||
palette = ImagePalette.ImagePalette(mode, image.getpalette(mode))
|
||||
if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4):
|
||||
color = palette.getcolor(color)
|
||||
else:
|
||||
palette = None
|
||||
out = Image.new(image.mode, (width, height), color)
|
||||
if palette:
|
||||
out.putpalette(palette.palette, mode)
|
||||
out.paste(image, (left, top))
|
||||
return out
|
||||
|
||||
|
||||
def fit(
|
||||
image: Image.Image,
|
||||
size: tuple[int, int],
|
||||
method: int = Image.Resampling.BICUBIC,
|
||||
bleed: float = 0.0,
|
||||
centering: tuple[float, float] = (0.5, 0.5),
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Returns a resized and cropped version of the image, cropped to the
|
||||
requested aspect ratio and size.
|
||||
|
||||
This function was contributed by Kevin Cazabon.
|
||||
|
||||
:param image: The image to resize and crop.
|
||||
:param size: The requested output size in pixels, given as a
|
||||
(width, height) tuple.
|
||||
:param method: Resampling method to use. Default is
|
||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
||||
See :ref:`concept-filters`.
|
||||
:param bleed: Remove a border around the outside of the image from all
|
||||
four edges. The value is a decimal percentage (use 0.01 for
|
||||
one percent). The default value is 0 (no border).
|
||||
Cannot be greater than or equal to 0.5.
|
||||
:param centering: Control the cropping position. Use (0.5, 0.5) for
|
||||
center cropping (e.g. if cropping the width, take 50% off
|
||||
of the left side, and therefore 50% off the right side).
|
||||
(0.0, 0.0) will crop from the top left corner (i.e. if
|
||||
cropping the width, take all of the crop off of the right
|
||||
side, and if cropping the height, take all of it off the
|
||||
bottom). (1.0, 0.0) will crop from the bottom left
|
||||
corner, etc. (i.e. if cropping the width, take all of the
|
||||
crop off the left side, and if cropping the height take
|
||||
none from the top, and therefore all off the bottom).
|
||||
:return: An image.
|
||||
"""
|
||||
|
||||
# by Kevin Cazabon, Feb 17/2000
|
||||
# kevin@cazabon.com
|
||||
# https://www.cazabon.com
|
||||
|
||||
centering_x, centering_y = centering
|
||||
|
||||
if not 0.0 <= centering_x <= 1.0:
|
||||
centering_x = 0.5
|
||||
if not 0.0 <= centering_y <= 1.0:
|
||||
centering_y = 0.5
|
||||
|
||||
if not 0.0 <= bleed < 0.5:
|
||||
bleed = 0.0
|
||||
|
||||
# calculate the area to use for resizing and cropping, subtracting
|
||||
# the 'bleed' around the edges
|
||||
|
||||
# number of pixels to trim off on Top and Bottom, Left and Right
|
||||
bleed_pixels = (bleed * image.size[0], bleed * image.size[1])
|
||||
|
||||
live_size = (
|
||||
image.size[0] - bleed_pixels[0] * 2,
|
||||
image.size[1] - bleed_pixels[1] * 2,
|
||||
)
|
||||
|
||||
# calculate the aspect ratio of the live_size
|
||||
live_size_ratio = live_size[0] / live_size[1]
|
||||
|
||||
# calculate the aspect ratio of the output image
|
||||
output_ratio = size[0] / size[1]
|
||||
|
||||
# figure out if the sides or top/bottom will be cropped off
|
||||
if live_size_ratio == output_ratio:
|
||||
# live_size is already the needed ratio
|
||||
crop_width = live_size[0]
|
||||
crop_height = live_size[1]
|
||||
elif live_size_ratio >= output_ratio:
|
||||
# live_size is wider than what's needed, crop the sides
|
||||
crop_width = output_ratio * live_size[1]
|
||||
crop_height = live_size[1]
|
||||
else:
|
||||
# live_size is taller than what's needed, crop the top and bottom
|
||||
crop_width = live_size[0]
|
||||
crop_height = live_size[0] / output_ratio
|
||||
|
||||
# make the crop
|
||||
crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x
|
||||
crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y
|
||||
|
||||
crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height)
|
||||
|
||||
# resize the image and return it
|
||||
return image.resize(size, method, box=crop)
|
||||
|
||||
|
||||
def flip(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Flip the image vertically (top to bottom).
|
||||
|
||||
:param image: The image to flip.
|
||||
:return: An image.
|
||||
"""
|
||||
return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
||||
|
||||
|
||||
def grayscale(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Convert the image to grayscale.
|
||||
|
||||
:param image: The image to convert.
|
||||
:return: An image.
|
||||
"""
|
||||
return image.convert("L")
|
||||
|
||||
|
||||
def invert(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Invert (negate) the image.
|
||||
|
||||
:param image: The image to invert.
|
||||
:return: An image.
|
||||
"""
|
||||
lut = list(range(255, -1, -1))
|
||||
return image.point(lut) if image.mode == "1" else _lut(image, lut)
|
||||
|
||||
|
||||
def mirror(image: Image.Image) -> Image.Image:
|
||||
"""
|
||||
Flip image horizontally (left to right).
|
||||
|
||||
:param image: The image to mirror.
|
||||
:return: An image.
|
||||
"""
|
||||
return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
|
||||
|
||||
def posterize(image: Image.Image, bits: int) -> Image.Image:
|
||||
"""
|
||||
Reduce the number of bits for each color channel.
|
||||
|
||||
:param image: The image to posterize.
|
||||
:param bits: The number of bits to keep for each channel (1-8).
|
||||
:return: An image.
|
||||
"""
|
||||
mask = ~(2 ** (8 - bits) - 1)
|
||||
lut = [i & mask for i in range(256)]
|
||||
return _lut(image, lut)
|
||||
|
||||
|
||||
def solarize(image: Image.Image, threshold: int = 128) -> Image.Image:
|
||||
"""
|
||||
Invert all pixel values above a threshold.
|
||||
|
||||
:param image: The image to solarize.
|
||||
:param threshold: All pixels above this grayscale level are inverted.
|
||||
:return: An image.
|
||||
"""
|
||||
lut = []
|
||||
for i in range(256):
|
||||
if i < threshold:
|
||||
lut.append(i)
|
||||
else:
|
||||
lut.append(255 - i)
|
||||
return _lut(image, lut)
|
||||
|
||||
|
||||
@overload
|
||||
def exif_transpose(image: Image.Image, *, in_place: Literal[True]) -> None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def exif_transpose(
|
||||
image: Image.Image, *, in_place: Literal[False] = False
|
||||
) -> Image.Image: ...
|
||||
|
||||
|
||||
def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None:
|
||||
"""
|
||||
If an image has an EXIF Orientation tag, other than 1, transpose the image
|
||||
accordingly, and remove the orientation data.
|
||||
|
||||
:param image: The image to transpose.
|
||||
:param in_place: Boolean. Keyword-only argument.
|
||||
If ``True``, the original image is modified in-place, and ``None`` is returned.
|
||||
If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned
|
||||
with the transposition applied. If there is no transposition, a copy of the
|
||||
image will be returned.
|
||||
"""
|
||||
image.load()
|
||||
image_exif = image.getexif()
|
||||
orientation = image_exif.get(ExifTags.Base.Orientation, 1)
|
||||
method = {
|
||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
||||
3: Image.Transpose.ROTATE_180,
|
||||
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
||||
5: Image.Transpose.TRANSPOSE,
|
||||
6: Image.Transpose.ROTATE_270,
|
||||
7: Image.Transpose.TRANSVERSE,
|
||||
8: Image.Transpose.ROTATE_90,
|
||||
}.get(orientation)
|
||||
if method is not None:
|
||||
if in_place:
|
||||
image.im = image.im.transpose(method)
|
||||
image._size = image.im.size
|
||||
else:
|
||||
transposed_image = image.transpose(method)
|
||||
exif_image = image if in_place else transposed_image
|
||||
|
||||
exif = exif_image.getexif()
|
||||
if ExifTags.Base.Orientation in exif:
|
||||
del exif[ExifTags.Base.Orientation]
|
||||
if "exif" in exif_image.info:
|
||||
exif_image.info["exif"] = exif.tobytes()
|
||||
elif "Raw profile type exif" in exif_image.info:
|
||||
exif_image.info["Raw profile type exif"] = exif.tobytes().hex()
|
||||
for key in ("XML:com.adobe.xmp", "xmp"):
|
||||
if key in exif_image.info:
|
||||
for pattern in (
|
||||
r'tiff:Orientation="([0-9])"',
|
||||
r"<tiff:Orientation>([0-9])</tiff:Orientation>",
|
||||
):
|
||||
value = exif_image.info[key]
|
||||
if isinstance(value, str):
|
||||
value = re.sub(pattern, "", value)
|
||||
elif isinstance(value, tuple):
|
||||
value = tuple(
|
||||
re.sub(pattern.encode(), b"", v) for v in value
|
||||
)
|
||||
else:
|
||||
value = re.sub(pattern.encode(), b"", value)
|
||||
exif_image.info[key] = value
|
||||
if not in_place:
|
||||
return transposed_image
|
||||
elif not in_place:
|
||||
return image.copy()
|
||||
return None
|
||||
@ -0,0 +1,286 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# image palette object
|
||||
#
|
||||
# History:
|
||||
# 1996-03-11 fl Rewritten.
|
||||
# 1997-01-03 fl Up and running.
|
||||
# 1997-08-23 fl Added load hack
|
||||
# 2001-04-16 fl Fixed randint shadow bug in random()
|
||||
#
|
||||
# Copyright (c) 1997-2001 by Secret Labs AB
|
||||
# Copyright (c) 1996-1997 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
from collections.abc import Sequence
|
||||
from typing import IO
|
||||
|
||||
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from . import Image
|
||||
|
||||
|
||||
class ImagePalette:
|
||||
"""
|
||||
Color palette for palette mapped images
|
||||
|
||||
:param mode: The mode to use for the palette. See:
|
||||
:ref:`concept-modes`. Defaults to "RGB"
|
||||
:param palette: An optional palette. If given, it must be a bytearray,
|
||||
an array or a list of ints between 0-255. The list must consist of
|
||||
all channels for one color followed by the next color (e.g. RGBRGBRGB).
|
||||
Defaults to an empty palette.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str = "RGB",
|
||||
palette: Sequence[int] | bytes | bytearray | None = None,
|
||||
) -> None:
|
||||
self.mode = mode
|
||||
self.rawmode: str | None = None # if set, palette contains raw data
|
||||
self.palette = palette or bytearray()
|
||||
self.dirty: int | None = None
|
||||
|
||||
@property
|
||||
def palette(self) -> Sequence[int] | bytes | bytearray:
|
||||
return self._palette
|
||||
|
||||
@palette.setter
|
||||
def palette(self, palette: Sequence[int] | bytes | bytearray) -> None:
|
||||
self._colors: dict[tuple[int, ...], int] | None = None
|
||||
self._palette = palette
|
||||
|
||||
@property
|
||||
def colors(self) -> dict[tuple[int, ...], int]:
|
||||
if self._colors is None:
|
||||
mode_len = len(self.mode)
|
||||
self._colors = {}
|
||||
for i in range(0, len(self.palette), mode_len):
|
||||
color = tuple(self.palette[i : i + mode_len])
|
||||
if color in self._colors:
|
||||
continue
|
||||
self._colors[color] = i // mode_len
|
||||
return self._colors
|
||||
|
||||
@colors.setter
|
||||
def colors(self, colors: dict[tuple[int, ...], int]) -> None:
|
||||
self._colors = colors
|
||||
|
||||
def copy(self) -> ImagePalette:
|
||||
new = ImagePalette()
|
||||
|
||||
new.mode = self.mode
|
||||
new.rawmode = self.rawmode
|
||||
if self.palette is not None:
|
||||
new.palette = self.palette[:]
|
||||
new.dirty = self.dirty
|
||||
|
||||
return new
|
||||
|
||||
def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]:
|
||||
"""
|
||||
Get palette contents in format suitable for the low-level
|
||||
``im.putpalette`` primitive.
|
||||
|
||||
.. warning:: This method is experimental.
|
||||
"""
|
||||
if self.rawmode:
|
||||
return self.rawmode, self.palette
|
||||
return self.mode, self.tobytes()
|
||||
|
||||
def tobytes(self) -> bytes:
|
||||
"""Convert palette to bytes.
|
||||
|
||||
.. warning:: This method is experimental.
|
||||
"""
|
||||
if self.rawmode:
|
||||
msg = "palette contains raw palette data"
|
||||
raise ValueError(msg)
|
||||
if isinstance(self.palette, bytes):
|
||||
return self.palette
|
||||
arr = array.array("B", self.palette)
|
||||
return arr.tobytes()
|
||||
|
||||
# Declare tostring as an alias for tobytes
|
||||
tostring = tobytes
|
||||
|
||||
def _new_color_index(
|
||||
self, image: Image.Image | None = None, e: Exception | None = None
|
||||
) -> int:
|
||||
if not isinstance(self.palette, bytearray):
|
||||
self._palette = bytearray(self.palette)
|
||||
index = len(self.palette) // 3
|
||||
special_colors: tuple[int | tuple[int, ...] | None, ...] = ()
|
||||
if image:
|
||||
special_colors = (
|
||||
image.info.get("background"),
|
||||
image.info.get("transparency"),
|
||||
)
|
||||
while index in special_colors:
|
||||
index += 1
|
||||
if index >= 256:
|
||||
if image:
|
||||
# Search for an unused index
|
||||
for i, count in reversed(list(enumerate(image.histogram()))):
|
||||
if count == 0 and i not in special_colors:
|
||||
index = i
|
||||
break
|
||||
if index >= 256:
|
||||
msg = "cannot allocate more than 256 colors"
|
||||
raise ValueError(msg) from e
|
||||
return index
|
||||
|
||||
def getcolor(
|
||||
self,
|
||||
color: tuple[int, ...],
|
||||
image: Image.Image | None = None,
|
||||
) -> int:
|
||||
"""Given an rgb tuple, allocate palette entry.
|
||||
|
||||
.. warning:: This method is experimental.
|
||||
"""
|
||||
if self.rawmode:
|
||||
msg = "palette contains raw palette data"
|
||||
raise ValueError(msg)
|
||||
if isinstance(color, tuple):
|
||||
if self.mode == "RGB":
|
||||
if len(color) == 4:
|
||||
if color[3] != 255:
|
||||
msg = "cannot add non-opaque RGBA color to RGB palette"
|
||||
raise ValueError(msg)
|
||||
color = color[:3]
|
||||
elif self.mode == "RGBA":
|
||||
if len(color) == 3:
|
||||
color += (255,)
|
||||
try:
|
||||
return self.colors[color]
|
||||
except KeyError as e:
|
||||
# allocate new color slot
|
||||
index = self._new_color_index(image, e)
|
||||
assert isinstance(self._palette, bytearray)
|
||||
self.colors[color] = index
|
||||
if index * 3 < len(self.palette):
|
||||
self._palette = (
|
||||
self._palette[: index * 3]
|
||||
+ bytes(color)
|
||||
+ self._palette[index * 3 + 3 :]
|
||||
)
|
||||
else:
|
||||
self._palette += bytes(color)
|
||||
self.dirty = 1
|
||||
return index
|
||||
else:
|
||||
msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable]
|
||||
raise ValueError(msg)
|
||||
|
||||
def save(self, fp: str | IO[str]) -> None:
|
||||
"""Save palette to text file.
|
||||
|
||||
.. warning:: This method is experimental.
|
||||
"""
|
||||
if self.rawmode:
|
||||
msg = "palette contains raw palette data"
|
||||
raise ValueError(msg)
|
||||
if isinstance(fp, str):
|
||||
fp = open(fp, "w")
|
||||
fp.write("# Palette\n")
|
||||
fp.write(f"# Mode: {self.mode}\n")
|
||||
for i in range(256):
|
||||
fp.write(f"{i}")
|
||||
for j in range(i * len(self.mode), (i + 1) * len(self.mode)):
|
||||
try:
|
||||
fp.write(f" {self.palette[j]}")
|
||||
except IndexError:
|
||||
fp.write(" 0")
|
||||
fp.write("\n")
|
||||
fp.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Internal
|
||||
|
||||
|
||||
def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette:
|
||||
palette = ImagePalette()
|
||||
palette.rawmode = rawmode
|
||||
palette.palette = data
|
||||
palette.dirty = 1
|
||||
return palette
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Factories
|
||||
|
||||
|
||||
def make_linear_lut(black: int, white: float) -> list[int]:
|
||||
if black == 0:
|
||||
return [int(white * i // 255) for i in range(256)]
|
||||
|
||||
msg = "unavailable when black is non-zero"
|
||||
raise NotImplementedError(msg) # FIXME
|
||||
|
||||
|
||||
def make_gamma_lut(exp: float) -> list[int]:
|
||||
return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)]
|
||||
|
||||
|
||||
def negative(mode: str = "RGB") -> ImagePalette:
|
||||
palette = list(range(256 * len(mode)))
|
||||
palette.reverse()
|
||||
return ImagePalette(mode, [i // len(mode) for i in palette])
|
||||
|
||||
|
||||
def random(mode: str = "RGB") -> ImagePalette:
|
||||
from random import randint
|
||||
|
||||
palette = [randint(0, 255) for _ in range(256 * len(mode))]
|
||||
return ImagePalette(mode, palette)
|
||||
|
||||
|
||||
def sepia(white: str = "#fff0c0") -> ImagePalette:
|
||||
bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)]
|
||||
return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)])
|
||||
|
||||
|
||||
def wedge(mode: str = "RGB") -> ImagePalette:
|
||||
palette = list(range(256 * len(mode)))
|
||||
return ImagePalette(mode, [i // len(mode) for i in palette])
|
||||
|
||||
|
||||
def load(filename: str) -> tuple[bytes, str]:
|
||||
# FIXME: supports GIMP gradients only
|
||||
|
||||
with open(filename, "rb") as fp:
|
||||
paletteHandlers: list[
|
||||
type[
|
||||
GimpPaletteFile.GimpPaletteFile
|
||||
| GimpGradientFile.GimpGradientFile
|
||||
| PaletteFile.PaletteFile
|
||||
]
|
||||
] = [
|
||||
GimpPaletteFile.GimpPaletteFile,
|
||||
GimpGradientFile.GimpGradientFile,
|
||||
PaletteFile.PaletteFile,
|
||||
]
|
||||
for paletteHandler in paletteHandlers:
|
||||
try:
|
||||
fp.seek(0)
|
||||
lut = paletteHandler(fp).getpalette()
|
||||
if lut:
|
||||
break
|
||||
except (SyntaxError, ValueError):
|
||||
pass
|
||||
else:
|
||||
msg = "cannot load palette"
|
||||
raise OSError(msg)
|
||||
|
||||
return lut # data, rawmode
|
||||
@ -0,0 +1,20 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# path interface
|
||||
#
|
||||
# History:
|
||||
# 1996-11-04 fl Created
|
||||
# 2002-04-14 fl Added documentation stub class
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image
|
||||
|
||||
Path = Image.core.path
|
||||
@ -0,0 +1,219 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# a simple Qt image interface.
|
||||
#
|
||||
# history:
|
||||
# 2006-06-03 fl: created
|
||||
# 2006-06-04 fl: inherit from QImage instead of wrapping it
|
||||
# 2006-06-05 fl: removed toimage helper; move string support to ImageQt
|
||||
# 2013-11-13 fl: add support for Qt5 (aurelien.ballier@cyclonit.com)
|
||||
#
|
||||
# Copyright (c) 2006 by Secret Labs AB
|
||||
# Copyright (c) 2006 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from io import BytesIO
|
||||
|
||||
from . import Image
|
||||
from ._util import is_path
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from . import ImageFile
|
||||
|
||||
QBuffer: type
|
||||
|
||||
qt_version: str | None
|
||||
qt_versions = [
|
||||
["6", "PyQt6"],
|
||||
["side6", "PySide6"],
|
||||
]
|
||||
|
||||
# If a version has already been imported, attempt it first
|
||||
qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True)
|
||||
for version, qt_module in qt_versions:
|
||||
try:
|
||||
qRgba: Callable[[int, int, int, int], int]
|
||||
if qt_module == "PyQt6":
|
||||
from PyQt6.QtCore import QBuffer, QByteArray, QIODevice
|
||||
from PyQt6.QtGui import QImage, QPixmap, qRgba
|
||||
elif qt_module == "PySide6":
|
||||
from PySide6.QtCore import ( # type: ignore[assignment]
|
||||
QBuffer,
|
||||
QByteArray,
|
||||
QIODevice,
|
||||
)
|
||||
from PySide6.QtGui import QImage, QPixmap, qRgba # type: ignore[assignment]
|
||||
except (ImportError, RuntimeError):
|
||||
continue
|
||||
qt_is_installed = True
|
||||
qt_version = version
|
||||
break
|
||||
else:
|
||||
qt_is_installed = False
|
||||
qt_version = None
|
||||
|
||||
|
||||
def rgb(r: int, g: int, b: int, a: int = 255) -> int:
|
||||
"""(Internal) Turns an RGB color into a Qt compatible color integer."""
|
||||
# use qRgb to pack the colors, and then turn the resulting long
|
||||
# into a negative integer with the same bitpattern.
|
||||
return qRgba(r, g, b, a) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile:
|
||||
"""
|
||||
:param im: QImage or PIL ImageQt object
|
||||
"""
|
||||
buffer = QBuffer()
|
||||
qt_openmode: object
|
||||
if qt_version == "6":
|
||||
try:
|
||||
qt_openmode = getattr(QIODevice, "OpenModeFlag")
|
||||
except AttributeError:
|
||||
qt_openmode = getattr(QIODevice, "OpenMode")
|
||||
else:
|
||||
qt_openmode = QIODevice
|
||||
buffer.open(getattr(qt_openmode, "ReadWrite"))
|
||||
# preserve alpha channel with png
|
||||
# otherwise ppm is more friendly with Image.open
|
||||
if im.hasAlphaChannel():
|
||||
im.save(buffer, "png")
|
||||
else:
|
||||
im.save(buffer, "ppm")
|
||||
|
||||
b = BytesIO()
|
||||
b.write(buffer.data())
|
||||
buffer.close()
|
||||
b.seek(0)
|
||||
|
||||
return Image.open(b)
|
||||
|
||||
|
||||
def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile:
|
||||
return fromqimage(im)
|
||||
|
||||
|
||||
def align8to32(bytes: bytes, width: int, mode: str) -> bytes:
|
||||
"""
|
||||
converts each scanline of data from 8 bit to 32 bit aligned
|
||||
"""
|
||||
|
||||
bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode]
|
||||
|
||||
# calculate bytes per line and the extra padding if needed
|
||||
bits_per_line = bits_per_pixel * width
|
||||
full_bytes_per_line, remaining_bits_per_line = divmod(bits_per_line, 8)
|
||||
bytes_per_line = full_bytes_per_line + (1 if remaining_bits_per_line else 0)
|
||||
|
||||
extra_padding = -bytes_per_line % 4
|
||||
|
||||
# already 32 bit aligned by luck
|
||||
if not extra_padding:
|
||||
return bytes
|
||||
|
||||
new_data = [
|
||||
bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding
|
||||
for i in range(len(bytes) // bytes_per_line)
|
||||
]
|
||||
|
||||
return b"".join(new_data)
|
||||
|
||||
|
||||
def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]:
|
||||
data = None
|
||||
colortable = None
|
||||
exclusive_fp = False
|
||||
|
||||
# handle filename, if given instead of image name
|
||||
if hasattr(im, "toUtf8"):
|
||||
# FIXME - is this really the best way to do this?
|
||||
im = str(im.toUtf8(), "utf-8")
|
||||
if is_path(im):
|
||||
im = Image.open(im)
|
||||
exclusive_fp = True
|
||||
assert isinstance(im, Image.Image)
|
||||
|
||||
qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage
|
||||
if im.mode == "1":
|
||||
format = getattr(qt_format, "Format_Mono")
|
||||
elif im.mode == "L":
|
||||
format = getattr(qt_format, "Format_Indexed8")
|
||||
colortable = [rgb(i, i, i) for i in range(256)]
|
||||
elif im.mode == "P":
|
||||
format = getattr(qt_format, "Format_Indexed8")
|
||||
palette = im.getpalette()
|
||||
assert palette is not None
|
||||
colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)]
|
||||
elif im.mode == "RGB":
|
||||
# Populate the 4th channel with 255
|
||||
im = im.convert("RGBA")
|
||||
|
||||
data = im.tobytes("raw", "BGRA")
|
||||
format = getattr(qt_format, "Format_RGB32")
|
||||
elif im.mode == "RGBA":
|
||||
data = im.tobytes("raw", "BGRA")
|
||||
format = getattr(qt_format, "Format_ARGB32")
|
||||
elif im.mode == "I;16":
|
||||
im = im.point(lambda i: i * 256)
|
||||
|
||||
format = getattr(qt_format, "Format_Grayscale16")
|
||||
else:
|
||||
if exclusive_fp:
|
||||
im.close()
|
||||
msg = f"unsupported image mode {repr(im.mode)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
size = im.size
|
||||
__data = data or align8to32(im.tobytes(), size[0], im.mode)
|
||||
if exclusive_fp:
|
||||
im.close()
|
||||
return {"data": __data, "size": size, "format": format, "colortable": colortable}
|
||||
|
||||
|
||||
if qt_is_installed:
|
||||
|
||||
class ImageQt(QImage):
|
||||
def __init__(self, im: Image.Image | str | QByteArray) -> None:
|
||||
"""
|
||||
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
|
||||
class.
|
||||
|
||||
:param im: A PIL Image object, or a file name (given either as
|
||||
Python string or a PyQt string object).
|
||||
"""
|
||||
im_data = _toqclass_helper(im)
|
||||
# must keep a reference, or Qt will crash!
|
||||
# All QImage constructors that take data operate on an existing
|
||||
# buffer, so this buffer has to hang on for the life of the image.
|
||||
# Fixes https://github.com/python-pillow/Pillow/issues/1370
|
||||
self.__data = im_data["data"]
|
||||
super().__init__(
|
||||
self.__data,
|
||||
im_data["size"][0],
|
||||
im_data["size"][1],
|
||||
im_data["format"],
|
||||
)
|
||||
if im_data["colortable"]:
|
||||
self.setColorTable(im_data["colortable"])
|
||||
|
||||
|
||||
def toqimage(im: Image.Image | str | QByteArray) -> ImageQt:
|
||||
return ImageQt(im)
|
||||
|
||||
|
||||
def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap:
|
||||
qimage = toqimage(im)
|
||||
pixmap = getattr(QPixmap, "fromImage")(qimage)
|
||||
if qt_version == "6":
|
||||
pixmap.detach()
|
||||
return pixmap
|
||||
@ -0,0 +1,88 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# sequence support classes
|
||||
#
|
||||
# history:
|
||||
# 1997-02-20 fl Created
|
||||
#
|
||||
# Copyright (c) 1997 by Secret Labs AB.
|
||||
# Copyright (c) 1997 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
##
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class Iterator:
|
||||
"""
|
||||
This class implements an iterator object that can be used to loop
|
||||
over an image sequence.
|
||||
|
||||
You can use the ``[]`` operator to access elements by index. This operator
|
||||
will raise an :py:exc:`IndexError` if you try to access a nonexistent
|
||||
frame.
|
||||
|
||||
:param im: An image object.
|
||||
"""
|
||||
|
||||
def __init__(self, im: Image.Image) -> None:
|
||||
if not hasattr(im, "seek"):
|
||||
msg = "im must have seek method"
|
||||
raise AttributeError(msg)
|
||||
self.im = im
|
||||
self.position = getattr(self.im, "_min_frame", 0)
|
||||
|
||||
def __getitem__(self, ix: int) -> Image.Image:
|
||||
try:
|
||||
self.im.seek(ix)
|
||||
return self.im
|
||||
except EOFError as e:
|
||||
msg = "end of sequence"
|
||||
raise IndexError(msg) from e
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
return self
|
||||
|
||||
def __next__(self) -> Image.Image:
|
||||
try:
|
||||
self.im.seek(self.position)
|
||||
self.position += 1
|
||||
return self.im
|
||||
except EOFError as e:
|
||||
msg = "end of sequence"
|
||||
raise StopIteration(msg) from e
|
||||
|
||||
|
||||
def all_frames(
|
||||
im: Image.Image | list[Image.Image],
|
||||
func: Callable[[Image.Image], Image.Image] | None = None,
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Applies a given function to all frames in an image or a list of images.
|
||||
The frames are returned as a list of separate images.
|
||||
|
||||
:param im: An image, or a list of images.
|
||||
:param func: The function to apply to all of the image frames.
|
||||
:returns: A list of images.
|
||||
"""
|
||||
if not isinstance(im, list):
|
||||
im = [im]
|
||||
|
||||
ims = []
|
||||
for imSequence in im:
|
||||
current = imSequence.tell()
|
||||
|
||||
ims += [im_frame.copy() for im_frame in Iterator(imSequence)]
|
||||
|
||||
imSequence.seek(current)
|
||||
return [func(im) for im in ims] if func else ims
|
||||
@ -0,0 +1,362 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# im.show() drivers
|
||||
#
|
||||
# History:
|
||||
# 2008-04-06 fl Created
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 2008.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from shlex import quote
|
||||
from typing import Any
|
||||
|
||||
from . import Image
|
||||
|
||||
_viewers = []
|
||||
|
||||
|
||||
def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None:
|
||||
"""
|
||||
The :py:func:`register` function is used to register additional viewers::
|
||||
|
||||
from PIL import ImageShow
|
||||
ImageShow.register(MyViewer()) # MyViewer will be used as a last resort
|
||||
ImageShow.register(MySecondViewer(), 0) # MySecondViewer will be prioritised
|
||||
ImageShow.register(ImageShow.XVViewer(), 0) # XVViewer will be prioritised
|
||||
|
||||
:param viewer: The viewer to be registered.
|
||||
:param order:
|
||||
Zero or a negative integer to prepend this viewer to the list,
|
||||
a positive integer to append it.
|
||||
"""
|
||||
if isinstance(viewer, type) and issubclass(viewer, Viewer):
|
||||
viewer = viewer()
|
||||
if order > 0:
|
||||
_viewers.append(viewer)
|
||||
else:
|
||||
_viewers.insert(0, viewer)
|
||||
|
||||
|
||||
def show(image: Image.Image, title: str | None = None, **options: Any) -> bool:
|
||||
r"""
|
||||
Display a given image.
|
||||
|
||||
:param image: An image object.
|
||||
:param title: Optional title. Not all viewers can display the title.
|
||||
:param \**options: Additional viewer options.
|
||||
:returns: ``True`` if a suitable viewer was found, ``False`` otherwise.
|
||||
"""
|
||||
for viewer in _viewers:
|
||||
if viewer.show(image, title=title, **options):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Viewer:
|
||||
"""Base class for viewers."""
|
||||
|
||||
# main api
|
||||
|
||||
def show(self, image: Image.Image, **options: Any) -> int:
|
||||
"""
|
||||
The main function for displaying an image.
|
||||
Converts the given image to the target format and displays it.
|
||||
"""
|
||||
|
||||
if not (
|
||||
image.mode in ("1", "RGBA")
|
||||
or (self.format == "PNG" and image.mode in ("I;16", "LA"))
|
||||
):
|
||||
base = Image.getmodebase(image.mode)
|
||||
if image.mode != base:
|
||||
image = image.convert(base)
|
||||
|
||||
return self.show_image(image, **options)
|
||||
|
||||
# hook methods
|
||||
|
||||
format: str | None = None
|
||||
"""The format to convert the image into."""
|
||||
options: dict[str, Any] = {}
|
||||
"""Additional options used to convert the image."""
|
||||
|
||||
def get_format(self, image: Image.Image) -> str | None:
|
||||
"""Return format name, or ``None`` to save as PGM/PPM."""
|
||||
return self.format
|
||||
|
||||
def get_command(self, file: str, **options: Any) -> str:
|
||||
"""
|
||||
Returns the command used to display the file.
|
||||
Not implemented in the base class.
|
||||
"""
|
||||
msg = "unavailable in base viewer"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def save_image(self, image: Image.Image) -> str:
|
||||
"""Save to temporary file and return filename."""
|
||||
return image._dump(format=self.get_format(image), **self.options)
|
||||
|
||||
def show_image(self, image: Image.Image, **options: Any) -> int:
|
||||
"""Display the given image."""
|
||||
return self.show_file(self.save_image(image), **options)
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
os.system(self.get_command(path, **options)) # nosec
|
||||
return 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
class WindowsViewer(Viewer):
|
||||
"""The default viewer on Windows is the default system application for PNG files."""
|
||||
|
||||
format = "PNG"
|
||||
options = {"compress_level": 1, "save_all": True}
|
||||
|
||||
def get_command(self, file: str, **options: Any) -> str:
|
||||
return (
|
||||
f'start "Pillow" /WAIT "{file}" '
|
||||
"&& ping -n 4 127.0.0.1 >NUL "
|
||||
f'&& del /f "{file}"'
|
||||
)
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
subprocess.Popen(
|
||||
self.get_command(path, **options),
|
||||
shell=True,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW"),
|
||||
) # nosec
|
||||
return 1
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
register(WindowsViewer)
|
||||
|
||||
|
||||
class MacViewer(Viewer):
|
||||
"""The default viewer on macOS using ``Preview.app``."""
|
||||
|
||||
format = "PNG"
|
||||
options = {"compress_level": 1, "save_all": True}
|
||||
|
||||
def get_command(self, file: str, **options: Any) -> str:
|
||||
# on darwin open returns immediately resulting in the temp
|
||||
# file removal while app is opening
|
||||
command = "open -a Preview.app"
|
||||
command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&"
|
||||
return command
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
subprocess.call(["open", "-a", "Preview.app", path])
|
||||
|
||||
pyinstaller = getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
|
||||
executable = (not pyinstaller and sys.executable) or shutil.which("python3")
|
||||
if executable:
|
||||
subprocess.Popen(
|
||||
[
|
||||
executable,
|
||||
"-c",
|
||||
"import os, sys, time; time.sleep(20); os.remove(sys.argv[1])",
|
||||
path,
|
||||
]
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if sys.platform == "darwin":
|
||||
register(MacViewer)
|
||||
|
||||
|
||||
class UnixViewer(abc.ABC, Viewer):
|
||||
format = "PNG"
|
||||
options = {"compress_level": 1, "save_all": True}
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
||||
pass
|
||||
|
||||
def get_command(self, file: str, **options: Any) -> str:
|
||||
command = self.get_command_ex(file, **options)[0]
|
||||
return f"{command} {quote(file)}"
|
||||
|
||||
|
||||
class XDGViewer(UnixViewer):
|
||||
"""
|
||||
The freedesktop.org ``xdg-open`` command.
|
||||
"""
|
||||
|
||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
||||
command = executable = "xdg-open"
|
||||
return command, executable
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
return 1
|
||||
|
||||
|
||||
class DisplayViewer(UnixViewer):
|
||||
"""
|
||||
The ImageMagick ``display`` command.
|
||||
This viewer supports the ``title`` parameter.
|
||||
"""
|
||||
|
||||
def get_command_ex(
|
||||
self, file: str, title: str | None = None, **options: Any
|
||||
) -> tuple[str, str]:
|
||||
command = executable = "display"
|
||||
if title:
|
||||
command += f" -title {quote(title)}"
|
||||
return command, executable
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
args = ["display"]
|
||||
title = options.get("title")
|
||||
if title:
|
||||
args += ["-title", title]
|
||||
args.append(path)
|
||||
|
||||
subprocess.Popen(args)
|
||||
return 1
|
||||
|
||||
|
||||
class GmDisplayViewer(UnixViewer):
|
||||
"""The GraphicsMagick ``gm display`` command."""
|
||||
|
||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
||||
executable = "gm"
|
||||
command = "gm display"
|
||||
return command, executable
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
subprocess.Popen(["gm", "display", path])
|
||||
return 1
|
||||
|
||||
|
||||
class EogViewer(UnixViewer):
|
||||
"""The GNOME Image Viewer ``eog`` command."""
|
||||
|
||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
||||
executable = "eog"
|
||||
command = "eog -n"
|
||||
return command, executable
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
subprocess.Popen(["eog", "-n", path])
|
||||
return 1
|
||||
|
||||
|
||||
class XVViewer(UnixViewer):
|
||||
"""
|
||||
The X Viewer ``xv`` command.
|
||||
This viewer supports the ``title`` parameter.
|
||||
"""
|
||||
|
||||
def get_command_ex(
|
||||
self, file: str, title: str | None = None, **options: Any
|
||||
) -> tuple[str, str]:
|
||||
# note: xv is pretty outdated. most modern systems have
|
||||
# imagemagick's display command instead.
|
||||
command = executable = "xv"
|
||||
if title:
|
||||
command += f" -name {quote(title)}"
|
||||
return command, executable
|
||||
|
||||
def show_file(self, path: str, **options: Any) -> int:
|
||||
"""
|
||||
Display given file.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError
|
||||
args = ["xv"]
|
||||
title = options.get("title")
|
||||
if title:
|
||||
args += ["-name", title]
|
||||
args.append(path)
|
||||
|
||||
subprocess.Popen(args)
|
||||
return 1
|
||||
|
||||
|
||||
if sys.platform not in ("win32", "darwin"): # unixoids
|
||||
if shutil.which("xdg-open"):
|
||||
register(XDGViewer)
|
||||
if shutil.which("display"):
|
||||
register(DisplayViewer)
|
||||
if shutil.which("gm"):
|
||||
register(GmDisplayViewer)
|
||||
if shutil.which("eog"):
|
||||
register(EogViewer)
|
||||
if shutil.which("xv"):
|
||||
register(XVViewer)
|
||||
|
||||
|
||||
class IPythonViewer(Viewer):
|
||||
"""The viewer for IPython frontends."""
|
||||
|
||||
def show_image(self, image: Image.Image, **options: Any) -> int:
|
||||
ipython_display(image)
|
||||
return 1
|
||||
|
||||
|
||||
try:
|
||||
from IPython.display import display as ipython_display
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
register(IPythonViewer)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Syntax: python3 ImageShow.py imagefile [title]")
|
||||
sys.exit()
|
||||
|
||||
with Image.open(sys.argv[1]) as im:
|
||||
print(show(im, *sys.argv[2:]))
|
||||
@ -0,0 +1,167 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# global image statistics
|
||||
#
|
||||
# History:
|
||||
# 1996-04-05 fl Created
|
||||
# 1997-05-21 fl Added mask; added rms, var, stddev attributes
|
||||
# 1997-08-05 fl Added median
|
||||
# 1998-07-05 hk Fixed integer overflow error
|
||||
#
|
||||
# Notes:
|
||||
# This class shows how to implement delayed evaluation of attributes.
|
||||
# To get a certain value, simply access the corresponding attribute.
|
||||
# The __getattr__ dispatcher takes care of the rest.
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996-97.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from functools import cached_property
|
||||
|
||||
from . import Image
|
||||
|
||||
|
||||
class Stat:
|
||||
def __init__(
|
||||
self, image_or_list: Image.Image | list[int], mask: Image.Image | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Calculate statistics for the given image. If a mask is included,
|
||||
only the regions covered by that mask are included in the
|
||||
statistics. You can also pass in a previously calculated histogram.
|
||||
|
||||
:param image: A PIL image, or a precalculated histogram.
|
||||
|
||||
.. note::
|
||||
|
||||
For a PIL image, calculations rely on the
|
||||
:py:meth:`~PIL.Image.Image.histogram` method. The pixel counts are
|
||||
grouped into 256 bins, even if the image has more than 8 bits per
|
||||
channel. So ``I`` and ``F`` mode images have a maximum ``mean``,
|
||||
``median`` and ``rms`` of 255, and cannot have an ``extrema`` maximum
|
||||
of more than 255.
|
||||
|
||||
:param mask: An optional mask.
|
||||
"""
|
||||
if isinstance(image_or_list, Image.Image):
|
||||
self.h = image_or_list.histogram(mask)
|
||||
elif isinstance(image_or_list, list):
|
||||
self.h = image_or_list
|
||||
else:
|
||||
msg = "first argument must be image or list" # type: ignore[unreachable]
|
||||
raise TypeError(msg)
|
||||
self.bands = list(range(len(self.h) // 256))
|
||||
|
||||
@cached_property
|
||||
def extrema(self) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Min/max values for each band in the image.
|
||||
|
||||
.. note::
|
||||
This relies on the :py:meth:`~PIL.Image.Image.histogram` method, and
|
||||
simply returns the low and high bins used. This is correct for
|
||||
images with 8 bits per channel, but fails for other modes such as
|
||||
``I`` or ``F``. Instead, use :py:meth:`~PIL.Image.Image.getextrema` to
|
||||
return per-band extrema for the image. This is more correct and
|
||||
efficient because, for non-8-bit modes, the histogram method uses
|
||||
:py:meth:`~PIL.Image.Image.getextrema` to determine the bins used.
|
||||
"""
|
||||
|
||||
def minmax(histogram: list[int]) -> tuple[int, int]:
|
||||
res_min, res_max = 255, 0
|
||||
for i in range(256):
|
||||
if histogram[i]:
|
||||
res_min = i
|
||||
break
|
||||
for i in range(255, -1, -1):
|
||||
if histogram[i]:
|
||||
res_max = i
|
||||
break
|
||||
return res_min, res_max
|
||||
|
||||
return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)]
|
||||
|
||||
@cached_property
|
||||
def count(self) -> list[int]:
|
||||
"""Total number of pixels for each band in the image."""
|
||||
return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)]
|
||||
|
||||
@cached_property
|
||||
def sum(self) -> list[float]:
|
||||
"""Sum of all pixels for each band in the image."""
|
||||
|
||||
v = []
|
||||
for i in range(0, len(self.h), 256):
|
||||
layer_sum = 0.0
|
||||
for j in range(256):
|
||||
layer_sum += j * self.h[i + j]
|
||||
v.append(layer_sum)
|
||||
return v
|
||||
|
||||
@cached_property
|
||||
def sum2(self) -> list[float]:
|
||||
"""Squared sum of all pixels for each band in the image."""
|
||||
|
||||
v = []
|
||||
for i in range(0, len(self.h), 256):
|
||||
sum2 = 0.0
|
||||
for j in range(256):
|
||||
sum2 += (j**2) * float(self.h[i + j])
|
||||
v.append(sum2)
|
||||
return v
|
||||
|
||||
@cached_property
|
||||
def mean(self) -> list[float]:
|
||||
"""Average (arithmetic mean) pixel level for each band in the image."""
|
||||
return [self.sum[i] / self.count[i] if self.count[i] else 0 for i in self.bands]
|
||||
|
||||
@cached_property
|
||||
def median(self) -> list[int]:
|
||||
"""Median pixel level for each band in the image."""
|
||||
|
||||
v = []
|
||||
for i in self.bands:
|
||||
s = 0
|
||||
half = self.count[i] // 2
|
||||
b = i * 256
|
||||
for j in range(256):
|
||||
s = s + self.h[b + j]
|
||||
if s > half:
|
||||
break
|
||||
v.append(j)
|
||||
return v
|
||||
|
||||
@cached_property
|
||||
def rms(self) -> list[float]:
|
||||
"""RMS (root-mean-square) for each band in the image."""
|
||||
return [
|
||||
math.sqrt(self.sum2[i] / self.count[i]) if self.count[i] else 0
|
||||
for i in self.bands
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def var(self) -> list[float]:
|
||||
"""Variance for each band in the image."""
|
||||
return [
|
||||
(
|
||||
(self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
|
||||
if self.count[i]
|
||||
else 0
|
||||
)
|
||||
for i in self.bands
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def stddev(self) -> list[float]:
|
||||
"""Standard deviation for each band in the image."""
|
||||
return [math.sqrt(self.var[i]) for i in self.bands]
|
||||
|
||||
|
||||
Global = Stat # compatibility
|
||||
@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from . import ImageFont
|
||||
from ._typing import _Ink
|
||||
|
||||
|
||||
class Text:
|
||||
def __init__(
|
||||
self,
|
||||
text: str | bytes,
|
||||
font: (
|
||||
ImageFont.ImageFont
|
||||
| ImageFont.FreeTypeFont
|
||||
| ImageFont.TransposedFont
|
||||
| None
|
||||
) = None,
|
||||
mode: str = "RGB",
|
||||
spacing: float = 4,
|
||||
direction: str | None = None,
|
||||
features: list[str] | None = None,
|
||||
language: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
:param text: String to be drawn.
|
||||
:param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
|
||||
:py:class:`~PIL.ImageFont.FreeTypeFont` instance,
|
||||
:py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
|
||||
``None``, the default font from :py:meth:`.ImageFont.load_default`
|
||||
will be used.
|
||||
:param mode: The image mode this will be used with.
|
||||
:param spacing: The number of pixels between lines.
|
||||
:param direction: Direction of the text. It can be ``"rtl"`` (right to left),
|
||||
``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
|
||||
Requires libraqm.
|
||||
:param features: A list of OpenType font features to be used during text
|
||||
layout. This is usually used to turn on optional font features
|
||||
that are not enabled by default, for example ``"dlig"`` or
|
||||
``"ss01"``, but can be also used to turn off default font
|
||||
features, for example ``"-liga"`` to disable ligatures or
|
||||
``"-kern"`` to disable kerning. To get all supported
|
||||
features, see `OpenType docs`_.
|
||||
Requires libraqm.
|
||||
:param language: Language of the text. Different languages may use
|
||||
different glyph shapes or ligatures. This parameter tells
|
||||
the font which language the text is in, and to apply the
|
||||
correct substitutions as appropriate, if available.
|
||||
It should be a `BCP 47 language code`_.
|
||||
Requires libraqm.
|
||||
"""
|
||||
self.text = text
|
||||
self.font = font or ImageFont.load_default()
|
||||
|
||||
self.mode = mode
|
||||
self.spacing = spacing
|
||||
self.direction = direction
|
||||
self.features = features
|
||||
self.language = language
|
||||
|
||||
self.embedded_color = False
|
||||
|
||||
self.stroke_width: float = 0
|
||||
self.stroke_fill: _Ink | None = None
|
||||
|
||||
def embed_color(self) -> None:
|
||||
"""
|
||||
Use embedded color glyphs (COLR, CBDT, SBIX).
|
||||
"""
|
||||
if self.mode not in ("RGB", "RGBA"):
|
||||
msg = "Embedded color supported only in RGB and RGBA modes"
|
||||
raise ValueError(msg)
|
||||
self.embedded_color = True
|
||||
|
||||
def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
|
||||
"""
|
||||
:param width: The width of the text stroke.
|
||||
:param fill: Color to use for the text stroke when drawing. If not given, will
|
||||
default to the ``fill`` parameter from
|
||||
:py:meth:`.ImageDraw.ImageDraw.text`.
|
||||
"""
|
||||
self.stroke_width = width
|
||||
self.stroke_fill = fill
|
||||
|
||||
def _get_fontmode(self) -> str:
|
||||
if self.mode in ("1", "P", "I", "F"):
|
||||
return "1"
|
||||
elif self.embedded_color:
|
||||
return "RGBA"
|
||||
else:
|
||||
return "L"
|
||||
|
||||
def get_length(self):
|
||||
"""
|
||||
Returns length (in pixels with 1/64 precision) of text.
|
||||
|
||||
This is the amount by which following text should be offset.
|
||||
Text bounding box may extend past the length in some fonts,
|
||||
e.g. when using italics or accents.
|
||||
|
||||
The result is returned as a float; it is a whole number if using basic layout.
|
||||
|
||||
Note that the sum of two lengths may not equal the length of a concatenated
|
||||
string due to kerning. If you need to adjust for kerning, include the following
|
||||
character and subtract its length.
|
||||
|
||||
For example, instead of::
|
||||
|
||||
hello = ImageText.Text("Hello", font).get_length()
|
||||
world = ImageText.Text("World", font).get_length()
|
||||
helloworld = ImageText.Text("HelloWorld", font).get_length()
|
||||
assert hello + world == helloworld
|
||||
|
||||
use::
|
||||
|
||||
hello = (
|
||||
ImageText.Text("HelloW", font).get_length() -
|
||||
ImageText.Text("W", font).get_length()
|
||||
) # adjusted for kerning
|
||||
world = ImageText.Text("World", font).get_length()
|
||||
helloworld = ImageText.Text("HelloWorld", font).get_length()
|
||||
assert hello + world == helloworld
|
||||
|
||||
or disable kerning with (requires libraqm)::
|
||||
|
||||
hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
|
||||
world = ImageText.Text("World", font, features=["-kern"]).get_length()
|
||||
helloworld = ImageText.Text(
|
||||
"HelloWorld", font, features=["-kern"]
|
||||
).get_length()
|
||||
assert hello + world == helloworld
|
||||
|
||||
:return: Either width for horizontal text, or height for vertical text.
|
||||
"""
|
||||
split_character = "\n" if isinstance(self.text, str) else b"\n"
|
||||
if split_character in self.text:
|
||||
msg = "can't measure length of multiline text"
|
||||
raise ValueError(msg)
|
||||
return self.font.getlength(
|
||||
self.text,
|
||||
self._get_fontmode(),
|
||||
self.direction,
|
||||
self.features,
|
||||
self.language,
|
||||
)
|
||||
|
||||
def _split(
|
||||
self, xy: tuple[float, float], anchor: str | None, align: str
|
||||
) -> list[tuple[tuple[float, float], str, str | bytes]]:
|
||||
if anchor is None:
|
||||
anchor = "lt" if self.direction == "ttb" else "la"
|
||||
elif len(anchor) != 2:
|
||||
msg = "anchor must be a 2 character string"
|
||||
raise ValueError(msg)
|
||||
|
||||
lines = (
|
||||
self.text.split("\n")
|
||||
if isinstance(self.text, str)
|
||||
else self.text.split(b"\n")
|
||||
)
|
||||
if len(lines) == 1:
|
||||
return [(xy, anchor, self.text)]
|
||||
|
||||
if anchor[1] in "tb" and self.direction != "ttb":
|
||||
msg = "anchor not supported for multiline text"
|
||||
raise ValueError(msg)
|
||||
|
||||
fontmode = self._get_fontmode()
|
||||
line_spacing = (
|
||||
self.font.getbbox(
|
||||
"A",
|
||||
fontmode,
|
||||
None,
|
||||
self.features,
|
||||
self.language,
|
||||
self.stroke_width,
|
||||
)[3]
|
||||
+ self.stroke_width
|
||||
+ self.spacing
|
||||
)
|
||||
|
||||
top = xy[1]
|
||||
parts = []
|
||||
if self.direction == "ttb":
|
||||
left = xy[0]
|
||||
for line in lines:
|
||||
parts.append(((left, top), anchor, line))
|
||||
left += line_spacing
|
||||
else:
|
||||
widths = []
|
||||
max_width: float = 0
|
||||
for line in lines:
|
||||
line_width = self.font.getlength(
|
||||
line, fontmode, self.direction, self.features, self.language
|
||||
)
|
||||
widths.append(line_width)
|
||||
max_width = max(max_width, line_width)
|
||||
|
||||
if anchor[1] == "m":
|
||||
top -= (len(lines) - 1) * line_spacing / 2.0
|
||||
elif anchor[1] == "d":
|
||||
top -= (len(lines) - 1) * line_spacing
|
||||
|
||||
idx = -1
|
||||
for line in lines:
|
||||
left = xy[0]
|
||||
idx += 1
|
||||
width_difference = max_width - widths[idx]
|
||||
|
||||
# align by align parameter
|
||||
if align in ("left", "justify"):
|
||||
pass
|
||||
elif align == "center":
|
||||
left += width_difference / 2.0
|
||||
elif align == "right":
|
||||
left += width_difference
|
||||
else:
|
||||
msg = 'align must be "left", "center", "right" or "justify"'
|
||||
raise ValueError(msg)
|
||||
|
||||
if (
|
||||
align == "justify"
|
||||
and width_difference != 0
|
||||
and idx != len(lines) - 1
|
||||
):
|
||||
words = (
|
||||
line.split(" ") if isinstance(line, str) else line.split(b" ")
|
||||
)
|
||||
if len(words) > 1:
|
||||
# align left by anchor
|
||||
if anchor[0] == "m":
|
||||
left -= max_width / 2.0
|
||||
elif anchor[0] == "r":
|
||||
left -= max_width
|
||||
|
||||
word_widths = [
|
||||
self.font.getlength(
|
||||
word,
|
||||
fontmode,
|
||||
self.direction,
|
||||
self.features,
|
||||
self.language,
|
||||
)
|
||||
for word in words
|
||||
]
|
||||
word_anchor = "l" + anchor[1]
|
||||
width_difference = max_width - sum(word_widths)
|
||||
i = 0
|
||||
for word in words:
|
||||
parts.append(((left, top), word_anchor, word))
|
||||
left += word_widths[i] + width_difference / (len(words) - 1)
|
||||
i += 1
|
||||
top += line_spacing
|
||||
continue
|
||||
|
||||
# align left by anchor
|
||||
if anchor[0] == "m":
|
||||
left -= width_difference / 2.0
|
||||
elif anchor[0] == "r":
|
||||
left -= width_difference
|
||||
parts.append(((left, top), anchor, line))
|
||||
top += line_spacing
|
||||
|
||||
return parts
|
||||
|
||||
def get_bbox(
|
||||
self,
|
||||
xy: tuple[float, float] = (0, 0),
|
||||
anchor: str | None = None,
|
||||
align: str = "left",
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""
|
||||
Returns bounding box (in pixels) of text.
|
||||
|
||||
Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
|
||||
precision. The bounding box includes extra margins for some fonts, e.g. italics
|
||||
or accents.
|
||||
|
||||
:param xy: The anchor coordinates of the text.
|
||||
:param anchor: The text anchor alignment. Determines the relative location of
|
||||
the anchor to the text. The default alignment is top left,
|
||||
specifically ``la`` for horizontal text and ``lt`` for
|
||||
vertical text. See :ref:`text-anchors` for details.
|
||||
:param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
|
||||
``"justify"`` determines the relative alignment of lines. Use the
|
||||
``anchor`` parameter to specify the alignment to ``xy``.
|
||||
|
||||
:return: ``(left, top, right, bottom)`` bounding box
|
||||
"""
|
||||
bbox: tuple[float, float, float, float] | None = None
|
||||
fontmode = self._get_fontmode()
|
||||
for xy, anchor, line in self._split(xy, anchor, align):
|
||||
bbox_line = self.font.getbbox(
|
||||
line,
|
||||
fontmode,
|
||||
self.direction,
|
||||
self.features,
|
||||
self.language,
|
||||
self.stroke_width,
|
||||
anchor,
|
||||
)
|
||||
bbox_line = (
|
||||
bbox_line[0] + xy[0],
|
||||
bbox_line[1] + xy[1],
|
||||
bbox_line[2] + xy[0],
|
||||
bbox_line[3] + xy[1],
|
||||
)
|
||||
if bbox is None:
|
||||
bbox = bbox_line
|
||||
else:
|
||||
bbox = (
|
||||
min(bbox[0], bbox_line[0]),
|
||||
min(bbox[1], bbox_line[1]),
|
||||
max(bbox[2], bbox_line[2]),
|
||||
max(bbox[3], bbox_line[3]),
|
||||
)
|
||||
|
||||
if bbox is None:
|
||||
return xy[0], xy[1], xy[0], xy[1]
|
||||
return bbox
|
||||
@ -0,0 +1,266 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# a Tk display interface
|
||||
#
|
||||
# History:
|
||||
# 96-04-08 fl Created
|
||||
# 96-09-06 fl Added getimage method
|
||||
# 96-11-01 fl Rewritten, removed image attribute and crop method
|
||||
# 97-05-09 fl Use PyImagingPaste method instead of image type
|
||||
# 97-05-12 fl Minor tweaks to match the IFUNC95 interface
|
||||
# 97-05-17 fl Support the "pilbitmap" booster patch
|
||||
# 97-06-05 fl Added file= and data= argument to image constructors
|
||||
# 98-03-09 fl Added width and height methods to Image classes
|
||||
# 98-07-02 fl Use default mode for "P" images without palette attribute
|
||||
# 98-07-02 fl Explicitly destroy Tkinter image objects
|
||||
# 99-07-24 fl Support multiple Tk interpreters (from Greg Couch)
|
||||
# 99-07-26 fl Automatically hook into Tkinter (if possible)
|
||||
# 99-08-15 fl Hook uses _imagingtk instead of _imaging
|
||||
#
|
||||
# Copyright (c) 1997-1999 by Secret Labs AB
|
||||
# Copyright (c) 1996-1997 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from ._typing import CapsuleType
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Check for Tkinter interface hooks
|
||||
|
||||
|
||||
def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None:
|
||||
source = None
|
||||
if "file" in kw:
|
||||
source = kw.pop("file")
|
||||
elif "data" in kw:
|
||||
source = BytesIO(kw.pop("data"))
|
||||
if not source:
|
||||
return None
|
||||
return Image.open(source)
|
||||
|
||||
|
||||
def _pyimagingtkcall(
|
||||
command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType
|
||||
) -> None:
|
||||
tk = photo.tk
|
||||
try:
|
||||
tk.call(command, photo, repr(ptr))
|
||||
except tkinter.TclError:
|
||||
# activate Tkinter hook
|
||||
# may raise an error if it cannot attach to Tkinter
|
||||
from . import _imagingtk
|
||||
|
||||
_imagingtk.tkinit(tk.interpaddr())
|
||||
tk.call(command, photo, repr(ptr))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# PhotoImage
|
||||
|
||||
|
||||
class PhotoImage:
|
||||
"""
|
||||
A Tkinter-compatible photo image. This can be used
|
||||
everywhere Tkinter expects an image object. If the image is an RGBA
|
||||
image, pixels having alpha 0 are treated as transparent.
|
||||
|
||||
The constructor takes either a PIL image, or a mode and a size.
|
||||
Alternatively, you can use the ``file`` or ``data`` options to initialize
|
||||
the photo image object.
|
||||
|
||||
:param image: Either a PIL image, or a mode string. If a mode string is
|
||||
used, a size must also be given.
|
||||
:param size: If the first argument is a mode string, this defines the size
|
||||
of the image.
|
||||
:keyword file: A filename to load the image from (using
|
||||
``Image.open(file)``).
|
||||
:keyword data: An 8-bit string containing image data (as loaded from an
|
||||
image file).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image: Image.Image | str | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
**kw: Any,
|
||||
) -> None:
|
||||
# Tk compatibility: file or data
|
||||
if image is None:
|
||||
image = _get_image_from_kw(kw)
|
||||
|
||||
if image is None:
|
||||
msg = "Image is required"
|
||||
raise ValueError(msg)
|
||||
elif isinstance(image, str):
|
||||
mode = image
|
||||
image = None
|
||||
|
||||
if size is None:
|
||||
msg = "If first argument is mode, size is required"
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
# got an image instead of a mode
|
||||
mode = image.mode
|
||||
if mode == "P":
|
||||
# palette mapped data
|
||||
image.apply_transparency()
|
||||
image.load()
|
||||
mode = image.palette.mode if image.palette else "RGB"
|
||||
size = image.size
|
||||
kw["width"], kw["height"] = size
|
||||
|
||||
if mode not in ["1", "L", "RGB", "RGBA"]:
|
||||
mode = Image.getmodebase(mode)
|
||||
|
||||
self.__mode = mode
|
||||
self.__size = size
|
||||
self.__photo = tkinter.PhotoImage(**kw)
|
||||
self.tk = self.__photo.tk
|
||||
if image:
|
||||
self.paste(image)
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
name = self.__photo.name
|
||||
except AttributeError:
|
||||
return
|
||||
self.__photo.name = None
|
||||
try:
|
||||
self.__photo.tk.call("image", "delete", name)
|
||||
except Exception:
|
||||
pass # ignore internal errors
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Get the Tkinter photo image identifier. This method is automatically
|
||||
called by Tkinter whenever a PhotoImage object is passed to a Tkinter
|
||||
method.
|
||||
|
||||
:return: A Tkinter photo image identifier (a string).
|
||||
"""
|
||||
return str(self.__photo)
|
||||
|
||||
def width(self) -> int:
|
||||
"""
|
||||
Get the width of the image.
|
||||
|
||||
:return: The width, in pixels.
|
||||
"""
|
||||
return self.__size[0]
|
||||
|
||||
def height(self) -> int:
|
||||
"""
|
||||
Get the height of the image.
|
||||
|
||||
:return: The height, in pixels.
|
||||
"""
|
||||
return self.__size[1]
|
||||
|
||||
def paste(self, im: Image.Image) -> None:
|
||||
"""
|
||||
Paste a PIL image into the photo image. Note that this can
|
||||
be very slow if the photo image is displayed.
|
||||
|
||||
:param im: A PIL image. The size must match the target region. If the
|
||||
mode does not match, the image is converted to the mode of
|
||||
the bitmap image.
|
||||
"""
|
||||
# convert to blittable
|
||||
ptr = im.getim()
|
||||
image = im.im
|
||||
if not image.isblock() or im.mode != self.__mode:
|
||||
block = Image.core.new_block(self.__mode, im.size)
|
||||
image.convert2(block, image) # convert directly between buffers
|
||||
ptr = block.ptr
|
||||
|
||||
_pyimagingtkcall("PyImagingPhoto", self.__photo, ptr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# BitmapImage
|
||||
|
||||
|
||||
class BitmapImage:
|
||||
"""
|
||||
A Tkinter-compatible bitmap image. This can be used everywhere Tkinter
|
||||
expects an image object.
|
||||
|
||||
The given image must have mode "1". Pixels having value 0 are treated as
|
||||
transparent. Options, if any, are passed on to Tkinter. The most commonly
|
||||
used option is ``foreground``, which is used to specify the color for the
|
||||
non-transparent parts. See the Tkinter documentation for information on
|
||||
how to specify colours.
|
||||
|
||||
:param image: A PIL image.
|
||||
"""
|
||||
|
||||
def __init__(self, image: Image.Image | None = None, **kw: Any) -> None:
|
||||
# Tk compatibility: file or data
|
||||
if image is None:
|
||||
image = _get_image_from_kw(kw)
|
||||
|
||||
if image is None:
|
||||
msg = "Image is required"
|
||||
raise ValueError(msg)
|
||||
self.__mode = image.mode
|
||||
self.__size = image.size
|
||||
|
||||
self.__photo = tkinter.BitmapImage(data=image.tobitmap(), **kw)
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
name = self.__photo.name
|
||||
except AttributeError:
|
||||
return
|
||||
self.__photo.name = None
|
||||
try:
|
||||
self.__photo.tk.call("image", "delete", name)
|
||||
except Exception:
|
||||
pass # ignore internal errors
|
||||
|
||||
def width(self) -> int:
|
||||
"""
|
||||
Get the width of the image.
|
||||
|
||||
:return: The width, in pixels.
|
||||
"""
|
||||
return self.__size[0]
|
||||
|
||||
def height(self) -> int:
|
||||
"""
|
||||
Get the height of the image.
|
||||
|
||||
:return: The height, in pixels.
|
||||
"""
|
||||
return self.__size[1]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Get the Tkinter bitmap image identifier. This method is automatically
|
||||
called by Tkinter whenever a BitmapImage object is passed to a Tkinter
|
||||
method.
|
||||
|
||||
:return: A Tkinter bitmap image identifier (a string).
|
||||
"""
|
||||
return str(self.__photo)
|
||||
|
||||
|
||||
def getimage(photo: PhotoImage) -> Image.Image:
|
||||
"""Copies the contents of a PhotoImage to a PIL image memory."""
|
||||
im = Image.new("RGBA", (photo.width(), photo.height()))
|
||||
|
||||
_pyimagingtkcall("PyImagingPhotoGet", photo, im.getim())
|
||||
|
||||
return im
|
||||
@ -0,0 +1,136 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# transform wrappers
|
||||
#
|
||||
# History:
|
||||
# 2002-04-08 fl Created
|
||||
#
|
||||
# Copyright (c) 2002 by Secret Labs AB
|
||||
# Copyright (c) 2002 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from . import Image
|
||||
|
||||
|
||||
class Transform(Image.ImageTransformHandler):
|
||||
"""Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`."""
|
||||
|
||||
method: Image.Transform
|
||||
|
||||
def __init__(self, data: Sequence[Any]) -> None:
|
||||
self.data = data
|
||||
|
||||
def getdata(self) -> tuple[Image.Transform, Sequence[int]]:
|
||||
return self.method, self.data
|
||||
|
||||
def transform(
|
||||
self,
|
||||
size: tuple[int, int],
|
||||
image: Image.Image,
|
||||
**options: Any,
|
||||
) -> Image.Image:
|
||||
"""Perform the transform. Called from :py:meth:`.Image.transform`."""
|
||||
# can be overridden
|
||||
method, data = self.getdata()
|
||||
return image.transform(size, method, data, **options)
|
||||
|
||||
|
||||
class AffineTransform(Transform):
|
||||
"""
|
||||
Define an affine image transform.
|
||||
|
||||
This function takes a 6-tuple (a, b, c, d, e, f) which contain the first
|
||||
two rows from the inverse of an affine transform matrix. For each pixel
|
||||
(x, y) in the output image, the new value is taken from a position (a x +
|
||||
b y + c, d x + e y + f) in the input image, rounded to nearest pixel.
|
||||
|
||||
This function can be used to scale, translate, rotate, and shear the
|
||||
original image.
|
||||
|
||||
See :py:meth:`.Image.transform`
|
||||
|
||||
:param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows
|
||||
from the inverse of an affine transform matrix.
|
||||
"""
|
||||
|
||||
method = Image.Transform.AFFINE
|
||||
|
||||
|
||||
class PerspectiveTransform(Transform):
|
||||
"""
|
||||
Define a perspective image transform.
|
||||
|
||||
This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel
|
||||
(x, y) in the output image, the new value is taken from a position
|
||||
((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in
|
||||
the input image, rounded to nearest pixel.
|
||||
|
||||
This function can be used to scale, translate, rotate, and shear the
|
||||
original image.
|
||||
|
||||
See :py:meth:`.Image.transform`
|
||||
|
||||
:param matrix: An 8-tuple (a, b, c, d, e, f, g, h).
|
||||
"""
|
||||
|
||||
method = Image.Transform.PERSPECTIVE
|
||||
|
||||
|
||||
class ExtentTransform(Transform):
|
||||
"""
|
||||
Define a transform to extract a subregion from an image.
|
||||
|
||||
Maps a rectangle (defined by two corners) from the image to a rectangle of
|
||||
the given size. The resulting image will contain data sampled from between
|
||||
the corners, such that (x0, y0) in the input image will end up at (0,0) in
|
||||
the output image, and (x1, y1) at size.
|
||||
|
||||
This method can be used to crop, stretch, shrink, or mirror an arbitrary
|
||||
rectangle in the current image. It is slightly slower than crop, but about
|
||||
as fast as a corresponding resize operation.
|
||||
|
||||
See :py:meth:`.Image.transform`
|
||||
|
||||
:param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the
|
||||
input image's coordinate system. See :ref:`coordinate-system`.
|
||||
"""
|
||||
|
||||
method = Image.Transform.EXTENT
|
||||
|
||||
|
||||
class QuadTransform(Transform):
|
||||
"""
|
||||
Define a quad image transform.
|
||||
|
||||
Maps a quadrilateral (a region defined by four corners) from the image to a
|
||||
rectangle of the given size.
|
||||
|
||||
See :py:meth:`.Image.transform`
|
||||
|
||||
:param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the
|
||||
upper left, lower left, lower right, and upper right corner of the
|
||||
source quadrilateral.
|
||||
"""
|
||||
|
||||
method = Image.Transform.QUAD
|
||||
|
||||
|
||||
class MeshTransform(Transform):
|
||||
"""
|
||||
Define a mesh image transform. A mesh transform consists of one or more
|
||||
individual quad transforms.
|
||||
|
||||
See :py:meth:`.Image.transform`
|
||||
|
||||
:param data: A list of (bbox, quad) tuples.
|
||||
"""
|
||||
|
||||
method = Image.Transform.MESH
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue