feat: Implement Universal Mirroring with vendorized dependencies and portable bundling

This commit is contained in:
thePR0M3TH3AN committed 2026-03-13 15:17:46 -04:00
1 parent c55ed24f2b
commit ac039b6cf6
1039 files changed
+314776 -14

No files matched your search

+8 -1
View File
@@ -1 +1,8 @@
"""VoxVera command line utilities."""
import sys
import os
from pathlib import Path
# Add the vendor directory to sys.path so we can run without pip install
vendor_dir = Path(__file__).parent / "vendor"
if vendor_dir.exists():
sys.path.insert(0, str(vendor_dir))
+60 -13
View File
@@ -310,6 +310,27 @@ def copy_template(name: str) -> str:
return str(dest)
def bundle_portable(dest_zip: Path):
"""Bundle the entire VoxVera source, vendor libs, and Tor binaries into a ZIP."""
with zipfile.ZipFile(dest_zip, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Include the voxvera/ source directory
for root, dirs, files in os.walk(ROOT):
if "__pycache__" in root or "host" in root:
continue
for file in files:
file_path = Path(root) / file
arcname = file_path.relative_to(ROOT.parent)
zipf.write(file_path, arcname)
# Include the run scripts from project root
for script in ["voxvera-run.sh", "voxvera-install.sh", "README.md", "requirements.txt"]:
script_path = ROOT.parent / script
if script_path.exists():
zipf.write(script_path, script)
print(f"Portable bundle created at {dest_zip}")
def build_assets(
config_path: str, download_path: str | None = None
):
@@ -331,7 +352,6 @@ def build_assets(
code = Path(lp).stem
with open(lp, "r", encoding="utf-8") as lf:
l_data = json.load(lf)
# Only include web and meta to keep HTML size small
all_locales[code] = {
"meta": l_data.get("meta", {}),
"web": l_data.get("web", {})
@@ -339,7 +359,7 @@ def build_assets(
html = html.replace("{{locales}}", json.dumps(all_locales))
# Statically replace {{placeholders}} with config values for Tor JS-disabled compatibility
# Statically replace {{placeholders}} for Tor JS-disabled compatibility
for key, value in data.items():
html = html.replace(f"{{{{{key}}}}}", str(value))
@@ -347,20 +367,21 @@ def build_assets(
dest = ROOT / "host" / folder_name
os.makedirs(dest, exist_ok=True)
# Update download link to point to our viral portable bundle
html = html.replace("download/download.zip", "download/voxvera-portable.zip")
with open(dest / "index.html", "w") as fh:
fh.write(html)
# Create the viral portable bundle
download_dir = dest / "download"
os.makedirs(download_dir, exist_ok=True)
bundle_portable(download_dir / "voxvera-portable.zip")
if download_path:
os.makedirs(dest / "download", exist_ok=True)
shutil.copy(download_path, dest / "download" / "download.zip")
# Only copy config if it's not already in the destination
if Path(config_path) != dest / "config.json":
shutil.copy(config_path, dest / "config.json")
# Copy QR codes only if they exist (may not be generated if URLs not set)
for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]:
qr_src = Path(src_dir) / qr_file
if qr_src.exists():
shutil.copy(qr_src, dest / qr_file)
print(f"Flyer files created under {dest}")
shutil.copy(download_path, download_dir / "extra-content.zip")
# ... rest of the copies ...
def get_tor_ports():
@@ -620,6 +641,29 @@ def import_multiple_sites(source_dir: str = None):
print("\nImport multiple complete.")
def vendorize():
"""Download all dependencies into voxvera/vendor/ for portable use."""
vendor_dir = ROOT / "vendor"
req_file = ROOT.parent / "requirements.txt"
if not req_file.exists():
print("Error: requirements.txt not found.")
return
os.makedirs(vendor_dir, exist_ok=True)
print(f"Vendorizing dependencies into {vendor_dir}...")
try:
subprocess.check_call([
sys.executable, "-m", "pip", "install",
"-r", str(req_file),
"--target", str(vendor_dir),
"--no-compile"
])
print("Vendorization complete.")
except subprocess.CalledProcessError as e:
print(f"Error during vendorization: {e}")
def batch_import_configs():
import glob
@@ -914,6 +958,7 @@ def main(argv=None):
help="Skip interactive prompts and use existing config",
)
sub.add_parser("check", help="Check for required external dependencies")
sub.add_parser("vendorize", help="Download all dependencies into voxvera/vendor/ for portability")
sub.add_parser("manage", help="Manage VoxVera servers interactively")
args = parser.parse_args(argv)
@@ -986,6 +1031,8 @@ def main(argv=None):
serve(config_path)
elif args.command == "check":
check_deps()
elif args.command == "vendorize":
vendorize()
else:
parser.print_help()
+2
View File
@@ -0,0 +1,2 @@
from InquirerPy.resolver import prompt, prompt_async
from InquirerPy.utils import get_style
+15
View File
@@ -0,0 +1,15 @@
"""Module contains base class for prompts.
BaseSimplePrompt ← InputPrompt ← SecretPrompt ...
↑
BaseComplexPrompt
↑
BaseListPrompt ← FuzzyPrompt
↑
ListPrompt ← ExpandPrompt ...
"""
from .complex import BaseComplexPrompt, FakeDocument
from .control import Choice, InquirerPyUIListControl
from .list import BaseListPrompt
from .simple import BaseSimplePrompt
+294
View File
@@ -0,0 +1,294 @@
"""Contains the interface class :class:`.BaseComplexPrompt` for more complex prompts and the mocked document class :class:`.FakeDocument`."""
import shutil
from dataclasses import dataclass
from typing import Any, Callable, List, Optional, Tuple, Union
from prompt_toolkit.application import Application
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.filters.base import Condition, FilterOrBool
from prompt_toolkit.key_binding.key_bindings import KeyHandlerCallable
from prompt_toolkit.keys import Keys
from InquirerPy.base.simple import BaseSimplePrompt
from InquirerPy.enum import INQUIRERPY_KEYBOARD_INTERRUPT
from InquirerPy.utils import (
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
@dataclass
class FakeDocument:
"""A fake `prompt_toolkit` document class.
Work around to allow non-buffer type :class:`~prompt_toolkit.layout.UIControl` to use
:class:`~prompt_toolkit.validation.Validator`.
Args:
text: Content to be validated.
cursor_position: Fake cursor position.
"""
text: str
cursor_position: int = 0
class BaseComplexPrompt(BaseSimplePrompt):
"""A base class to create a more complex prompt that will involve :class:`~prompt_toolkit.application.Application`.
Note:
This class does not create :class:`~prompt_toolkit.layout.Layout` nor :class:`~prompt_toolkit.application.Application`,
it only contains the necessary attributes and helper functions to be consumed.
Note:
Use :class:`~InquirerPy.base.BaseListPrompt` to create a complex list prompt which involves multiple choices. It has
more methods and helper function implemented.
See Also:
:class:`~InquirerPy.base.BaseListPrompt`
:class:`~InquirerPy.prompts.fuzzy.FuzzyPrompt`
"""
def __init__(
self,
message: Union[str, Callable[[InquirerPySessionResult], str]],
style: Optional[InquirerPyStyle] = None,
border: bool = False,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
instruction: str = "",
long_instruction: str = "",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
super().__init__(
message=message,
style=style,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
instruction=instruction,
transformer=transformer,
filter=filter,
invalid_message=invalid_message,
validate=validate,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
self._invalid_message = invalid_message
self._rendered = False
self._invalid = False
self._loading = False
self._application: Application
self._long_instruction = long_instruction
self._border = border
self._height_offset = 2 # prev prompt result + current prompt question
if self._border:
self._height_offset += 2
if self._long_instruction:
self._height_offset += 1
self._validation_window_bottom_offset = 0 if not self._long_instruction else 1
if self._wrap_lines:
self._validation_window_bottom_offset += (
self.extra_long_instruction_line_count
)
self._is_vim_edit = Condition(lambda: self._editing_mode == EditingMode.VI)
self._is_invalid = Condition(lambda: self._invalid)
self._is_displaying_long_instruction = Condition(
lambda: self._long_instruction != ""
)
def _redraw(self) -> None:
"""Redraw the application UI."""
self._application.invalidate()
def register_kb(
self, *keys: Union[Keys, str], filter: FilterOrBool = True
) -> Callable[[KeyHandlerCallable], KeyHandlerCallable]:
"""Decorate keybinding registration function.
Ensure that the `invalid` state is cleared on next keybinding entered.
"""
kb_dec = super().register_kb(*keys, filter=filter)
def decorator(func: KeyHandlerCallable) -> KeyHandlerCallable:
@kb_dec
def executable(event):
if self._invalid:
self._invalid = False
func(event)
return executable
return decorator
def _exception_handler(self, _, context) -> None:
"""Set exception handler for the event loop.
Skip the question and raise exception.
Args:
loop: Current event loop.
context: Exception context.
"""
self._status["answered"] = True
self._status["result"] = INQUIRERPY_KEYBOARD_INTERRUPT
self._status["skipped"] = True
self._application.exit(exception=context["exception"])
def _after_render(self, app: Optional[Application]) -> None:
"""Run after the :class:`~prompt_toolkit.application.Application` is rendered/updated.
Since this function is fired up on each render, adding a check on `self._rendered` to
process logics that should only run once.
Set event loop exception handler here, since its guaranteed that the event loop is running
in `_after_render`.
"""
if not self._rendered:
self._rendered = True
self._keybinding_factory()
self._on_rendered(app)
def _set_error(self, message: str) -> None:
"""Set error message and set invalid state.
Args:
message: Error message to display.
"""
self._invalid_message = message
self._invalid = True
def _get_error_message(self) -> List[Tuple[str, str]]:
"""Obtain the error message dynamically.
Returns:
FormattedText in list of tuple format.
"""
return [
(
"class:validation-toolbar",
self._invalid_message,
)
]
def _on_rendered(self, _: Optional[Application]) -> None:
"""Run once after the UI is rendered. Acts like `ComponentDidMount`."""
pass
def _get_prompt_message(self) -> List[Tuple[str, str]]:
"""Get the prompt message to display.
Returns:
Formatted text in list of tuple format.
"""
pre_answer = (
"class:instruction",
" %s " % self.instruction if self.instruction else " ",
)
post_answer = ("class:answer", " %s" % self.status["result"])
return super()._get_prompt_message(pre_answer, post_answer)
def _run(self) -> Any:
"""Run the application."""
return self.application.run()
async def _run_async(self) -> None:
"""Run the application asynchronously."""
return await self.application.run_async()
@property
def application(self) -> Application:
"""Get the application.
:class:`.BaseComplexPrompt` requires :attr:`.BaseComplexPrompt._application` to be defined since this class
doesn't implement :class:`~prompt_toolkit.layout.Layout` and :class:`~prompt_toolkit.application.Application`.
Raises:
NotImplementedError: When `self._application` is not defined.
"""
if not self._application:
raise NotImplementedError
return self._application
@application.setter
def application(self, value: Application) -> None:
self._application = value
@property
def height_offset(self) -> int:
"""int: Height offset to apply."""
if not self._wrap_lines:
return self._height_offset
return self.extra_line_count + self._height_offset
@property
def total_message_length(self) -> int:
"""int: Total length of the message."""
total_message_length = 0
if self._qmark:
total_message_length += len(self._qmark)
total_message_length += 1 # Extra space if qmark is present
total_message_length += len(str(self._message))
total_message_length += 1 # Extra space between message and instruction
total_message_length += len(str(self._instruction))
if self._instruction:
total_message_length += 1 # Extra space behind the instruction
return total_message_length
@property
def extra_message_line_count(self) -> int:
"""int: Get the extra lines created caused by line wrapping.
Minus 1 on the totoal message length as we only want the extra line.
24 // 24 will equal to 1 however we only want the value to be 1 when we have 25 char
which will create an extra line.
"""
term_width, _ = shutil.get_terminal_size()
return (self.total_message_length - 1) // term_width
@property
def extra_long_instruction_line_count(self) -> int:
"""int: Get the extra lines created caused by line wrapping.
See Also:
:attr:`.BaseComplexPrompt.extra_message_line_count`
"""
if self._long_instruction:
term_width, _ = shutil.get_terminal_size()
return (len(self._long_instruction) - 1) // term_width
else:
return 0
@property
def extra_line_count(self) -> int:
"""Get the extra lines created caused by line wrapping.
Used mainly to calculate how much additional offset should be applied when getting
the height.
Returns:
Total extra lines created due to line wrapping.
"""
result = 0
# message wrap
result += self.extra_message_line_count
# long instruction wrap
result += self.extra_long_instruction_line_count
return result
+227
View File
@@ -0,0 +1,227 @@
"""Contains the content control class :class:`.InquirerPyUIListControl`."""
from abc import abstractmethod
from dataclasses import asdict, dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple, cast
from prompt_toolkit.layout.controls import FormattedTextControl
from InquirerPy.exceptions import InvalidArgument, RequiredKeyNotFound
from InquirerPy.separator import Separator
from InquirerPy.utils import InquirerPyListChoices, InquirerPySessionResult
__all__ = ["Choice", "InquirerPyUIListControl"]
@dataclass
class Choice:
"""Class to create choices for list type prompts.
A simple dataclass that can be used as an alternate to using :class:`dict`
when working with choices.
Args:
value: The value of the choice when user selects this choice.
name: The value that should be presented to the user prior/after selection of the choice.
This value is optional, if not provided, it will fallback to the string representation of `value`.
enabled: Indicates if the choice should be pre-selected.
This only has effects when the prompt has `multiselect` enabled.
"""
value: Any
name: Optional[str] = None
enabled: bool = False
def __post_init__(self):
"""Assign strinify value to name if not present."""
if self.name is None:
self.name = str(self.value)
class InquirerPyUIListControl(FormattedTextControl):
"""A base class to create :class:`~prompt_toolkit.layout.UIControl` to display list type contents.
Args:
choices(InquirerPyListChoices): List of choices to display as the content.
Can also be a callable or async callable that returns a list of choices.
default: Default value, this will affect the cursor position.
multiselect: Indicate if the current prompt has `multiselect` enabled.
session_result: Current session result.
"""
def __init__(
self,
choices: InquirerPyListChoices,
default: Any = None,
multiselect: bool = False,
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
self._session_result = session_result or {}
self._selected_choice_index: int = 0
self._choice_func = None
self._multiselect = multiselect
self._default = (
default
if not isinstance(default, Callable)
else cast(Callable, default)(self._session_result)
)
self._raw_choices = (
choices
if not isinstance(choices, Callable)
else cast(Callable, choices)(self._session_result)
)
self._choices = self._get_choices(self._raw_choices, self._default)
self._safety_check()
self._format_choices()
super().__init__(self._get_formatted_choices)
def _get_choices(self, choices: List[Any], default: Any) -> List[Dict[str, Any]]:
"""Process the raw user input choices and format it into dictionary.
Args:
choices: List of chices to display.
default: Default value, this will affect the :attr:`.InquirerPyUIListControl.selected_choice_index`
Returns:
List of choices.
Raises:
RequiredKeyNotFound: When the provided choice is missing the `name` or `value` key.
"""
processed_choices: List[Dict[str, Any]] = []
try:
for index, choice in enumerate(choices, start=0):
if isinstance(choice, dict):
if choice["value"] == default:
self.selected_choice_index = index
processed_choices.append(
{
"name": str(choice["name"]),
"value": choice["value"],
"enabled": choice.get("enabled", False)
if self._multiselect
else False,
}
)
elif isinstance(choice, Separator):
if self.selected_choice_index == index:
self.selected_choice_index = (
self.selected_choice_index + 1
) % len(choices)
processed_choices.append(
{"name": str(choice), "value": choice, "enabled": False}
)
elif isinstance(choice, Choice):
dict_choice = asdict(choice)
if dict_choice["value"] == default:
self.selected_choice_index = index
if not self._multiselect:
dict_choice["enabled"] = False
processed_choices.append(dict_choice)
else:
if choice == default:
self.selected_choice_index = index
processed_choices.append(
{"name": str(choice), "value": choice, "enabled": False}
)
except KeyError:
raise RequiredKeyNotFound(
"dictionary type of choice require a 'name' key and a 'value' key"
)
return processed_choices
@property
def selected_choice_index(self) -> int:
"""int: Current highlighted index."""
return self._selected_choice_index
@selected_choice_index.setter
def selected_choice_index(self, value: int) -> None:
self._selected_choice_index = value
@property
def choices(self) -> List[Dict[str, Any]]:
"""List[Dict[str, Any]]: Get all processed choices."""
return self._choices
@choices.setter
def choices(self, value: List[Dict[str, Any]]) -> None:
self._choices = value
def _safety_check(self) -> None:
"""Validate processed choices.
Check if the choices are empty or if it only contains :class:`~InquirerPy.separator.Separator`.
"""
if not self.choices:
raise InvalidArgument("argument choices cannot be empty")
should_proceed: bool = False
for choice in self.choices:
if not isinstance(choice["value"], Separator):
should_proceed = True
break
if not should_proceed:
raise InvalidArgument(
"argument choices should contain choices other than separator"
)
def _get_formatted_choices(self) -> List[Tuple[str, str]]:
"""Get all choices in formatted text format.
Returns:
List of choices in formatted text form.
"""
display_choices = []
for index, choice in enumerate(self.choices):
if index == self.selected_choice_index:
display_choices += self._get_hover_text(choice)
else:
display_choices += self._get_normal_text(choice)
display_choices.append(("", "\n"))
if display_choices:
display_choices.pop()
return display_choices
def _format_choices(self) -> None:
"""Perform post processing on the choices.
Additional customisation to the choices after :meth:`.InquirerPyUIListControl._get_choices` call.
"""
pass
@abstractmethod
def _get_hover_text(self, choice) -> List[Tuple[str, str]]:
"""Generate the formatted text for hovered choice.
Returns:
Formatted text in list of tuple format.
"""
pass
@abstractmethod
def _get_normal_text(self, choice) -> List[Tuple[str, str]]:
"""Generate the formatted text for non-hovered choices.
Returns:
Formatted text in list of tuple format.
"""
pass
@property
def choice_count(self) -> int:
"""int: Total count of choices."""
return len(self.choices)
@property
def selection(self) -> Dict[str, Any]:
"""Dict[str, Any]: Current selected choice."""
return self.choices[self.selected_choice_index]
@property
def loading(self) -> bool:
"""bool: Indicate if the content control is loading."""
return self._loading
@loading.setter
def loading(self, value: bool) -> None:
self._loading = value
+238
View File
@@ -0,0 +1,238 @@
"""Contains the base class :class:`.BaseListPrompt` which can be used to create a prompt involving choices."""
from abc import abstractmethod
from typing import Any, Callable, List, Optional
from prompt_toolkit.filters.base import Condition
from prompt_toolkit.keys import Keys
from InquirerPy.base.complex import BaseComplexPrompt
from InquirerPy.base.control import InquirerPyUIListControl
from InquirerPy.separator import Separator
from InquirerPy.utils import (
InquirerPyKeybindings,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
class BaseListPrompt(BaseComplexPrompt):
"""A base class to create a complex prompt involving choice selections (i.e. list) using `prompt_toolkit` Application.
Note:
This class does not create :class:`~prompt_toolkit.layout.Layout` nor :class:`~prompt_toolkit.application.Application`,
it only contains the necessary attributes and helper functions to be consumed.
See Also:
:class:`~InquirerPy.prompts.list.ListPrompt`
:class:`~InquirerPy.prompts.fuzzy.FuzzyPrompt`
"""
def __init__(
self,
message: InquirerPyMessage,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
instruction: str = "",
long_instruction: str = "",
border: bool = False,
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
multiselect: bool = False,
keybindings: Optional[InquirerPyKeybindings] = None,
cycle: bool = True,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
super().__init__(
message=message,
style=style,
border=border,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
transformer=transformer,
filter=filter,
invalid_message=invalid_message,
validate=validate,
instruction=instruction,
long_instruction=long_instruction,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
self._content_control: InquirerPyUIListControl
self._multiselect = multiselect
self._is_multiselect = Condition(lambda: self._multiselect)
self._cycle = cycle
if not keybindings:
keybindings = {}
self.kb_maps = {
"down": [
{"key": "down"},
{"key": "c-n", "filter": ~self._is_vim_edit},
{"key": "j", "filter": self._is_vim_edit},
],
"up": [
{"key": "up"},
{"key": "c-p", "filter": ~self._is_vim_edit},
{"key": "k", "filter": self._is_vim_edit},
],
"toggle": [
{"key": "space"},
],
"toggle-down": [
{"key": Keys.Tab},
],
"toggle-up": [
{"key": Keys.BackTab},
],
"toggle-all": [
{"key": "alt-r"},
{"key": "c-r"},
],
"toggle-all-true": [
{"key": "alt-a"},
{"key": "c-a"},
],
"toggle-all-false": [],
**keybindings,
}
self.kb_func_lookup = {
"down": [{"func": self._handle_down}],
"up": [{"func": self._handle_up}],
"toggle": [{"func": self._handle_toggle_choice}],
"toggle-down": [
{"func": self._handle_toggle_choice},
{"func": self._handle_down},
],
"toggle-up": [
{"func": self._handle_toggle_choice},
{"func": self._handle_up},
],
"toggle-all": [{"func": self._handle_toggle_all}],
"toggle-all-true": [{"func": self._handle_toggle_all, "args": [True]}],
"toggle-all-false": [{"func": self._handle_toggle_all, "args": [False]}],
}
@property
def content_control(self) -> InquirerPyUIListControl:
"""Get the content controller object.
Needs to be an instance of :class:`~InquirerPy.base.control.InquirerPyUIListControl`.
Each :class:`.BaseComplexPrompt` requires a `content_control` to display custom
contents for the prompt.
Raises:
NotImplementedError: When `self._content_control` is not found.
"""
if not self._content_control:
raise NotImplementedError
return self._content_control
@content_control.setter
def content_control(self, value: InquirerPyUIListControl) -> None:
self._content_control = value
@property
def result_name(self) -> Any:
"""Get the result value that should be printed to the terminal.
In multiselect scenario, return result as a list.
"""
if self._multiselect:
return [choice["name"] for choice in self.selected_choices]
else:
try:
return self.content_control.selection["name"]
except IndexError:
return ""
@property
def result_value(self) -> Any:
"""Get the result value that should return to the user.
In multiselect scenario, return result as a list.
"""
if self._multiselect:
return [choice["value"] for choice in self.selected_choices]
else:
try:
return self.content_control.selection["value"]
except IndexError:
return ""
@property
def selected_choices(self) -> List[Any]:
"""List[Any]: Get all user selected choices."""
def filter_choice(choice):
return not isinstance(choice, Separator) and choice["enabled"]
return list(filter(filter_choice, self.content_control.choices))
def _handle_down(self, _) -> bool:
"""Handle event when user attempts to move down.
Returns:
Boolean indicating if the action hits the cap.
"""
if self._cycle:
self.content_control.selected_choice_index = (
self.content_control.selected_choice_index + 1
) % self.content_control.choice_count
return False
else:
self.content_control.selected_choice_index += 1
if (
self.content_control.selected_choice_index
>= self.content_control.choice_count
):
self.content_control.selected_choice_index = (
self.content_control.choice_count - 1
)
return True
return False
def _handle_up(self, _) -> bool:
"""Handle event when user attempts to move up.
Returns:
Boolean indicating if the action hits the cap.
"""
if self._cycle:
self.content_control.selected_choice_index = (
self.content_control.selected_choice_index - 1
) % self.content_control.choice_count
return False
else:
self.content_control.selected_choice_index -= 1
if self.content_control.selected_choice_index < 0:
self.content_control.selected_choice_index = 0
return True
return False
@abstractmethod
def _handle_toggle_choice(self, event) -> None:
"""Handle event when user attempting to toggle the state of the chocie."""
pass
@abstractmethod
def _handle_toggle_all(self, event, value: bool) -> None:
"""Handle event when user attempting to alter the state of all choices."""
pass
+378
View File
@@ -0,0 +1,378 @@
"""Contains the base class :class:`.BaseSimplePrompt`."""
import os
import re
from abc import ABC, abstractmethod
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
cast,
)
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.filters.base import Condition, FilterOrBool
from prompt_toolkit.key_binding.key_bindings import KeyBindings, KeyHandlerCallable
from prompt_toolkit.keys import Keys
from prompt_toolkit.styles.style import Style
from prompt_toolkit.validation import Validator
from InquirerPy.enum import INQUIRERPY_KEYBOARD_INTERRUPT
from InquirerPy.exceptions import RequiredKeyNotFound
from InquirerPy.utils import (
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
get_style,
)
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
class BaseSimplePrompt(ABC):
"""The base class to create a simple terminal input prompt.
Note:
No actual :class:`~prompt_toolkit.application.Application` is created by this class.
This class only creates some common interface and attributes that can be easily used
by `prompt_toolkit`.
To have a functional prompt, you'll at least have to implement the :meth:`.BaseSimplePrompt._run`
and :meth:`.BaseSimplePrompt._get_prompt_message`.
See Also:
:class:`~InquirerPy.prompts.input.InputPrompt`
"""
def __init__(
self,
message: InquirerPyMessage,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
instruction: str = "",
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
default: Any = "",
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
self._mandatory = mandatory
self._mandatory_message = mandatory_message
self._result = session_result or {}
self._message = (
message
if not isinstance(message, Callable)
else cast(Callable, message)(self._result)
)
self._instruction = instruction
self._default = (
default if not isinstance(default, Callable) else default(self._result)
)
self._style = Style.from_dict(style.dict if style else get_style().dict)
self._qmark = qmark
self._amark = amark
self._status = {"answered": False, "result": None, "skipped": False}
self._kb = KeyBindings()
self._lexer = "class:input"
self._transformer = transformer
self._filter = filter
self._wrap_lines = wrap_lines
self._editing_mode = (
EditingMode.VI
if vi_mode or bool(os.getenv("INQUIRERPY_VI_MODE", False))
else EditingMode.EMACS
)
if isinstance(validate, Validator):
self._validator = validate
else:
self._validator = Validator.from_callable(
validate if validate else lambda _: True,
invalid_message,
move_cursor_to_end=True,
)
self._raise_kbi = not os.getenv(
"INQUIRERPY_NO_RAISE_KBI", not raise_keyboard_interrupt
)
self._is_rasing_kbi = Condition(lambda: self._raise_kbi)
self._kb_maps = {
"answer": [{"key": Keys.Enter}],
"interrupt": [
{"key": "c-c", "filter": self._is_rasing_kbi},
{"key": "c-d", "filter": ~self._is_rasing_kbi},
],
"skip": [{"key": "c-z"}, {"key": "c-c", "filter": ~self._is_rasing_kbi}],
}
self._kb_func_lookup = {
"answer": [{"func": self._handle_enter}],
"interrupt": [{"func": self._handle_interrupt}],
"skip": [{"func": self._handle_skip}],
}
def _keybinding_factory(self):
"""Register all keybindings in `self._kb_maps`.
It's required to call this function at the end of prompt constructor if
it inherits from :class:`~InquirerPy.base.simple.BaseSimplePrompt` or
:class:`~InquirerPy.base.complex.BaseComplexPrompt`.
"""
def _factory(keys, filter, action):
if action not in self.kb_func_lookup:
raise RequiredKeyNotFound(f"keybinding action {action} not found")
if not isinstance(keys, list):
keys = [keys]
@self.register_kb(*keys, filter=filter)
def _(event):
for method in self.kb_func_lookup[action]:
method["func"](event, *method.get("args", []))
for key, item in self.kb_maps.items():
if not isinstance(item, list):
item = [item]
for kb in item:
_factory(kb["key"], kb.get("filter", Condition(lambda: True)), key)
@abstractmethod
def _set_error(self, message: str) -> None:
"""Set the error message for the prompt.
Args:
message: Error message to set.
"""
pass
def _handle_skip(self, event: Optional["KeyPressEvent"]) -> None:
"""Handle the event when attempting to skip a prompt.
Skip the prompt if the `_mandatory` field is False, otherwise
show an error message that the prompt cannot be skipped.
"""
if not self._mandatory:
self.status["answered"] = True
self.status["skipped"] = True
self.status["result"] = None
if event:
event.app.exit(result=None)
else:
self._set_error(message=self._mandatory_message)
def _handle_interrupt(self, event: Optional["KeyPressEvent"]) -> None:
"""Handle the event when a KeyboardInterrupt signal is sent."""
self.status["answered"] = True
self.status["result"] = INQUIRERPY_KEYBOARD_INTERRUPT
self.status["skipped"] = True
if event:
event.app.exit(result=INQUIRERPY_KEYBOARD_INTERRUPT)
@abstractmethod
def _handle_enter(self, event: Optional["KeyPressEvent"]) -> None:
"""Handle the event when user attempt to answer the question."""
pass
@property
def status(self) -> Dict[str, Any]:
"""Dict[str, Any]: Get current prompt status.
The status contains 3 keys: "answered" and "result".
answered: If the current prompt is answered.
result: The result of the user answer.
skipped: If the prompt is skipped.
"""
return self._status
@status.setter
def status(self, value) -> None:
self._status = value
def register_kb(
self, *keys: Union[Keys, str], filter: FilterOrBool = True, **kwargs
) -> Callable[[KeyHandlerCallable], KeyHandlerCallable]:
"""Keybinding registration decorator.
This decorator wraps around the :meth:`prompt_toolkit.key_binding.KeyBindings.add` with
added feature to process `alt` realted keybindings.
By default, `prompt_toolkit` doesn't process `alt` related keybindings,
it requires `alt-ANY` to `escape` + `ANY`.
Args:
keys: The keys to bind that can trigger the function.
filter: :class:`~prompt_toolkit.filter.Condition` to indicate if this keybinding should be active.
Returns:
A decorator that should be applied to the function thats intended to be active when the keys
are pressed.
Examples:
>>> @self.register_kb("alt-j")
... def test(event):
... pass
"""
alt_pattern = re.compile(r"^alt-(.*)")
def decorator(func: KeyHandlerCallable) -> KeyHandlerCallable:
formatted_keys = []
for key in keys:
match = alt_pattern.match(key)
if match:
formatted_keys.append("escape")
formatted_keys.append(match.group(1))
else:
formatted_keys.append(key)
@self._kb.add(*formatted_keys, filter=filter, **kwargs)
def executable(event) -> None:
func(event)
return executable
return decorator
@abstractmethod
def _get_prompt_message(
self, pre_answer: Tuple[str, str], post_answer: Tuple[str, str]
) -> List[Tuple[str, str]]:
"""Get the question message in formatted text form to display in the prompt.
This function is mainly used to render the question message dynamically based
on the current status (answered or not answered) of the prompt.
Note:
The function requires implementation when inheriting :class:`.BaseSimplePrompt`.
You should call `super()._get_prompt_message(pre_answer, post_answer)` in
the implemented `_get_prompt_message`.
Args:
pre_answer: The message to display before the question is answered.
post_answer: The information to display after the question is answered.
Returns:
Formatted text in list of tuple format.
"""
display_message = []
if self.status["skipped"]:
display_message.append(("class:skipped", self._qmark))
display_message.append(
("class:skipped", "%s%s " % (" " if self._qmark else "", self._message))
)
elif self.status["answered"]:
display_message.append(("class:answermark", self._amark))
display_message.append(
(
"class:answered_question",
"%s%s" % (" " if self._amark else "", self._message),
)
)
display_message.append(
post_answer
if not self._transformer
else (
"class:answer",
" %s" % self._transformer(self.status["result"]),
)
)
else:
display_message.append(("class:questionmark", self._qmark))
display_message.append(
(
"class:question",
"%s%s" % (" " if self._qmark else "", self._message),
)
)
display_message.append(pre_answer)
return display_message
@abstractmethod
def _run(self) -> Any:
"""Abstractmethod to enforce a run function is implemented.
All prompt instance requires a `_run` call to initialise and run an instance of
`PromptSession` or `Application`.
"""
pass
@abstractmethod
async def _run_async(self) -> Any:
"""Abstractmethod to enforce a run function is implemented.
All prompt instance requires a `_run_async` call to initialise and run an instance of
`PromptSession` or `Application`.
"""
pass
def execute(self, raise_keyboard_interrupt: Optional[bool] = None) -> Any:
"""Run the prompt and get the result.
Args:
raise_keyboard_interrupt: **Deprecated**. Set this parameter on the prompt initialisation instead.
Returns:
Value of the user answer. Types varies depending on the prompt.
Raises:
KeyboardInterrupt: When `ctrl-c` is pressed and `raise_keyboard_interrupt` is True.
"""
result = self._run()
if raise_keyboard_interrupt is not None:
self._raise_kbi = not os.getenv(
"INQUIRERPY_NO_RAISE_KBI", not raise_keyboard_interrupt
)
if result == INQUIRERPY_KEYBOARD_INTERRUPT:
raise KeyboardInterrupt
if not self._filter:
return result
return self._filter(result)
async def execute_async(self) -> None:
"""Run the prompt asynchronously and get the result.
Returns:
Value of the user answer. Types varies depending on the prompt.
Raises:
KeyboardInterrupt: When `ctrl-c` is pressed and `raise_keyboard_interrupt` is True.
"""
result = await self._run_async()
if result == INQUIRERPY_KEYBOARD_INTERRUPT:
raise KeyboardInterrupt
if not self._filter:
return result
return self._filter(result)
@property
def instruction(self) -> str:
"""str: Instruction to display next to question."""
return self._instruction
@property
def kb_maps(self) -> Dict[str, Any]:
"""Dict[str, Any]: Keybinding mappings."""
return self._kb_maps
@kb_maps.setter
def kb_maps(self, value: Dict[str, Any]) -> None:
self._kb_maps = {**self._kb_maps, **value}
@property
def kb_func_lookup(self) -> Dict[str, Any]:
"""Dict[str, Any]: Keybinding function lookup mappings.."""
return self._kb_func_lookup
@kb_func_lookup.setter
def kb_func_lookup(self, value: Dict[str, Any]) -> None:
self._kb_func_lookup = {**self._kb_func_lookup, **value}
+1
View File
@@ -0,0 +1 @@
from .spinner import SpinnerWindow
+38
View File
@@ -0,0 +1,38 @@
"""Module contains :class:`.InstructionWindow` which can be used to display long instructions."""
from typing import TYPE_CHECKING
from prompt_toolkit.layout.containers import ConditionalContainer, Window
from prompt_toolkit.layout.controls import FormattedTextControl
if TYPE_CHECKING:
from prompt_toolkit.filters.base import FilterOrBool
from prompt_toolkit.formatted_text.base import AnyFormattedText
class InstructionWindow(ConditionalContainer):
"""Conditional `prompt_toolkit` :class:`~prompt_toolkit.layout.Window` that displays long instructions.
Args:
message: Long instructions to display.
filter: Condition to display the instruction window.
"""
def __init__(self, message: str, filter: "FilterOrBool", **kwargs) -> None:
self._message = message
super().__init__(
Window(
FormattedTextControl(text=self._get_message),
dont_extend_height=True,
**kwargs
),
filter=filter,
)
def _get_message(self) -> "AnyFormattedText":
"""Get long instruction to display.
Returns:
FormattedText in list of tuple format.
"""
return [("class:long_instruction", self._message)]
+42
View File
@@ -0,0 +1,42 @@
"""Module contains the main message window :class:`~prompt_toolkit.container.Container`."""
from typing import TYPE_CHECKING
from prompt_toolkit.layout.containers import ConditionalContainer, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.dimension import LayoutDimension
if TYPE_CHECKING:
from prompt_toolkit.filters.base import FilterOrBool
from prompt_toolkit.formatted_text.base import AnyFormattedText
class MessageWindow(ConditionalContainer):
"""Main window to display question to the user.
Args:
message: The message to display in the terminal.
filter: Condition that this message window should be displayed.
Use a loading condition to only display this window while its not loading.
wrap_lines: Enable line wrapping if the message is too long.
show_cursor: Display cursor.
"""
def __init__(
self,
message: "AnyFormattedText",
filter: "FilterOrBool",
wrap_lines: bool = True,
show_cursor: bool = True,
**kwargs
) -> None:
super().__init__(
content=Window(
height=LayoutDimension.exact(1) if not wrap_lines else None,
content=FormattedTextControl(message, show_cursor=show_cursor),
wrap_lines=wrap_lines,
dont_extend_height=True,
**kwargs
),
filter=filter,
)
+108
View File
@@ -0,0 +1,108 @@
"""Module contains spinner related resources.
Note:
The spinner is not a standalone spinner to run in the terminal
but rather a `prompt_toolkit` :class:`~prompt_toolkit.layout.Window` that displays a spinner.
Use library such as `yaspin <https://github.com/pavdmyt/yaspin>`_ if you need a plain spinner.
"""
import asyncio
from typing import TYPE_CHECKING, Callable, List, NamedTuple, Optional, Tuple, Union
from prompt_toolkit.filters.utils import to_filter
from prompt_toolkit.layout.containers import ConditionalContainer, Window
from prompt_toolkit.layout.controls import FormattedTextControl
if TYPE_CHECKING:
from prompt_toolkit.filters.base import Filter
__all__ = ["SPINNERS", "SpinnerWindow"]
class SPINNERS(NamedTuple):
"""Presets of spinner patterns.
See Also:
https://github.com/pavdmyt/yaspin/blob/master/yaspin/data/spinners.json
This only contains some basic ones thats ready to use. For more patterns, checkout the
URL above.
Examples:
>>> from InquirerPy import inquirer
>>> from InquirerPy.spinner import SPINNERS
>>> inquirer.select(message="", choices=lambda _: [1, 2, 3], spinner_pattern=SPINNERS.dots)
"""
dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
dots2 = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
line = ["-", "\\", "|", "/"]
line2 = ["⠂", "-", "–", "—", "–", "-"]
pipe = ["┤", "┘", "┴", "└", "├", "┌", "┬", "┐"]
star = ["✶", "✸", "✹", "✺", "✹", "✷"]
star2 = ["+", "x", "*"]
flip = ["_", "_", "_", "-", "`", "`", "'", "´", "-", "_", "_", "_"]
hamburger = ["☱", "☲", "☴"]
grow_vertical = ["▁", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃"]
grow_horizontal = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "▊", "▋", "▌", "▍", "▎"]
box_bounce = ["▖", "▘", "▝", "▗"]
triangle = ["◢", "◣", "◤", "◥"]
arc = ["◜", "◠", "◝", "◞", "◡", "◟"]
circle = ["◡", "⊙", "◠"]
class SpinnerWindow(ConditionalContainer):
"""Conditional `prompt_toolkit` :class:`~prompt_toolkit.layout.Window` that displays a spinner.
Args:
loading: A :class:`~prompt_toolkit.filters.Condition` to indicate if the spinner should be visible.
redraw: A redraw function (i.e. :meth:`~prompt_toolkit.application.Application.invalidate`) to refresh the UI.
pattern: List of pattern to display as the spinner.
delay: Spinner refresh frequency.
text: Loading text to display.
"""
def __init__(
self,
loading: "Filter",
redraw: Callable[[], None],
pattern: Optional[Union[List[str], SPINNERS]] = None,
delay: float = 0.1,
text: str = "",
) -> None:
self._loading = to_filter(loading)
self._spinning = False
self._redraw = redraw
self._pattern = pattern or SPINNERS.line
self._char = self._pattern[0]
self._delay = delay
self._text = text or "Loading ..."
super().__init__(
content=Window(content=FormattedTextControl(text=self._get_text)),
filter=self._loading,
)
def _get_text(self) -> List[Tuple[str, str]]:
"""Dynamically get the text for the :class:`~prompt_toolkit.layout.Window`.
Returns:
Formatted text.
"""
return [
("class:spinner_pattern", self._char),
("", " "),
("class:spinner_text", self._text),
]
async def start(self) -> None:
"""Start the spinner."""
if self._spinning:
return
self._spinning = True
while self._loading():
for char in self._pattern:
await asyncio.sleep(self._delay)
self._char = char
self._redraw()
self._spinning = False
+60
View File
@@ -0,0 +1,60 @@
"""Module contains :class:`.ValidationWindow` which can be used to display error."""
from typing import Optional
from prompt_toolkit.filters.base import FilterOrBool
from prompt_toolkit.formatted_text.base import AnyFormattedText
from prompt_toolkit.layout.containers import ConditionalContainer, Float, Window
from prompt_toolkit.layout.controls import FormattedTextControl
class ValidationWindow(ConditionalContainer):
"""Conditional `prompt_toolkit` :class:`~prompt_toolkit.layout.Window` that displays error.
Args:
invalid_message: Error message to display when error occured.
filter: Condition to display the error window.
"""
def __init__(
self, invalid_message: AnyFormattedText, filter: FilterOrBool, **kwargs
) -> None:
super().__init__(
Window(
FormattedTextControl(invalid_message), dont_extend_height=True, **kwargs
),
filter=filter,
)
class ValidationFloat(Float):
""":class:`~prompt_toolkit.layout.Float` wrapper around :class:`.ValidationWindow`.
Args:
invalid_message: Error message to display when error occured.
filter: Condition to display the error window.
left: Distance to left.
right: Distance to right.
bottom: Distance to bottom.
top: Distance to top.
"""
def __init__(
self,
invalid_message: AnyFormattedText,
filter: FilterOrBool,
left: Optional[int] = None,
right: Optional[int] = None,
bottom: Optional[int] = None,
top: Optional[int] = None,
**kwargs
) -> None:
super().__init__(
content=ValidationWindow(
invalid_message=invalid_message, filter=filter, **kwargs
),
left=left,
right=right,
bottom=bottom,
top=top,
)
+7
View File
@@ -0,0 +1,7 @@
"""Module contains common constants."""
INQUIRERPY_KEYBOARD_INTERRUPT: str = "INQUIRERPY_KEYBOARD_INTERRUPT"
INQUIRERPY_POINTER_SEQUENCE: str = "\u276f"
INQUIRERPY_FILL_CIRCLE_SEQUENCE: str = "\u25c9"
INQUIRERPY_EMPTY_CIRCLE_SEQUENCE: str = "\u25cb"
INQUIRERPY_QMARK_SEQUENCE: str = "\u003f"
+25
View File
@@ -0,0 +1,25 @@
"""Module contains exceptions that will be raised by `InquirerPy`."""
class InvalidArgument(Exception):
"""Provided argument is invalid.
Args:
message: Exception message.
"""
def __init__(self, message: str = "invalid argument"):
self._message = message
super().__init__(self._message)
class RequiredKeyNotFound(Exception):
"""Missing required keys in dictionary.
Args:
message: Exception message.
"""
def __init__(self, message="required key not found"):
self.message = message
super().__init__(self.message)
+17
View File
@@ -0,0 +1,17 @@
"""Servers as another entry point for `InquirerPy`.
See Also:
:ref:`index:Alternate Syntax`.
`inquirer` directly interact with individual prompt classes. It’s more flexible, easier to customise and also provides IDE type hintings/completions.
"""
from InquirerPy.prompts import CheckboxPrompt as checkbox
from InquirerPy.prompts import ConfirmPrompt as confirm
from InquirerPy.prompts import ExpandPrompt as expand
from InquirerPy.prompts import FilePathPrompt as filepath
from InquirerPy.prompts import FuzzyPrompt as fuzzy
from InquirerPy.prompts import InputPrompt as text
from InquirerPy.prompts import ListPrompt as select
from InquirerPy.prompts import NumberPrompt as number
from InquirerPy.prompts import RawlistPrompt as rawlist
from InquirerPy.prompts import SecretPrompt as secret
+11
View File
@@ -0,0 +1,11 @@
"""Module contains import of all prompts classes."""
from InquirerPy.prompts.checkbox import CheckboxPrompt
from InquirerPy.prompts.confirm import ConfirmPrompt
from InquirerPy.prompts.expand import ExpandPrompt
from InquirerPy.prompts.filepath import FilePathPrompt
from InquirerPy.prompts.fuzzy import FuzzyPrompt
from InquirerPy.prompts.input import InputPrompt
from InquirerPy.prompts.list import ListPrompt
from InquirerPy.prompts.number import NumberPrompt
from InquirerPy.prompts.rawlist import RawlistPrompt
from InquirerPy.prompts.secret import SecretPrompt
+244
View File
@@ -0,0 +1,244 @@
"""Module contains the class to create a checkbox prompt."""
from typing import Any, Callable, List, Optional, Tuple, Union
from prompt_toolkit.validation import ValidationError
from InquirerPy.base import FakeDocument, InquirerPyUIListControl
from InquirerPy.enum import (
INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
INQUIRERPY_FILL_CIRCLE_SEQUENCE,
INQUIRERPY_POINTER_SEQUENCE,
)
from InquirerPy.prompts.list import ListPrompt
from InquirerPy.separator import Separator
from InquirerPy.utils import (
InquirerPyKeybindings,
InquirerPyListChoices,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
__all__ = ["CheckboxPrompt"]
class InquirerPyCheckboxControl(InquirerPyUIListControl):
"""An :class:`~prompt_toolkit.layout.UIControl` class that displays a list of choices.
Reference the parameter definition in :class:`.CheckboxPrompt`.
"""
def __init__(
self,
choices: InquirerPyListChoices,
default: Any,
pointer: str,
enabled_symbol: str,
disabled_symbol: str,
session_result: Optional[InquirerPySessionResult],
) -> None:
"""Initialise required attributes and call base class."""
self._pointer = pointer
self._enabled_symbol = enabled_symbol
self._disabled_symbol = disabled_symbol
super().__init__(
choices=choices,
default=default,
session_result=session_result,
multiselect=True,
)
def _format_choices(self) -> None:
pass
def _get_hover_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("class:pointer", self._pointer))
if self._pointer:
display_choices.append(("", " "))
if not isinstance(choice["value"], Separator):
display_choices.append(
(
"class:checkbox",
self._enabled_symbol
if choice["enabled"]
else self._disabled_symbol,
)
)
if self._enabled_symbol and self._disabled_symbol:
display_choices.append(("", " "))
display_choices.append(("[SetCursorPosition]", ""))
display_choices.append(("class:pointer", choice["name"]))
return display_choices
def _get_normal_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("", len(self._pointer) * " "))
if self._pointer:
display_choices.append(("", " "))
if not isinstance(choice["value"], Separator):
display_choices.append(
(
"class:checkbox",
self._enabled_symbol
if choice["enabled"]
else self._disabled_symbol,
)
)
if self._enabled_symbol and self._disabled_symbol:
display_choices.append(("", " "))
display_choices.append(("", choice["name"]))
else:
display_choices.append(("class:separator", choice["name"]))
return display_choices
class CheckboxPrompt(ListPrompt):
"""Create a prompt which displays a list of checkboxes to toggle.
A wrapper class around :class:`~prompt_toolkit.application.Application`.
User can toggle on/off on each checkbox.
Works very similar to :class:`~InquirerPy.prompts.list.ListPrompt` with `multiselect` enabled,
the main difference is visual/UI and also when not toggling anything, the result will be empty.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
choices: List of choices to display and select.
Refer to :ref:`pages/dynamic:choices` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default value of the prompt.
This will be used to determine which choice is highlighted (current selection),
The default value should be the value of one of the choices.
Refer to :ref:`pages/dynamic:default` documentation for more details.
separator: Separator symbol. Custom symbol that will be used as a separator between the choice index number and the choices.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
pointer: Pointer symbol. Customer symbol that will be used to indicate the current choice selection.
enabled_symbol: Checkbox ticked symbol. Custom symbol which indicate the checkbox is ticked.
disabled_symbol: Checkbox not ticked symbol. Custom symbol which indicate the checkbox is not ticked.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
The main use case for this prompt would be when `multiselect` is True, you can enforce a min/max selection.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
height: Preferred height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
max_height: Max height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
border: Create border around the choice window.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
show_cursor: Display cursor at the end of the prompt.
Set to False to hide the cursor.
cycle: Return to top item if hit bottom during navigation or vice versa.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.checkbox(message="Select:", choices=[1, 2, 3]).execute()
>>> print(result)
[1]
"""
def __init__(
self,
message: InquirerPyMessage,
choices: InquirerPyListChoices,
default: Any = None,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
pointer: str = INQUIRERPY_POINTER_SEQUENCE,
enabled_symbol: str = INQUIRERPY_FILL_CIRCLE_SEQUENCE,
disabled_symbol: str = INQUIRERPY_EMPTY_CIRCLE_SEQUENCE,
border: bool = False,
instruction: str = "",
long_instruction: str = "",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
height: Optional[Union[int, str]] = None,
max_height: Optional[Union[int, str]] = None,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
keybindings: Optional[InquirerPyKeybindings] = None,
show_cursor: bool = True,
cycle: bool = True,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
self.content_control = InquirerPyCheckboxControl(
choices=choices,
default=default,
pointer=pointer,
enabled_symbol=enabled_symbol,
disabled_symbol=disabled_symbol,
session_result=session_result,
)
super().__init__(
message=message,
choices=choices,
style=style,
border=border,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
instruction=instruction,
long_instruction=long_instruction,
transformer=transformer,
filter=filter,
height=height,
max_height=max_height,
validate=validate,
invalid_message=invalid_message,
multiselect=True,
keybindings=keybindings,
show_cursor=show_cursor,
cycle=cycle,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
def _handle_enter(self, event) -> None:
"""Override this method to force empty array result.
When user does not select anything, exit with empty list.
Args:
event: Keypress event.
"""
try:
fake_document = FakeDocument(self.result_value)
self._validator.validate(fake_document) # type: ignore
except ValidationError:
self._invalid = True
else:
self.status["answered"] = True
self.status["result"] = self.result_name
event.app.exit(result=self.result_value)
+196
View File
@@ -0,0 +1,196 @@
"""Module contains the class to create a confirm prompt."""
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple
from prompt_toolkit.buffer import ValidationState
from prompt_toolkit.keys import Keys
from prompt_toolkit.shortcuts import PromptSession
from prompt_toolkit.validation import ValidationError
from InquirerPy.base import BaseSimplePrompt
from InquirerPy.exceptions import InvalidArgument
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
)
if TYPE_CHECKING:
from prompt_toolkit.input.base import Input
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.output.base import Output
__all__ = ["ConfirmPrompt"]
class ConfirmPrompt(BaseSimplePrompt):
"""Create a prompt that provides 2 options (confirm/deny) and controlled via single keypress.
A wrapper class around :class:`~prompt_toolkit.shortcuts.PromptSession`.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Used for compatibility .
default: Set the default value of the prompt, should be either `True` or `False`.
This affects the value returned when user directly hit `enter` key.
Refer to :ref:`pages/dynamic:default` documentation for more details.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
confirm_letter: Letter used to confirm the prompt. A keybinding will be created for this letter.
Default is `y` and pressing `y` will answer the prompt with value `True`.
reject_letter: Letter used to reject the prompt. A keybinding will be created for this letter.
Default is `n` and pressing `n` will answer the prompt with value `False`.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
input: Used internally and will be removed in future updates.
output: Used internally and will be removed in future updates.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.confirm(message="Confirm?").execute()
>>> print(result)
True
"""
def __init__(
self,
message: InquirerPyMessage,
style: Optional[InquirerPyStyle] = None,
default: InquirerPyDefault = False,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
instruction: str = "",
long_instruction: str = "",
transformer: Optional[Callable[[bool], Any]] = None,
filter: Optional[Callable[[bool], Any]] = None,
keybindings: Optional[InquirerPyKeybindings] = None,
wrap_lines: bool = True,
confirm_letter: str = "y",
reject_letter: str = "n",
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
input: Optional["Input"] = None,
output: Optional["Output"] = None,
) -> None:
vi_mode = False
super().__init__(
message=message,
style=style,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
instruction=instruction,
transformer=transformer,
filter=filter,
default=default,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
if not isinstance(self._default, bool):
raise InvalidArgument(
f"{type(self).__name__} argument default should be type of bool"
)
self._confirm_letter = confirm_letter
self._reject_letter = reject_letter
if not keybindings:
keybindings = {}
self.kb_maps = {
"confirm": [
{"key": self._confirm_letter},
{"key": self._confirm_letter.upper()},
],
"reject": [
{"key": self._reject_letter},
{"key": self._reject_letter.upper()},
],
"any": [{"key": Keys.Any}],
**keybindings,
}
self.kb_func_lookup = {
"confirm": [{"func": self._handle_confirm}],
"reject": [{"func": self._handle_reject}],
"any": [{"func": lambda _: None}],
}
self._keybinding_factory()
self._session = PromptSession(
message=self._get_prompt_message,
key_bindings=self._kb,
style=self._style,
wrap_lines=self._wrap_lines,
bottom_toolbar=[("class:long_instruction", long_instruction)]
if long_instruction
else None,
input=input,
output=output,
)
def _set_error(self, message: str) -> None:
self._session.default_buffer.validation_state = ValidationState.INVALID
self._session.default_buffer.validation_error = ValidationError(message=message)
def _handle_reject(self, event) -> None:
self._session.default_buffer.text = ""
self.status["answered"] = True
self.status["result"] = False
event.app.exit(result=False)
def _handle_confirm(self, event) -> None:
self._session.default_buffer.text = ""
self.status["answered"] = True
self.status["result"] = True
event.app.exit(result=True)
def _handle_enter(self, event: "KeyPressEvent") -> None:
self.status["answered"] = True
self.status["result"] = self._default
event.app.exit(result=self._default)
def _get_prompt_message(self) -> List[Tuple[str, str]]:
"""Get message to display infront of the input buffer.
Returns:
Formatted text in list of tuple format.
"""
if not self.instruction:
pre_answer = (
"class:instruction",
" (%s/%s) " % (self._confirm_letter.upper(), self._reject_letter)
if self._default
else " (%s/%s) " % (self._confirm_letter, self._reject_letter.upper()),
)
else:
pre_answer = ("class:instruction", " %s " % self.instruction)
post_answer = ("class:answer", " Yes" if self.status["result"] else " No")
return super()._get_prompt_message(pre_answer, post_answer)
def _run(self) -> bool:
return self._session.prompt()
async def _run_async(self) -> Any:
return await self._session.prompt_async()
+458
View File
@@ -0,0 +1,458 @@
"""Module contains the class to create an expand prompt."""
from dataclasses import dataclass
from typing import Any, Callable, List, Optional, Tuple, Union
from InquirerPy.base import BaseListPrompt, InquirerPyUIListControl
from InquirerPy.base.control import Choice
from InquirerPy.enum import INQUIRERPY_POINTER_SEQUENCE
from InquirerPy.exceptions import InvalidArgument, RequiredKeyNotFound
from InquirerPy.prompts.list import ListPrompt
from InquirerPy.separator import Separator
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyListChoices,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
__all__ = ["ExpandPrompt", "ExpandHelp", "ExpandChoice"]
@dataclass
class ExpandHelp:
"""Help choice for the :class:`.ExpandPrompt`.
Args:
key: The key to bind to toggle the expansion of the prompt.
message: The help message.
"""
key: str = "h"
message: str = "Help, list all choices"
@dataclass
class ExpandChoice(Choice):
"""Choice class for :class:`.ExpandPrompt`.
See Also:
:class:`~InquirerPy.base.control.Choice`
Args:
value: The value of the choice when user selects this choice.
name: The value that should be presented to the user prior/after selection of the choice.
This value is optional, if not provided, it will fallback to the string representation of `value`.
enabled: Indicates if the choice should be pre-selected.
This only has effects when the prompt has `multiselect` enabled.
key: Char to bind to the choice. Pressing this value will jump to the choice,
If this value is missing, the first char of the `str(value)` will be used as the key.
"""
key: Optional[str] = None
def __post_init__(self):
"""Assign stringify value to name and also create key using the first char of the value if not present."""
super().__post_init__()
if self.key is None:
self.key = str(self.value)[0].lower()
class InquirerPyExpandControl(InquirerPyUIListControl):
"""An :class:`~prompt_toolkit.layout.UIControl` class that displays a list of choices.
Reference the parameter definition in :class:`.ExpandPrompt`.
"""
def __init__(
self,
choices: InquirerPyListChoices,
default: Any,
pointer: str,
separator: str,
expand_help: ExpandHelp,
expand_pointer: str,
marker: str,
session_result: Optional[InquirerPySessionResult],
multiselect: bool,
marker_pl: str,
) -> None:
self._pointer = pointer
self._separator = separator
self._expanded = False
self._expand_pointer = expand_pointer
self._marker = marker
self._marker_pl = marker_pl
self._expand_help = expand_help
super().__init__(
choices=choices,
default=default,
session_result=session_result,
multiselect=multiselect,
)
def _format_choices(self) -> None:
self._key_maps = {}
try:
count = 0
separator_count = 0
for raw_choice, choice in zip(self._raw_choices, self.choices): # type: ignore
if (
not isinstance(raw_choice, dict)
and not isinstance(raw_choice, Separator)
and not isinstance(raw_choice, ExpandChoice)
):
raise InvalidArgument(
"expand prompt argument choices requires each choice to be type of dictionary or Separator or ExpandChoice"
)
if isinstance(raw_choice, Separator):
separator_count += 1
else:
choice["key"] = (
raw_choice.key
if isinstance(raw_choice, ExpandChoice)
else raw_choice["key"]
)
self._key_maps[choice["key"]] = count
count += 1
except KeyError:
raise RequiredKeyNotFound(
"expand prompt choice requires a key 'key' to exists"
)
self.choices.append(
{
"key": self._expand_help.key,
"value": self._expand_help,
"name": self._expand_help.message,
"enabled": False,
}
)
self._key_maps[self._expand_help.key] = len(self.choices) - 1
first_valid_choice_index = 0
while isinstance(self.choices[first_valid_choice_index]["value"], Separator):
first_valid_choice_index += 1
if self.selected_choice_index == first_valid_choice_index:
for index, choice in enumerate(self.choices):
if isinstance(choice["value"], Separator):
continue
if choice["key"] == self._default:
self.selected_choice_index = index
break
def _get_formatted_choices(self) -> List[Tuple[str, str]]:
"""Override this parent class method as expand require visual switch of content.
Two types of mode:
* non expand mode
* expand mode
"""
if self._expanded:
return super()._get_formatted_choices()
else:
display_choices = []
display_choices.append(("class:pointer", self._expand_pointer))
display_choices.append(
("", self.choices[self.selected_choice_index]["name"])
)
return display_choices
def _get_hover_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("class:pointer", self._pointer))
display_choices.append(
(
"class:marker",
self._marker if choice["enabled"] else self._marker_pl,
)
)
if not isinstance(choice["value"], Separator):
display_choices.append(
("class:pointer", "%s%s" % (choice["key"], self._separator))
)
display_choices.append(("[SetCursorPosition]", ""))
display_choices.append(("class:pointer", choice["name"]))
return display_choices
def _get_normal_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("", len(self._pointer) * " "))
display_choices.append(
(
"class:marker",
self._marker if choice["enabled"] else self._marker_pl,
)
)
if not isinstance(choice["value"], Separator):
display_choices.append(("", "%s%s" % (choice["key"], self._separator)))
display_choices.append(("", choice["name"]))
else:
display_choices.append(("class:separator", choice["name"]))
return display_choices
class ExpandPrompt(ListPrompt):
"""Create a compact prompt with the ability to expand.
A wrapper class around :class:`~prompt_toolkit.application.Application`.
Contains a list of chocies binded to a shortcut letter.
The prompt can be expanded using `h` key.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
choices: List of choices to display and select.
Refer to :ref:`pages/prompts/expand:Choices` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default value of the prompt.
This will be used to determine which choice is highlighted (current selection),
The default value should the value of one of the choices.
For :class:`.ExpandPrompt` specifically, default value can also be a `choice["key"]` which is the shortcut key for the choice.
Refer to :ref:`pages/dynamic:default` documentation for more details.
separator: Separator symbol. Custom symbol that will be used as a separator between the choice index number and the choices.
help_msg: This parameter is DEPRECATED. Use expand_help instead.
expand_help: The help configuration for the prompt. Must be an instance of :class:`.ExpandHelp`.
If this value is None, the default help key will be binded to `h` and the default help message would be
"Help, List all choices."
expand_pointer: Pointer symbol before prompt expansion. Custom symbol that will be displayed to indicate the prompt is not expanded.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
pointer: Pointer symbol. Customer symbol that will be used to indicate the current choice selection.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
The main use case for this prompt would be when `multiselect` is True, you can enforce a min/max selection.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
height: Preferred height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
max_height: Max height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
multiselect: Enable multi-selection on choices.
You can use `validate` parameter to control min/max selections.
Setting to True will also change the result from a single value to a list of values.
marker: Marker Symbol. Custom symbol to indicate if a choice is selected.
This will take effects when `multiselect` is True.
marker_pl: Marker place holder when the choice is not selected.
This is empty space by default.
border: Create border around the choice window.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
show_cursor: Display cursor at the end of the prompt.
Set to False to hide the cursor.
cycle: Return to top item if hit bottom during navigation or vice versa.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.expand(message="Select one:", choices[{"name": "1", "value": "1", "key": "a"}]).execute()
>>> print(result)
"1"
"""
def __init__(
self,
message: InquirerPyMessage,
choices: InquirerPyListChoices,
default: InquirerPyDefault = "",
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
pointer: str = " ",
separator: str = ") ",
help_msg: str = "Help, list all choices",
expand_help: Optional[ExpandHelp] = None,
expand_pointer: str = "%s " % INQUIRERPY_POINTER_SEQUENCE,
instruction: str = "",
long_instruction: str = "",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
height: Optional[Union[int, str]] = None,
max_height: Optional[Union[int, str]] = None,
multiselect: bool = False,
marker: str = INQUIRERPY_POINTER_SEQUENCE,
marker_pl: str = " ",
border: bool = False,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
keybindings: Optional[InquirerPyKeybindings] = None,
show_cursor: bool = True,
cycle: bool = True,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
if expand_help is None:
expand_help = ExpandHelp(message=help_msg)
self._expand_help = expand_help
self.content_control: InquirerPyExpandControl = InquirerPyExpandControl(
choices=choices,
default=default,
pointer=pointer,
separator=separator,
expand_help=expand_help,
expand_pointer=expand_pointer,
marker=marker,
marker_pl=marker_pl,
session_result=session_result,
multiselect=multiselect,
)
super().__init__(
message=message,
choices=choices,
style=style,
border=border,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
instruction=instruction,
long_instruction=long_instruction,
transformer=transformer,
filter=filter,
height=height,
max_height=max_height,
validate=validate,
invalid_message=invalid_message,
multiselect=multiselect,
keybindings=keybindings,
show_cursor=show_cursor,
cycle=cycle,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
def _on_rendered(self, _) -> None:
"""Override this method to apply custom keybindings.
Needs to creat these kb in the callback due to `after_render`
retrieve the choices asynchronously.
"""
def keybinding_factory(key):
@self.register_kb(key.lower())
def keybinding(_) -> None:
if key == self._expand_help.key:
self.content_control._expanded = not self.content_control._expanded
else:
self.content_control.selected_choice_index = (
self.content_control._key_maps[key]
)
return keybinding
for choice in self.content_control.choices:
if not isinstance(choice["value"], Separator):
keybinding_factory(choice["key"])
def _handle_up(self, event) -> None:
"""Handle the event when user attempt to move up.
Overriding this method to skip the help choice.
"""
if not self.content_control._expanded:
return
while True:
cap = BaseListPrompt._handle_up(self, event)
if not isinstance(
self.content_control.selection["value"], Separator
) and not isinstance(self.content_control.selection["value"], ExpandHelp):
break
else:
if cap and not self._cycle:
self._handle_down(event)
break
def _handle_down(self, event) -> None:
"""Handle the event when user attempt to move down.
Overriding this method to skip the help choice.
"""
if not self.content_control._expanded:
return
while True:
cap = BaseListPrompt._handle_down(self, event)
if not isinstance(
self.content_control.selection["value"], Separator
) and not isinstance(self.content_control.selection["value"], ExpandHelp):
break
elif (
isinstance(self.content_control.selection["value"], ExpandHelp)
and not self._cycle
):
self._handle_up(event)
break
else:
if cap and not self._cycle:
self._handle_up(event)
break
@property
def instruction(self) -> str:
"""Construct the instruction behind the question.
If _instruction exists, use that.
:return: The instruction text.
"""
return (
"(%s)" % "".join(self.content_control._key_maps.keys())
if not self._instruction
else self._instruction
)
def _get_prompt_message(self) -> List[Tuple[str, str]]:
"""Return the formatted text to display in the prompt.
Overriding this method to allow multiple formatted class to be displayed.
"""
display_message = super()._get_prompt_message()
if not self.status["answered"]:
display_message.append(
("class:input", self.content_control.selection["key"])
)
return display_message
def _handle_toggle_all(self, _, value: Optional[bool] = None) -> None:
"""Override this method to ignore `ExpandHelp`.
:param value: Specify a value to toggle.
"""
if not self.content_control._expanded:
return
for choice in self.content_control.choices:
if isinstance(choice["value"], Separator) or isinstance(
choice["value"], ExpandHelp
):
continue
choice["enabled"] = value if value else not choice["enabled"]
def _handle_toggle_choice(self, event) -> None:
"""Override this method to ignore keypress when not expanded."""
if not self.content_control._expanded:
return
super()._handle_toggle_choice(event)
+192
View File
@@ -0,0 +1,192 @@
"""Module contains the class to create filepath prompt and filepath completer class."""
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Generator, Optional
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.completion.base import ThreadedCompleter
from InquirerPy.prompts.input import InputPrompt
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
if TYPE_CHECKING:
from prompt_toolkit.input.base import Input
from prompt_toolkit.output.base import Output
__all__ = ["FilePathPrompt", "FilePathCompleter"]
class FilePathCompleter(Completer):
"""An auto completion class which generates system filepath.
See Also:
:class:`~prompt_toolkit.completion.Completer`
Args:
only_directories: Only complete directories.
only_files: Only complete files.
"""
def __init__(self, only_directories: bool = False, only_files: bool = False):
self._only_directories = only_directories
self._only_files = only_files
self._delimiter = "/" if os.name == "posix" else "\\"
def get_completions(
self, document, complete_event
) -> Generator[Completion, None, None]:
"""Get a list of valid system paths."""
if document.text == "~":
return
validation = lambda file, doc_text: str(file).startswith(doc_text)
if document.cursor_position == 0:
dirname = Path.cwd()
validation = lambda file, doc_text: True
elif document.text.startswith("~"):
dirname = Path(os.path.dirname(f"{Path.home()}{document.text[1:]}"))
validation = lambda file, doc_text: str(file).startswith(
f"{Path.home()}{doc_text[1:]}"
)
elif document.text.startswith(f".{self._delimiter}"):
dirname = Path(os.path.dirname(document.text))
validation = lambda file, doc_text: str(file).startswith(doc_text[2:])
else:
dirname = Path(os.path.dirname(document.text))
for item in self._get_completion(document, dirname, validation):
yield item
def _get_completion(
self, document, path, validation
) -> Generator[Completion, None, None]:
if not path.is_dir():
return
for file in path.iterdir():
if self._only_directories and not file.is_dir():
continue
if self._only_files and not file.is_file():
continue
if validation(file, document.text):
file_name = file.name
display_name = file_name
if file.is_dir():
display_name = f"{file_name}{self._delimiter}"
yield Completion(
file.name,
start_position=-1 * len(os.path.basename(document.text)),
display=display_name,
)
class FilePathPrompt(InputPrompt):
"""Create a prompt that provides auto completion for system filepaths.
A wrapper class around :class:`~prompt_toolkit.shortcuts.PromptSession`.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default text value of the prompt.
Refer to :ref:`pages/dynamic:default` documentation for more details.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
multicolumn_complete: Change the auto-completion UI to a multi column display.
validate: Add validation to user input.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
only_directories: Only complete directories.
only_files: Only complete files.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
input: Used internally and will be removed in future updates.
output: Used internally and will be removed in future updates.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.filepath(message="Enter a path:").execute()
>>> print(result)
/home/ubuntu/README.md
"""
def __init__(
self,
message: InquirerPyMessage,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
default: InquirerPyDefault = "",
qmark: str = "?",
amark: str = "?",
instruction: str = "",
long_instruction: str = "",
multicolumn_complete: bool = False,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
only_directories: bool = False,
only_files: bool = False,
transformer: Optional[Callable[[str], Any]] = None,
filter: Optional[Callable[[str], Any]] = None,
keybindings: Optional[InquirerPyKeybindings] = None,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
input: Optional["Input"] = None,
output: Optional["Output"] = None,
) -> None:
super().__init__(
message=message,
style=style,
vi_mode=vi_mode,
default=default,
qmark=qmark,
amark=amark,
instruction=instruction,
long_instruction=long_instruction,
completer=ThreadedCompleter(
FilePathCompleter(
only_directories=only_directories, only_files=only_files
)
),
multicolumn_complete=multicolumn_complete,
validate=validate,
invalid_message=invalid_message,
transformer=transformer,
filter=filter,
keybindings=keybindings,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
input=input,
output=output,
)
+680
View File
@@ -0,0 +1,680 @@
"""Module contains the class to create a fuzzy prompt."""
import asyncio
import math
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
cast,
)
from pfzy import fuzzy_match
from pfzy.score import fzy_scorer, substr_scorer
from pfzy.types import HAYSTACKS
from prompt_toolkit.application.application import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.filters.cli import IsDone
from prompt_toolkit.layout.containers import (
ConditionalContainer,
FloatContainer,
HSplit,
Window,
)
from prompt_toolkit.layout.controls import BufferControl, DummyControl
from prompt_toolkit.layout.dimension import Dimension, LayoutDimension
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.layout.processors import AfterInput, BeforeInput
from prompt_toolkit.lexers.base import SimpleLexer
from prompt_toolkit.validation import ValidationError
from prompt_toolkit.widgets.base import Frame
from InquirerPy.base import FakeDocument, InquirerPyUIListControl
from InquirerPy.base.list import BaseListPrompt
from InquirerPy.containers.instruction import InstructionWindow
from InquirerPy.containers.message import MessageWindow
from InquirerPy.containers.validation import ValidationFloat
from InquirerPy.enum import INQUIRERPY_POINTER_SEQUENCE
from InquirerPy.exceptions import InvalidArgument
from InquirerPy.separator import Separator
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyListChoices,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
calculate_height,
)
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
__all__ = ["FuzzyPrompt"]
class InquirerPyFuzzyControl(InquirerPyUIListControl):
"""An :class:`~prompt_toolkit.layout.UIControl` class that displays a list of choices.
This only displays the chocies. The actual input buffer will be handled by a separate
:class:`~prompt_toolkit.layout.BufferControl`.
Reference the parameter definition in :class:`.FuzzyPrompt`.
"""
def __init__(
self,
choices: InquirerPyListChoices,
pointer: str,
marker: str,
current_text: Callable[[], str],
max_lines: int,
session_result: Optional[InquirerPySessionResult],
multiselect: bool,
marker_pl: str,
match_exact: bool,
) -> None:
self._pointer = pointer
self._marker = marker
self._marker_pl = marker_pl
self._current_text = current_text
self._max_lines = max_lines if max_lines > 0 else 1
self._scorer = fzy_scorer if not match_exact else substr_scorer
super().__init__(
choices=choices,
default=None,
session_result=session_result,
multiselect=multiselect,
)
def _format_choices(self) -> None:
for index, choice in enumerate(self.choices):
if isinstance(choice["value"], Separator):
raise InvalidArgument(
"fuzzy prompt argument choices should not contain Separator"
)
choice["index"] = index
choice["indices"] = []
self._filtered_choices = self.choices
self._first_line = 0
self._last_line = min(self._max_lines, self.choice_count)
self._height = self._last_line - self._first_line
def _get_hover_text(self, choice) -> List[Tuple[str, str]]:
"""Get the current highlighted line of text.
If in the middle of filtering, loop through the char and color
indices matched char into style class `class:fuzzy_match`.
Returns:
FormattedText in list of tuple format.
"""
display_choices = []
display_choices.append(("class:pointer", self._pointer))
display_choices.append(
(
"class:marker",
self._marker
if self.choices[choice["index"]]["enabled"]
else self._marker_pl,
)
)
display_choices.append(("[SetCursorPosition]", ""))
if not choice["indices"]:
display_choices.append(("class:pointer", choice["name"]))
else:
indices = set(choice["indices"])
for index, char in enumerate(choice["name"]):
if index in indices:
display_choices.append(("class:fuzzy_match", char))
else:
display_choices.append(("class:pointer", char))
return display_choices
def _get_normal_text(self, choice) -> List[Tuple[str, str]]:
"""Get the line of text in `FormattedText`.
If in the middle of filtering, loop through the char and color
indices matched char into `class:fuzzy_match`.
Calculate spaces of pointer to make the choice equally align.
Returns:
FormattedText in list of tuple format.
"""
display_choices = []
display_choices.append(("class:pointer", len(self._pointer) * " "))
display_choices.append(
(
"class:marker",
self._marker
if self.choices[choice["index"]]["enabled"]
else self._marker_pl,
)
)
if not choice["indices"]:
display_choices.append(("", choice["name"]))
else:
indices = set(choice["indices"])
for index, char in enumerate(choice["name"]):
if index in indices:
display_choices.append(("class:fuzzy_match", char))
else:
display_choices.append(("", char))
return display_choices
def _get_formatted_choices(self) -> List[Tuple[str, str]]:
"""Get all available choices in formatted text format.
Overriding this method because `self.choice` will be the
full choice list. Using `self.filtered_choice` to get
a list of choice based on current_text.
Returns:
FormattedText in list of tuple format.
"""
display_choices = []
if self.choice_count == 0:
self._selected_choice_index = 0
return display_choices
if self._selected_choice_index < 0:
self._selected_choice_index = 0
elif self._selected_choice_index >= self.choice_count:
self._selected_choice_index = self.choice_count - 1
if (self._last_line - self._first_line) < min(self.choice_count, self._height):
self._last_line = min(self.choice_count, self._height)
self._first_line = self._last_line - min(self.choice_count, self._height)
if self._selected_choice_index <= self._first_line:
self._first_line = self._selected_choice_index
self._last_line = self._first_line + min(self._height, self.choice_count)
elif self._selected_choice_index >= self._last_line:
self._last_line = self._selected_choice_index + 1
self._first_line = self._last_line - min(self._height, self.choice_count)
if self._last_line > self.choice_count:
self._last_line = self.choice_count
self._first_line = self._last_line - min(self._height, self.choice_count)
if self._first_line < 0:
self._first_line = 0
self._last_line = self._first_line + min(self._height, self.choice_count)
for index in range(self._first_line, self._last_line):
if index == self.selected_choice_index:
display_choices += self._get_hover_text(self._filtered_choices[index])
else:
display_choices += self._get_normal_text(self._filtered_choices[index])
display_choices.append(("", "\n"))
if display_choices:
display_choices.pop()
return display_choices
async def _filter_choices(self, wait_time: float) -> List[Dict[str, Any]]:
"""Call to filter choices using fzy fuzzy match.
Args:
wait_time: Additional time to wait before filtering the choice.
Returns:
Filtered choices.
"""
if not self._current_text():
for choice in self.choices:
choice["indices"] = []
choices = self.choices
else:
await asyncio.sleep(wait_time)
choices = await fuzzy_match(
self._current_text(),
cast(HAYSTACKS, self.choices),
key="name",
scorer=self._scorer,
)
return choices
@property
def selection(self) -> Dict[str, Any]:
"""Override this value since `self.choice` does not indicate the choice displayed.
`self.filtered_choice` is the up to date choice displayed.
Returns:
A dictionary of name and value for the current pointed choice.
"""
return self._filtered_choices[self.selected_choice_index]
@property
def choice_count(self) -> int:
"""int: Filtered choice count."""
return len(self._filtered_choices)
class FuzzyPrompt(BaseListPrompt):
"""Create a prompt that lists choices while also allowing fuzzy search like fzf.
A wrapper class around :class:`~prompt_toolkit.application.Application`.
Fuzzy search using :func:`pfzy.match.fuzzy_match` function.
Override the default keybindings for up/down as j/k cannot be bind even if `editing_mode` is vim
due to the input buffer.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
choices: List of choices to display and select.
Refer to :ref:`pages/dynamic:choices` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default value in the search buffer.
Different than other list type prompts, the `default` parameter tries to replicate what fzf does and
add the value in `default` to search buffer so it starts searching immediatelly.
Refer to :ref:`pages/dynamic:default` documentation for more details.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
pointer: Pointer symbol. Customer symbol that will be used to indicate the current choice selection.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
The main use case for this prompt would be when `multiselect` is True, you can enforce a min/max selection.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
height: Preferred height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
max_height: Max height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
multiselect: Enable multi-selection on choices.
You can use `validate` parameter to control min/max selections.
Setting to True will also change the result from a single value to a list of values.
prompt: Input prompt symbol. Custom symbol to display infront of the input buffer to indicate for input.
border: Create border around the choice window.
info: Display choice information similar to fzf --info=inline next to the prompt.
match_exact: Use exact sub-string match instead of using fzy fuzzy match algorithm.
exact_symbol: Custom symbol to display in the info section when `info=True`.
marker: Marker Symbol. Custom symbol to indicate if a choice is selected.
This will take effects when `multiselect` is True.
marker_pl: Marker place holder when the choice is not selected.
This is empty space by default.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
cycle: Return to top item if hit bottom during navigation or vice versa.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.fuzzy(message="Select one:", choices=[1, 2, 3]).execute()
>>> print(result)
1
"""
def __init__(
self,
message: InquirerPyMessage,
choices: InquirerPyListChoices,
default: InquirerPyDefault = "",
pointer: str = INQUIRERPY_POINTER_SEQUENCE,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
instruction: str = "",
long_instruction: str = "",
multiselect: bool = False,
prompt: str = INQUIRERPY_POINTER_SEQUENCE,
marker: str = INQUIRERPY_POINTER_SEQUENCE,
marker_pl: str = " ",
border: bool = False,
info: bool = True,
match_exact: bool = False,
exact_symbol: str = " E",
height: Optional[Union[str, int]] = None,
max_height: Optional[Union[str, int]] = None,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
keybindings: Optional[InquirerPyKeybindings] = None,
cycle: bool = True,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
if not keybindings:
keybindings = {}
self._prompt = prompt
self._info = info
self._task = None
self._rendered = False
self._exact_symbol = exact_symbol
keybindings = {
"up": [{"key": "up"}, {"key": "c-p"}],
"down": [{"key": "down"}, {"key": "c-n"}],
"toggle": [],
"toggle-exact": [],
**keybindings,
}
super().__init__(
message=message,
style=style,
border=border,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
transformer=transformer,
filter=filter,
validate=validate,
invalid_message=invalid_message,
multiselect=multiselect,
instruction=instruction,
long_instruction=long_instruction,
keybindings=keybindings,
cycle=cycle,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
self.kb_func_lookup = {"toggle-exact": [{"func": self._toggle_exact}]}
self._default = (
default
if not isinstance(default, Callable)
else cast(Callable, default)(self._result)
)
self._height_offset += 1 # search input
self._dimmension_height, self._dimmension_max_height = calculate_height(
height, max_height, height_offset=self.height_offset
)
self._content_control: InquirerPyFuzzyControl = InquirerPyFuzzyControl(
choices=choices,
pointer=pointer,
marker=marker,
current_text=self._get_current_text,
max_lines=self._dimmension_max_height,
session_result=session_result,
multiselect=multiselect,
marker_pl=marker_pl,
match_exact=match_exact,
)
self._buffer = Buffer(on_text_changed=self._on_text_changed)
input_window = Window(
height=LayoutDimension.exact(1),
content=BufferControl(
self._buffer,
[
AfterInput(self._generate_after_input),
BeforeInput(self._generate_before_input),
],
lexer=SimpleLexer("class:input"),
),
)
choice_height_dimmension = lambda: Dimension(
max=self._dimmension_max_height,
preferred=self._dimmension_height,
min=self.content_control._height if self.content_control._height > 0 else 1,
)
self.choice_window = Window(
content=self.content_control,
height=choice_height_dimmension,
dont_extend_height=True,
)
main_content_window = HSplit([input_window, self.choice_window])
if self._border:
main_content_window = Frame(main_content_window)
self._layout = Layout(
FloatContainer(
content=HSplit(
[
MessageWindow(
message=self._get_prompt_message,
filter=True,
wrap_lines=self._wrap_lines,
show_cursor=True,
),
ConditionalContainer(
main_content_window,
filter=~IsDone(),
),
ConditionalContainer(
Window(content=DummyControl()),
filter=~IsDone() & self._is_displaying_long_instruction,
),
InstructionWindow(
message=self._long_instruction,
filter=~IsDone() & self._is_displaying_long_instruction,
wrap_lines=self._wrap_lines,
),
],
),
floats=[
ValidationFloat(
invalid_message=self._get_error_message,
filter=self._is_invalid & ~IsDone(),
wrap_lines=self._wrap_lines,
left=0,
bottom=self._validation_window_bottom_offset,
),
],
)
)
self._layout.focus(input_window)
self._application = Application(
layout=self._layout,
style=self._style,
key_bindings=self._kb,
editing_mode=self._editing_mode,
after_render=self._after_render,
)
def _toggle_exact(self, _, value: Optional[bool] = None) -> None:
"""Toggle matching algorithm.
Switch between fzy fuzzy match or sub-string exact match.
Args:
value: Specify the value to toggle.
"""
if value is not None:
self.content_control._scorer = fzy_scorer if not value else substr_scorer
else:
self.content_control._scorer = (
fzy_scorer
if self.content_control._scorer == substr_scorer
else substr_scorer
)
def _on_rendered(self, _) -> None:
"""Render callable choices and set the buffer default text.
Setting buffer default text has to be after application is rendered and choice are loaded,
because `self._filter_choices` will use the event loop from `Application`.
"""
if self._default:
default_text = str(self._default)
self._buffer.text = default_text
self._buffer.cursor_position = len(default_text)
def _handle_toggle_all(self, _, value: Optional[bool] = None) -> None:
"""Toggle all choice `enabled` status.
Args:
value: Specify the value to toggle.
"""
if not self._multiselect:
return
for choice in self.content_control._filtered_choices:
raw_choice = self.content_control.choices[choice["index"]]
if isinstance(raw_choice["value"], Separator):
continue
raw_choice["enabled"] = value if value else not raw_choice["enabled"]
def _generate_after_input(self) -> List[Tuple[str, str]]:
"""Virtual text displayed after the user input."""
display_message = []
if self._info:
display_message.append(("", " "))
display_message.append(
(
"class:fuzzy_info",
f"{self.content_control.choice_count}/{len(self.content_control.choices)}",
)
)
if self._multiselect:
display_message.append(
("class:fuzzy_info", f" ({len(self.selected_choices)})")
)
if self.content_control._scorer == substr_scorer:
display_message.append(("class:fuzzy_info", self._exact_symbol))
return display_message
def _generate_before_input(self) -> List[Tuple[str, str]]:
"""Display prompt symbol as virtual text before user input."""
display_message = []
display_message.append(("class:fuzzy_prompt", "%s " % self._prompt))
return display_message
def _filter_callback(self, task):
"""Redraw `self._application` when the filter task is finished."""
if task.cancelled():
return
self.content_control._filtered_choices = task.result()
self._application.invalidate()
def _calculate_wait_time(self) -> float:
"""Calculate wait time to smoother the application on big data set.
Using digit of the choices lengeth to get wait time.
For digit greater than 6, using formula 2^(digit - 5) * 0.3 to increase the wait_time.
Returns:
Desired wait time before running the filter.
"""
wait_table = {
2: 0.05,
3: 0.1,
4: 0.2,
5: 0.3,
}
digit = 1
if len(self.content_control.choices) > 0:
digit = int(math.log10(len(self.content_control.choices))) + 1
if digit < 2:
return 0.0
if digit in wait_table:
return wait_table[digit]
return wait_table[5] * (2 ** (digit - 5))
def _on_text_changed(self, _) -> None:
"""Handle buffer text change event.
1. Check if there is current task running.
2. Cancel if already has task, increase wait_time
3. Create a filtered_choice task in asyncio event loop
4. Add callback
1. Run a new filter on all choices.
2. Re-calculate current selected_choice_index
if it exceeds the total filtered_choice.
3. Avoid selected_choice_index less than zero,
this fix the issue of cursor lose when:
choice -> empty choice -> choice
Don't need to create or check asyncio event loop, `prompt_toolkit`
application already has a event loop running.
"""
if self._invalid:
self._invalid = False
wait_time = self._calculate_wait_time()
if self._task and not self._task.done():
self._task.cancel()
self._task = asyncio.create_task(
self.content_control._filter_choices(wait_time)
)
self._task.add_done_callback(self._filter_callback)
def _handle_toggle_choice(self, _) -> None:
"""Handle tab event, alter the `selected` state of the choice."""
if not self._multiselect:
return
current_selected_index = self.content_control.selection["index"]
self.content_control.choices[current_selected_index][
"enabled"
] = not self.content_control.choices[current_selected_index]["enabled"]
def _handle_enter(self, event: "KeyPressEvent") -> None:
"""Handle enter event.
Validate the result first.
In multiselect scenario, if no TAB is entered, then capture the current
highlighted choice and return the value in a list.
Otherwise, return all TAB choices as a list.
In normal scenario, reutrn the current highlighted choice.
If current UI contains no choice due to filter, return None.
"""
try:
fake_document = FakeDocument(self.result_value)
self._validator.validate(fake_document) # type: ignore
if self._multiselect:
self.status["answered"] = True
if not self.selected_choices:
self.status["result"] = [self.content_control.selection["name"]]
event.app.exit(result=[self.content_control.selection["value"]])
else:
self.status["result"] = self.result_name
event.app.exit(result=self.result_value)
else:
self.status["answered"] = True
self.status["result"] = self.content_control.selection["name"]
event.app.exit(result=self.content_control.selection["value"])
except ValidationError as e:
self._set_error(str(e))
except IndexError:
self.status["answered"] = True
self.status["result"] = None if not self._multiselect else []
event.app.exit(result=None if not self._multiselect else [])
@property
def content_control(self) -> InquirerPyFuzzyControl:
"""InquirerPyFuzzyControl: Override for type-hinting."""
return cast(InquirerPyFuzzyControl, super().content_control)
@content_control.setter
def content_control(self, value: InquirerPyFuzzyControl) -> None:
self._content_control = value
def _get_current_text(self) -> str:
"""Get current input buffer text."""
return self._buffer.text
+253
View File
@@ -0,0 +1,253 @@
"""Module contains the class to create an input prompt."""
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
from prompt_toolkit.buffer import ValidationState
from prompt_toolkit.completion import NestedCompleter
from prompt_toolkit.completion.base import Completer
from prompt_toolkit.filters.base import Condition
from prompt_toolkit.keys import Keys
from prompt_toolkit.lexers import SimpleLexer
from prompt_toolkit.shortcuts.prompt import CompleteStyle, PromptSession
from prompt_toolkit.validation import ValidationError
from InquirerPy.base import BaseSimplePrompt
from InquirerPy.enum import INQUIRERPY_POINTER_SEQUENCE
from InquirerPy.exceptions import InvalidArgument
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
if TYPE_CHECKING:
from prompt_toolkit.input.base import Input
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.output.base import Output
__all__ = ["InputPrompt"]
class InputPrompt(BaseSimplePrompt):
"""Create a text prompt that accepts user input.
A wrapper class around :class:`~prompt_toolkit.shortcuts.PromptSession`.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default text value of the prompt.
Refer to :ref:`pages/dynamic:default` documentation for more details.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
completer: Add auto completion to the prompt.
Refer to :ref:`pages/prompts/input:Auto Completion` documentation for more details.
multicolumn_complete: Change the auto-completion UI to a multi column display.
multiline: Enable multiline edit. While multiline edit is active, pressing `enter` won't complete the answer.
and will create a new line. Use `esc` followd by `enter` to complete the question.
validate: Add validation to user input.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
is_password: Used internally for :class:`~InquirerPy.prompts.secret.SecretPrompt`.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
input: Used internally and will be removed in future updates.
output: Used internally and will be removed in future updates.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.text(message="Enter your name:").execute()
>>> print(f"Name: {result}")
Name: Michael
"""
def __init__(
self,
message: InquirerPyMessage,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
default: InquirerPyDefault = "",
qmark: str = "?",
amark: str = "?",
instruction: str = "",
long_instruction: str = "",
completer: Optional[Union[Dict[str, Optional[str]], "Completer"]] = None,
multicolumn_complete: bool = False,
multiline: bool = False,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
transformer: Optional[Callable[[str], Any]] = None,
filter: Optional[Callable[[str], Any]] = None,
keybindings: Optional[InquirerPyKeybindings] = None,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
is_password: bool = False,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
input: Optional["Input"] = None,
output: Optional["Output"] = None,
) -> None:
super().__init__(
message,
style,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
instruction=instruction,
validate=validate,
invalid_message=invalid_message,
transformer=transformer,
filter=filter,
session_result=session_result,
default=default,
wrap_lines=wrap_lines,
mandatory=mandatory,
mandatory_message=mandatory_message,
raise_keyboard_interrupt=raise_keyboard_interrupt,
)
if not isinstance(self._default, str):
raise InvalidArgument(
f"{type(self).__name__} argument 'default' should be type of str"
)
self._completer = None
if isinstance(completer, dict):
self._completer = NestedCompleter.from_nested_dict(completer)
elif isinstance(completer, Completer):
self._completer = completer
self._multiline = multiline
self._complete_style = (
CompleteStyle.COLUMN
if not multicolumn_complete
else CompleteStyle.MULTI_COLUMN
)
@Condition
def is_multiline():
return self._multiline
if not keybindings:
keybindings = {}
self.kb_maps = {
"answer": [
{"key": Keys.Enter, "filter": ~is_multiline},
{"key": [Keys.Escape, Keys.Enter], "filter": is_multiline},
],
"completion": [{"key": "c-space"}],
**keybindings,
}
self.kb_func_lookup = {"completion": [{"func": self._handle_completion}]}
self._keybinding_factory()
self._session = PromptSession(
message=self._get_prompt_message,
key_bindings=self._kb,
style=self._style,
completer=self._completer,
validator=self._validator,
validate_while_typing=False,
input=input,
output=output,
editing_mode=self._editing_mode,
lexer=SimpleLexer(self._lexer),
is_password=is_password,
multiline=self._multiline,
complete_style=self._complete_style,
wrap_lines=wrap_lines,
bottom_toolbar=[("class:long_instruction", long_instruction)]
if long_instruction
else None,
)
def _set_error(self, message: str) -> None:
self._session.default_buffer.validation_state = ValidationState.INVALID
self._session.default_buffer.validation_error = ValidationError(message=message)
def _handle_enter(self, event: "KeyPressEvent") -> None:
try:
self._session.validator.validate(self._session.default_buffer) # type: ignore
except ValidationError:
self._session.default_buffer.validate_and_handle()
else:
self.status["answered"] = True
self.status["result"] = self._session.default_buffer.text
self._session.default_buffer.text = ""
event.app.exit(result=self.status["result"])
def _handle_completion(self, event) -> None:
if self._completer is None:
return
buff = event.app.current_buffer
if buff.complete_state:
buff.complete_next()
else:
buff.start_completion(select_first=False)
def _get_prompt_message(
self,
pre_answer: Optional[Tuple[str, str]] = None,
post_answer: Optional[Tuple[str, str]] = None,
) -> List[Tuple[str, str]]:
"""Get message to display infront of the input buffer.
Args:
pre_answer: The formatted text to display before answering the question.
post_answer: The formatted text to display after answering the question.
Returns:
Formatted text in list of tuple format.
"""
if not pre_answer:
if self._multiline and not self._instruction:
pre_answer = ("class:instruction", " ESC + Enter to finish input")
else:
pre_answer = (
"class:instruction",
" %s " % self.instruction if self.instruction else " ",
)
if not post_answer:
if self._multiline and self.status["result"]:
lines = self.status["result"].split("\n")
if len(lines) > 1:
number_of_chars = len("".join(lines[1:]))
lines[0] += "...[%s char%s]" % (
number_of_chars,
"s" if number_of_chars > 1 else "",
)
post_answer = ("class:answer", " %s" % lines[0])
else:
post_answer = ("class:answer", " %s" % self.status["result"])
formatted_message = super()._get_prompt_message(pre_answer, post_answer)
if not self.status["answered"] and self._multiline:
formatted_message.append(
("class:questionmark", "\n%s " % INQUIRERPY_POINTER_SEQUENCE)
)
return formatted_message
def _run(self) -> str:
return self._session.prompt(default=self._default)
async def _run_async(self) -> Any:
return await self._session.prompt_async(default=self._default)
+368
View File
@@ -0,0 +1,368 @@
"""Module contains the class to create a list prompt."""
import shutil
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, Union
from prompt_toolkit.application.application import Application
from prompt_toolkit.filters.cli import IsDone
from prompt_toolkit.layout.containers import (
ConditionalContainer,
FloatContainer,
HSplit,
Window,
)
from prompt_toolkit.layout.controls import DummyControl
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.validation import ValidationError
from prompt_toolkit.widgets.base import Frame
from InquirerPy.base import InquirerPyUIListControl
from InquirerPy.base.complex import FakeDocument
from InquirerPy.base.list import BaseListPrompt
from InquirerPy.containers.instruction import InstructionWindow
from InquirerPy.containers.message import MessageWindow
from InquirerPy.containers.validation import ValidationFloat
from InquirerPy.enum import INQUIRERPY_POINTER_SEQUENCE
from InquirerPy.separator import Separator
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyListChoices,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
calculate_height,
)
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
__all__ = ["ListPrompt"]
class InquirerPyListControl(InquirerPyUIListControl):
"""An :class:`~prompt_toolkit.layout.UIControl` class that displays a list of choices.
Reference the parameter definition in :class:`.ListPrompt`.
"""
def __init__(
self,
choices: InquirerPyListChoices,
default: Any,
pointer: str,
marker: str,
session_result: Optional[InquirerPySessionResult],
multiselect: bool,
marker_pl: str,
) -> None:
self._pointer: str = pointer
self._marker: str = marker
self._marker_pl: str = marker_pl
super().__init__(
choices=choices,
default=default,
session_result=session_result,
multiselect=multiselect,
)
def _get_hover_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("class:pointer", self._pointer))
display_choices.append(
(
"class:marker",
self._marker if choice["enabled"] else self._marker_pl,
)
)
display_choices.append(("[SetCursorPosition]", ""))
display_choices.append(("class:pointer", choice["name"]))
return display_choices
def _get_normal_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("", len(self._pointer) * " "))
display_choices.append(
(
"class:marker",
self._marker if choice["enabled"] else self._marker_pl,
)
)
if not isinstance(choice["value"], Separator):
display_choices.append(("", choice["name"]))
else:
display_choices.append(("class:separator", choice["name"]))
return display_choices
class ListPrompt(BaseListPrompt):
"""Create a prompt that displays a list of choices to select.
A wrapper class around :class:`~prompt_toolkit.application.Application`.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
choices: List of choices to display and select.
Refer to :ref:`pages/dynamic:choices` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default value of the prompt.
This will be used to determine which choice is highlighted (current selection),
The default value should be the value of one of the choices.
Refer to :ref:`pages/dynamic:default` documentation for more details.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
pointer: Pointer symbol. Customer symbol that will be used to indicate the current choice selection.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
The main use case for this prompt would be when `multiselect` is True, you can enforce a min/max selection.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
height: Preferred height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
max_height: Max height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
multiselect: Enable multi-selection on choices.
You can use `validate` parameter to control min/max selections.
Setting to True will also change the result from a single value to a list of values.
marker: Marker Symbol. Custom symbol to indicate if a choice is selected.
This will take effects when `multiselect` is True.
marker_pl: Marker place holder when the choice is not selected.
This is empty space by default.
border: Create border around the choice window.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
show_cursor: Display cursor at the end of the prompt.
Set to False to hide the cursor.
cycle: Return to top item if hit bottom during navigation or vice versa.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.select(message="Select one:", choices=[1, 2, 3]).execute()
>>> print(result)
1
"""
def __init__(
self,
message: InquirerPyMessage,
choices: InquirerPyListChoices,
default: InquirerPyDefault = None,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
pointer: str = INQUIRERPY_POINTER_SEQUENCE,
instruction: str = "",
long_instruction: str = "",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
height: Optional[Union[int, str]] = None,
max_height: Optional[Union[int, str]] = None,
multiselect: bool = False,
marker: str = INQUIRERPY_POINTER_SEQUENCE,
marker_pl: str = " ",
border: bool = False,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
keybindings: Optional[InquirerPyKeybindings] = None,
show_cursor: bool = True,
cycle: bool = True,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
if not hasattr(self, "_content_control"):
self.content_control = InquirerPyListControl(
choices=choices,
default=default,
pointer=pointer,
marker=marker,
session_result=session_result,
multiselect=multiselect,
marker_pl=marker_pl,
)
super().__init__(
message=message,
style=style,
border=border,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
instruction=instruction,
long_instruction=long_instruction,
transformer=transformer,
filter=filter,
validate=validate,
invalid_message=invalid_message,
multiselect=multiselect,
keybindings=keybindings,
cycle=cycle,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
self._show_cursor = show_cursor
self._dimmension_height, self._dimmension_max_height = calculate_height(
height, max_height, height_offset=self.height_offset
)
main_content_window = Window(
content=self.content_control,
height=Dimension(
max=self._dimmension_max_height,
preferred=self._dimmension_height,
),
dont_extend_height=True,
)
if self._border:
main_content_window = Frame(main_content_window)
self._layout = FloatContainer(
content=HSplit(
[
MessageWindow(
message=self._get_prompt_message_with_cursor
if self._show_cursor
else self._get_prompt_message,
filter=True,
wrap_lines=self._wrap_lines,
show_cursor=self._show_cursor,
),
ConditionalContainer(main_content_window, filter=~IsDone()),
ConditionalContainer(
Window(content=DummyControl()),
filter=~IsDone() & self._is_displaying_long_instruction,
),
InstructionWindow(
message=self._long_instruction,
filter=~IsDone() & self._is_displaying_long_instruction,
wrap_lines=self._wrap_lines,
),
]
),
floats=[
ValidationFloat(
invalid_message=self._get_error_message,
filter=self._is_invalid & ~IsDone(),
wrap_lines=self._wrap_lines,
left=0,
bottom=self._validation_window_bottom_offset,
),
],
)
self.application = Application(
layout=Layout(self._layout),
style=self._style,
key_bindings=self._kb,
after_render=self._after_render,
)
def _get_prompt_message_with_cursor(self) -> List[Tuple[str, str]]:
"""Obtain the prompt message to display and display cursor behind the message.
This ensures that cursor is always at the end of the window.
"""
message = self._get_prompt_message()
message.append(("[SetCursorPosition]", ""))
message.append(("", " ")) # [SetCursorPosition] require char behind it
return message
def _handle_toggle_choice(self, _) -> None:
"""Toggle the `enabled` status of the choice."""
if not self._multiselect:
return
self.content_control.selection["enabled"] = not self.content_control.selection[
"enabled"
]
def _handle_toggle_all(self, _, value: Optional[bool] = None) -> None:
"""Toggle all choice `enabled` status.
Args:
value: Sepcify a value to toggle.
"""
if not self._multiselect:
return
for choice in self.content_control.choices:
if isinstance(choice["value"], Separator):
continue
choice["enabled"] = value if value else not choice["enabled"]
def _handle_up(self, event) -> None:
"""Handle the event when user attempt to move up."""
while True:
cap = super()._handle_up(event)
if not isinstance(self.content_control.selection["value"], Separator):
break
else:
if cap and not self._cycle:
self._handle_down(event)
break
def _handle_down(self, event) -> None:
"""Handle the event when user attempt to move down."""
while True:
cap = super()._handle_down(event)
if not isinstance(self.content_control.selection["value"], Separator):
break
else:
if cap and not self._cycle:
self._handle_up(event)
break
def _handle_enter(self, event: "KeyPressEvent") -> None:
"""Handle the event when user hit `enter` key.
1. Set the prompt state to answered.
2. Set the result to the name of the selected choices.
3. Exit the app with the value of the selected choices.
In multiselect scenario, if nothing is selected, return the current highlighted choice.
"""
try:
fake_document = FakeDocument(self.result_value)
self._validator.validate(fake_document) # type: ignore
except ValidationError as e:
self._set_error(str(e))
else:
self.status["answered"] = True
if self._multiselect and not self.selected_choices:
self.status["result"] = [self.content_control.selection["name"]]
event.app.exit(result=[self.content_control.selection["value"]])
else:
self.status["result"] = self.result_name
event.app.exit(result=self.result_value)
@property
def extra_message_line_count(self) -> int:
"""int: Get extra lines created for message caused by line wrapping.
Overriding it to count the cursor as well.
"""
cursor_offset = -1 if not self._show_cursor else 0
term_width, _ = shutil.get_terminal_size()
return (self.total_message_length + cursor_offset) // term_width
+616
View File
@@ -0,0 +1,616 @@
"""Module contains the class to create a number prompt."""
import re
from decimal import Decimal
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple, Union, cast
from prompt_toolkit.application.application import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.filters.base import Condition
from prompt_toolkit.filters.cli import IsDone
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout.containers import (
ConditionalContainer,
HorizontalAlign,
HSplit,
VSplit,
Window,
)
from prompt_toolkit.layout.controls import (
BufferControl,
DummyControl,
FormattedTextControl,
)
from prompt_toolkit.layout.dimension import Dimension, LayoutDimension
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.lexers.base import SimpleLexer
from prompt_toolkit.validation import ValidationError
from InquirerPy.base.complex import BaseComplexPrompt, FakeDocument
from InquirerPy.containers.instruction import InstructionWindow
from InquirerPy.containers.validation import ValidationWindow
from InquirerPy.enum import INQUIRERPY_QMARK_SEQUENCE
from InquirerPy.exceptions import InvalidArgument
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
__all__ = ["NumberPrompt"]
class NumberPrompt(BaseComplexPrompt):
"""Create a input prompts that only takes number as input.
A wrapper class around :class:`~prompt_toolkit.application.Application`.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default value of the prompt.
You can enter either the floating value or integer value as the default.
Refer to :ref:`pages/dynamic:default` documentation for more details.
float_allowed: Allow decimal input. This will change the prompt to have 2 input buffer, one for the
whole value and one for the integral value.
min_allowed: Set the minimum value of the prompt. When the input value goes below this value, it
will automatically reset to this value.
max_allowed: Set the maximum value of the prompt. When the inptu value goes above this value, it
will automatically reset to this value.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
decimal_symbol: Decimal point symbol. Custom symbol to display as the decimal point.
replace_mode: Start each input buffer in replace mode if default value is 0.
When typing, it will replace the 0 with the new value. The replace mode will be disabled once the value
is changed.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.number(message="Enter number:").execute()
>>> print(result)
0
"""
def __init__(
self,
message: InquirerPyMessage,
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
default: InquirerPyDefault = 0,
float_allowed: bool = False,
max_allowed: Optional[Union[int, float]] = None,
min_allowed: Optional[Union[int, float]] = None,
decimal_symbol: str = ". ",
replace_mode: bool = False,
qmark: str = INQUIRERPY_QMARK_SEQUENCE,
amark: str = "?",
instruction: str = "",
long_instruction: str = "",
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
transformer: Optional[Callable[[str], Any]] = None,
filter: Optional[Callable[[str], Any]] = None,
keybindings: Optional[InquirerPyKeybindings] = None,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
super().__init__(
message=message,
style=style,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
transformer=transformer,
filter=filter,
invalid_message=invalid_message,
validate=validate,
instruction=instruction,
long_instruction=long_instruction,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
self._float = float_allowed
self._is_float = Condition(lambda: self._float)
self._max = max_allowed
self._min = min_allowed
self._value_error_message = "Remove any non-integer value"
self._decimal_symbol = decimal_symbol
self._whole_replace = False
self._integral_replace = False
self._replace_mode = replace_mode
self._leading_zero_pattern = re.compile(r"^(0*)[0-9]+.*")
self._sn_pattern = re.compile(r"^.*E-.*")
self._no_default = False
if default is None:
default = 0
self._no_default = True
if isinstance(default, Callable):
default = cast(Callable, default)(session_result)
if self._float:
default = Decimal(str(float(cast(int, default))))
if self._float:
if not isinstance(default, float) and not isinstance(default, Decimal):
raise InvalidArgument(
f"{type(self).__name__} argument 'default' should return type of float or Decimal"
)
elif not isinstance(default, int):
raise InvalidArgument(
f"{type(self).__name__} argument 'default' should return type of int"
)
self._default = default
if keybindings is None:
keybindings = {}
self.kb_maps = {
"down": [
{"key": "down"},
{"key": "c-n", "filter": ~self._is_vim_edit},
{"key": "j", "filter": self._is_vim_edit},
],
"up": [
{"key": "up"},
{"key": "c-p", "filter": ~self._is_vim_edit},
{"key": "k", "filter": self._is_vim_edit},
],
"left": [
{"key": "left"},
{"key": "c-b", "filter": ~self._is_vim_edit},
{"key": "h", "filter": self._is_vim_edit},
],
"right": [
{"key": "right"},
{"key": "c-f", "filter": ~self._is_vim_edit},
{"key": "l", "filter": self._is_vim_edit},
],
"dot": [{"key": "."}],
"focus": [{"key": Keys.Tab}, {"key": "s-tab"}],
"input": [{"key": str(i)} for i in range(10)],
"negative_toggle": [{"key": "-"}],
**keybindings,
}
self.kb_func_lookup = {
"down": [{"func": self._handle_down}],
"up": [{"func": self._handle_up}],
"left": [{"func": self._handle_left}],
"right": [{"func": self._handle_right}],
"focus": [{"func": self._handle_focus}],
"input": [{"func": self._handle_input}],
"negative_toggle": [{"func": self._handle_negative_toggle}],
"dot": [{"func": self._handle_dot}],
}
@self.register_kb(Keys.Any)
def _(_):
pass
self._whole_width = 1
self._whole_buffer = Buffer(
on_text_changed=self._on_whole_text_change,
on_cursor_position_changed=self._on_cursor_position_change,
)
self._integral_width = 1
self._integral_buffer = Buffer(
on_text_changed=self._on_integral_text_change,
on_cursor_position_changed=self._on_cursor_position_change,
)
self._whole_window = Window(
height=LayoutDimension.exact(1) if not self._wrap_lines else None,
content=BufferControl(
buffer=self._whole_buffer,
lexer=SimpleLexer("class:input"),
),
width=lambda: Dimension(
min=self._whole_width,
max=self._whole_width,
preferred=self._whole_width,
),
dont_extend_width=True,
)
self._integral_window = Window(
height=LayoutDimension.exact(1) if not self._wrap_lines else None,
content=BufferControl(
buffer=self._integral_buffer,
lexer=SimpleLexer("class:input"),
),
width=lambda: Dimension(
min=self._integral_width,
max=self._integral_width,
preferred=self._integral_width,
),
)
self._layout = Layout(
HSplit(
[
VSplit(
[
Window(
height=LayoutDimension.exact(1)
if not self._wrap_lines
else None,
content=FormattedTextControl(self._get_prompt_message),
wrap_lines=self._wrap_lines,
dont_extend_height=True,
dont_extend_width=True,
),
ConditionalContainer(self._whole_window, filter=~IsDone()),
ConditionalContainer(
Window(
height=LayoutDimension.exact(1)
if not self._wrap_lines
else None,
content=FormattedTextControl(
[("", self._decimal_symbol)]
),
wrap_lines=self._wrap_lines,
dont_extend_height=True,
dont_extend_width=True,
),
filter=self._is_float & ~IsDone(),
),
ConditionalContainer(
self._integral_window, filter=self._is_float & ~IsDone()
),
],
align=HorizontalAlign.LEFT,
),
ConditionalContainer(
Window(content=DummyControl()),
filter=~IsDone() & self._is_displaying_long_instruction,
),
ValidationWindow(
invalid_message=self._get_error_message,
filter=self._is_invalid & ~IsDone(),
wrap_lines=self._wrap_lines,
),
InstructionWindow(
message=self._long_instruction,
filter=~IsDone() & self._is_displaying_long_instruction,
wrap_lines=self._wrap_lines,
),
]
),
)
self.focus = self._whole_window
self._application = Application(
layout=self._layout,
style=self._style,
key_bindings=self._kb,
after_render=self._after_render,
editing_mode=self._editing_mode,
)
def _fix_sn(self, value: str) -> Tuple[str, str]:
"""Fix sciencetific notation format.
Args:
value: Value to fix.
Returns:
A tuple of whole buffer text and integral buffer text.
"""
left, right = value.split("E-")
whole_buffer_text = "0"
integral_buffer_text = f"{(int(right) - 1) * '0'}{left.replace('.', '')}"
return whole_buffer_text, integral_buffer_text
def _on_rendered(self, _) -> None:
"""Additional processing to adjust buffer content after render."""
if self._no_default:
return
if not self._float:
self._whole_buffer.text = str(self._default)
self._integral_buffer.text = "0"
else:
if self._sn_pattern.match(str(self._default)) is None:
whole_buffer_text, integral_buffer_text = str(self._default).split(".")
else:
whole_buffer_text, integral_buffer_text = self._fix_sn(
str(self._default)
)
self._integral_buffer.text = integral_buffer_text
self._whole_buffer.text = whole_buffer_text
self._whole_buffer.cursor_position = len(self._whole_buffer.text)
self._integral_buffer.cursor_position = len(self._integral_buffer.text)
if self._replace_mode:
# check to start replace mode if applicable
if self._whole_buffer.text == "0":
self._whole_replace = True
self._whole_buffer.cursor_position = 0
if self._integral_buffer.text == "0":
self._integral_replace = True
self._integral_buffer.cursor_position = 0
def _handle_number(self, increment: bool) -> None:
"""Handle number increment and decrement.
Additional processing to handle leading zeros in integral buffer
as well as SN notation.
Args:
increment: Indicate if the operation should increment or decrement.
"""
if self.buffer_replace:
self.buffer_replace = False
self.focus_buffer.cursor_position += 1
try:
leading_zeros = ""
if self.focus_buffer == self._integral_buffer:
zeros = self._leading_zero_pattern.match(self._integral_buffer.text)
if zeros is not None:
leading_zeros = zeros.group(1)
current_text_len = len(self.focus_buffer.text)
if not self.focus_buffer.text:
next_text = "0"
next_text_len = 1
else:
if not increment:
if (
self.focus_buffer == self._integral_buffer
and int(self.focus_buffer.text) == 0
):
return
next_text = leading_zeros + str(int(self.focus_buffer.text) - 1)
else:
next_text = leading_zeros + str(int(self.focus_buffer.text) + 1)
next_text_len = len(next_text)
desired_position = (
self.focus_buffer.cursor_position + next_text_len - current_text_len
)
self.focus_buffer.cursor_position = desired_position
self.focus_buffer.text = next_text
if self.focus_buffer.cursor_position != desired_position:
self.focus_buffer.cursor_position = desired_position
except ValueError:
self._set_error(message=self._value_error_message)
def _handle_down(self, _) -> None:
"""Handle down key press."""
self._handle_number(increment=False)
def _handle_up(self, _) -> None:
"""Handle up key press."""
self._handle_number(increment=True)
def _handle_left(self, _) -> None:
"""Handle left key press.
Move to the left by one cursor position and focus the whole window
if applicable.
"""
self.buffer_replace = False
if (
self.focus == self._integral_window
and self.focus_buffer.cursor_position == 0
):
self.focus = self._whole_window
else:
self.focus_buffer.cursor_position -= 1
def _handle_right(self, _) -> None:
"""Handle right key press.
Move to the right by one cursor position and focus the integral window
if applicable.
"""
self.buffer_replace = False
if (
self.focus == self._whole_window
and self.focus_buffer.cursor_position == len(self.focus_buffer.text)
and self._float
):
self.focus = self._integral_window
else:
self.focus_buffer.cursor_position += 1
def _handle_enter(self, event: "KeyPressEvent") -> None:
"""Handle enter event and answer/close the prompt."""
if not self._float and not self._whole_buffer.text:
result = ""
elif (
self._float
and not self._whole_buffer.text
and not self._integral_buffer.text
):
result = ""
else:
result = str(self.value)
try:
fake_document = FakeDocument(result)
self._validator.validate(fake_document) # type: ignore
except ValidationError as e:
self._set_error(str(e))
else:
self.status["answered"] = True
self.status["result"] = result
event.app.exit(result=result)
def _handle_dot(self, _) -> None:
"""Focus the integral window if `float_allowed`."""
self._handle_focus(_, self._integral_window)
def _handle_focus(self, _, window: Optional[Window] = None) -> None:
"""Focus either the integral window or whole window."""
if not self._float:
return
if window is not None:
self.focus = window
return
if self.focus == self._whole_window:
self.focus = self._integral_window
else:
self.focus = self._whole_window
def _handle_input(self, event: "KeyPressEvent") -> None:
"""Handle user input of numbers.
Buffer will start as replace mode if the value is zero, once
cursor is moved or content is changed, disable replace mode.
"""
if self.buffer_replace:
self.buffer_replace = False
self.focus_buffer.text = event.key_sequence[0].data
self.focus_buffer.cursor_position += 1
else:
self.focus_buffer.insert_text(event.key_sequence[0].data)
def _handle_negative_toggle(self, _) -> None:
"""Toggle negativity of the prompt value.
Force the `-` sign at the start.
"""
if self._whole_buffer.text == "-":
self._whole_buffer.text = "0"
return
if self._whole_buffer.text.startswith("-"):
move_cursor = self._whole_buffer.cursor_position < len(
self._whole_buffer.text
)
self._whole_buffer.text = self._whole_buffer.text[1:]
if move_cursor:
self._whole_buffer.cursor_position -= 1
else:
move_cursor = self._whole_buffer.cursor_position != 0
self._whole_buffer.text = f"-{self._whole_buffer.text}"
if move_cursor:
self._whole_buffer.cursor_position += 1
def _on_whole_text_change(self, buffer: Buffer) -> None:
"""Handle event of text changes in buffer."""
self._whole_width = len(buffer.text) + 1
self._on_text_change(buffer)
def _on_integral_text_change(self, buffer: Buffer) -> None:
"""Handle event of text changes in buffer."""
self._integral_width = len(buffer.text) + 1
self._on_text_change(buffer)
def _on_text_change(self, buffer: Buffer) -> None:
"""Disable replace mode and fix cursor position on text changes."""
self.buffer_replace = False
if buffer.text and buffer.text != "-":
self.value = self.value
if buffer.text.startswith("-") and buffer.cursor_position == 0:
buffer.cursor_position = 1
def _on_cursor_position_change(self, buffer: Buffer) -> None:
"""Fix cursor position on cursor movement."""
if self.focus_buffer.text.startswith("-") and buffer.cursor_position == 0:
buffer.cursor_position = 1
@property
def buffer_replace(self) -> bool:
"""bool: Current buffer replace mode."""
if self.focus_buffer == self._whole_buffer:
return self._whole_replace
else:
return self._integral_replace
@buffer_replace.setter
def buffer_replace(self, value) -> None:
if self.focus_buffer == self._whole_buffer:
self._whole_replace = value
else:
self._integral_replace = value
@property
def focus_buffer(self) -> Buffer:
"""Buffer: Current editable buffer."""
if self.focus == self._whole_window:
return self._whole_buffer
else:
return self._integral_buffer
@property
def focus(self) -> Window:
"""Window: Current focused window."""
return self._focus
@focus.setter
def focus(self, value: Window) -> None:
self._focus = value
self._layout.focus(self._focus)
@property
def value(self) -> Union[int, float, Decimal]:
"""Union[int, float]: The actual value of the prompt, combining and transforming all input buffer values."""
try:
if not self._float:
return int(self._whole_buffer.text)
else:
return Decimal(
f"{self._whole_buffer.text}.{self._integral_buffer.text if self._integral_buffer.text else 0}"
)
except ValueError:
self._set_error(self._value_error_message)
return self._default
@value.setter
def value(self, value: Union[int, float, Decimal]) -> None:
if self._min is not None:
value = max(
value, self._min if not self._float else Decimal(str(self._min))
)
if self._max is not None:
value = min(
value, self._max if not self._float else Decimal(str(self._max))
)
if not self._float:
self._whole_buffer.text = str(value)
else:
if self._sn_pattern.match(str(value)) is None:
whole_buffer_text, integral_buffer_text = str(value).split(".")
else:
whole_buffer_text, integral_buffer_text = self._fix_sn(str(value))
if self._whole_buffer.text:
self._whole_buffer.text = whole_buffer_text
if self._integral_buffer.text:
self._integral_buffer.text = integral_buffer_text
+285
View File
@@ -0,0 +1,285 @@
"""Module contains the class to create a rawlist prompt."""
from typing import Any, Callable, List, Optional, Tuple, Union
from InquirerPy.base import InquirerPyUIListControl
from InquirerPy.enum import INQUIRERPY_POINTER_SEQUENCE
from InquirerPy.exceptions import InvalidArgument
from InquirerPy.prompts.list import ListPrompt
from InquirerPy.separator import Separator
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyListChoices,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
__all__ = ["RawlistPrompt"]
class InquirerPyRawlistControl(InquirerPyUIListControl):
"""An :class:`~prompt_toolkit.layout.UIControl` class that displays a list of choices.
Reference the parameter definition in :class:`.RawlistPrompt`.
"""
def __init__(
self,
choices: InquirerPyListChoices,
default: Any,
pointer: str,
separator: str,
marker: str,
session_result: Optional[InquirerPySessionResult],
multiselect: bool,
marker_pl: str,
) -> None:
self._pointer = pointer
self._separator = separator
self._marker = marker
self._marker_pl = marker_pl
super().__init__(
choices=choices,
default=default,
session_result=session_result,
multiselect=multiselect,
)
def _format_choices(self) -> None:
separator_count = 0
for index, choice in enumerate(self.choices):
if isinstance(choice["value"], Separator):
separator_count += 1
continue
choice["display_index"] = index + 1 - separator_count
choice["actual_index"] = index
if self.choices:
first_valid_choice_index = 0
while isinstance(
self.choices[first_valid_choice_index]["value"], Separator
):
first_valid_choice_index += 1
if self.selected_choice_index == first_valid_choice_index:
for choice in self.choices:
if isinstance(choice["value"], Separator):
continue
if choice["display_index"] == self._default:
self.selected_choice_index = choice["actual_index"]
break
def _get_hover_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("class:pointer", self._pointer))
display_choices.append(
(
"class:marker",
self._marker if choice["enabled"] else self._marker_pl,
)
)
if not isinstance(choice["value"], Separator):
display_choices.append(
(
"class:pointer",
"%s%s" % (str(choice["display_index"]), self._separator),
)
)
display_choices.append(("[SetCursorPosition]", ""))
display_choices.append(("class:pointer", choice["name"]))
return display_choices
def _get_normal_text(self, choice) -> List[Tuple[str, str]]:
display_choices = []
display_choices.append(("", len(self._pointer) * " "))
display_choices.append(
(
"class:marker",
self._marker if choice["enabled"] else self._marker_pl,
)
)
if not isinstance(choice["value"], Separator):
display_choices.append(
("", "%s%s" % (str(choice["display_index"]), self._separator))
)
display_choices.append(("", choice["name"]))
else:
display_choices.append(("class:separator", choice["name"]))
return display_choices
class RawlistPrompt(ListPrompt):
"""Create a prompt that displays a list of choices with index number as shortcuts.
A wrapper class around :class:`~prompt_toolkit.application.Application`.
Each choice will have an index number infront of them with keybinding created for that index number.
Enables user to use number to jump to different choices using number.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
choices: List of choices to display and select.
Refer to :ref:`pages/prompts/rawlist:Choices` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default value of the prompt.
This will be used to determine which choice is highlighted (current selection),
The default value should be the value of one of the choices.
For :class:`.RawlistPrompt` specifically, default value can also be value between 0-9.
Refer to :ref:`pages/dynamic:default` documentation for more details.
separator: Separator symbol. Custom symbol that will be used as a separator between the choice index number and the choices.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
pointer: Pointer symbol. Customer symbol that will be used to indicate the current choice selection.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
The main use case for this prompt would be when `multiselect` is True, you can enforce a min/max selection.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
height: Preferred height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
max_height: Max height of the prompt.
Refer to :ref:`pages/height:Height` documentation for more details.
multiselect: Enable multi-selection on choices.
You can use `validate` parameter to control min/max selections.
Setting to True will also change the result from a single value to a list of values.
marker: Marker Symbol. Custom symbol to indicate if a choice is selected.
This will take effects when `multiselect` is True.
marker_pl: Marker place holder when the choice is not selected.
This is empty space by default.
border: Create border around the choice window.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
show_cursor: Display cursor at the end of the prompt.
Set to False to hide the cursor.
cycle: Return to top item if hit bottom during navigation or vice versa.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.rawlist(message="Select one:", choices=[1, 2, 3]).execute()
>>> print(result)
1
"""
def __init__(
self,
message: InquirerPyMessage,
choices: InquirerPyListChoices,
default: InquirerPyDefault = None,
separator: str = ") ",
style: Optional[InquirerPyStyle] = None,
vi_mode: bool = False,
qmark: str = "?",
amark: str = "?",
pointer: str = " ",
instruction: str = "",
long_instruction: str = "",
transformer: Optional[Callable[[Any], Any]] = None,
filter: Optional[Callable[[Any], Any]] = None,
height: Optional[Union[int, str]] = None,
max_height: Optional[Union[int, str]] = None,
multiselect: bool = False,
marker: str = INQUIRERPY_POINTER_SEQUENCE,
marker_pl: str = " ",
border: bool = False,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
keybindings: Optional[InquirerPyKeybindings] = None,
show_cursor: bool = True,
cycle: bool = True,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
) -> None:
self.content_control = InquirerPyRawlistControl(
choices=choices,
default=default,
pointer=pointer,
separator=separator,
marker=marker,
session_result=session_result,
multiselect=multiselect,
marker_pl=marker_pl,
)
super().__init__(
message=message,
choices=choices,
style=style,
border=border,
vi_mode=vi_mode,
qmark=qmark,
amark=amark,
instruction=instruction,
long_instruction=long_instruction,
transformer=transformer,
filter=filter,
height=height,
max_height=max_height,
multiselect=multiselect,
validate=validate,
invalid_message=invalid_message,
keybindings=keybindings,
show_cursor=show_cursor,
cycle=cycle,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
)
def _on_rendered(self, _) -> None:
"""Override this method to apply custom keybindings.
Needs to creat these kb in the callback due to `after_render`
retrieve the choices asynchronously.
Check if fetched choices exceed the limit of 9, raise
InvalidArgument exception.
"""
def keybinding_factory(choice):
@self.register_kb(str(choice["display_index"]))
def keybinding(_) -> None:
self.content_control.selected_choice_index = int(choice["actual_index"])
return keybinding
if self.content_control.choice_count >= 10:
raise InvalidArgument("rawlist argument choices cannot exceed length of 9")
for choice in self.content_control.choices:
if not isinstance(choice["value"], Separator):
keybinding_factory(choice)
def _get_prompt_message(self) -> List[Tuple[str, str]]:
"""Return the formatted text to display in the prompt.
Overriding this method to allow multiple formatted class to be displayed.
"""
display_message = super()._get_prompt_message()
if not self.status["answered"] and self.content_control.choices:
display_message.append(
("class:input", str(self.content_control.selection["display_index"]))
)
return display_message
+134
View File
@@ -0,0 +1,134 @@
"""Module contains the class to create a secret prompt."""
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple
from InquirerPy.prompts.input import InputPrompt
from InquirerPy.utils import (
InquirerPyDefault,
InquirerPyKeybindings,
InquirerPyMessage,
InquirerPySessionResult,
InquirerPyStyle,
InquirerPyValidate,
)
if TYPE_CHECKING:
from prompt_toolkit.input.base import Input
from prompt_toolkit.output.base import Output
__all__ = ["SecretPrompt"]
class SecretPrompt(InputPrompt):
"""Create a text prompt which transforms the input to asterisks while typing.
A wrapper class around :class:`~prompt_toolkit.shortcuts.PromptSession`.
Args:
message: The question to ask the user.
Refer to :ref:`pages/dynamic:message` documentation for more details.
style: An :class:`InquirerPyStyle` instance.
Refer to :ref:`Style <pages/style:Alternate Syntax>` documentation for more details.
vi_mode: Use vim keybinding for the prompt.
Refer to :ref:`pages/kb:Keybindings` documentation for more details.
default: Set the default text value of the prompt.
Refer to :ref:`pages/dynamic:default` documentation for more details.
qmark: Question mark symbol. Custom symbol that will be displayed infront of the question before its answered.
amark: Answer mark symbol. Custom symbol that will be displayed infront of the question after its answered.
instruction: Short instruction to display next to the question.
long_instruction: Long instructions to display at the bottom of the prompt.
validate: Add validation to user input.
Refer to :ref:`pages/validator:Validator` documentation for more details.
invalid_message: Error message to display when user input is invalid.
Refer to :ref:`pages/validator:Validator` documentation for more details.
transformer: A function which performs additional transformation on the value that gets printed to the terminal.
Different than `filter` parameter, this is only visual effect and won’t affect the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:transformer` documentation for more details.
filter: A function which performs additional transformation on the result.
This affects the actual value returned by :meth:`~InquirerPy.base.simple.BaseSimplePrompt.execute`.
Refer to :ref:`pages/dynamic:filter` documentation for more details.
keybindings: Customise the builtin keybindings.
Refer to :ref:`pages/kb:Keybindings` for more details.
wrap_lines: Soft wrap question lines when question exceeds the terminal width.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
mandatory: Indicate if the prompt is mandatory. If True, then the question cannot be skipped.
mandatory_message: Error message to show when user attempts to skip mandatory prompt.
session_result: Used internally for :ref:`index:Classic Syntax (PyInquirer)`.
input: Used internally and will be removed in future updates.
output: Used internally and will be removed in future updates.
Examples:
>>> from InquirerPy import inquirer
>>> result = inquirer.secret(message="Password:").execute()
>>> print(f"Password: {result}")
Password: asdf123
"""
def __init__(
self,
message: InquirerPyMessage,
style: Optional[InquirerPyStyle] = None,
default: InquirerPyDefault = "",
qmark: str = "?",
amark: str = "?",
instruction: str = "",
long_instruction: str = "",
vi_mode: bool = False,
validate: Optional[InquirerPyValidate] = None,
invalid_message: str = "Invalid input",
transformer: Optional[Callable[[str], Any]] = None,
filter: Optional[Callable[[str], Any]] = None,
keybindings: Optional[InquirerPyKeybindings] = None,
wrap_lines: bool = True,
raise_keyboard_interrupt: bool = True,
mandatory: bool = True,
mandatory_message: str = "Mandatory prompt",
session_result: Optional[InquirerPySessionResult] = None,
input: Optional["Input"] = None,
output: Optional["Output"] = None,
) -> None:
super().__init__(
message=message,
style=style,
vi_mode=vi_mode,
default=default,
qmark=qmark,
amark=amark,
instruction=instruction,
long_instruction=long_instruction,
validate=validate,
invalid_message=invalid_message,
is_password=True,
transformer=transformer,
filter=filter,
keybindings=keybindings,
wrap_lines=wrap_lines,
raise_keyboard_interrupt=raise_keyboard_interrupt,
mandatory=mandatory,
mandatory_message=mandatory_message,
session_result=session_result,
input=input,
output=output,
)
def _get_prompt_message(self) -> List[Tuple[str, str]]:
"""Get message to display infront of the input buffer.
Args:
pre_answer: The formatted text to display before answering the question.
post_answer: The formatted text to display after answering the question.
Returns:
Formatted text in list of tuple format.
"""
pre_answer = (
"class:instruction",
" %s " % self.instruction if self.instruction else " ",
)
post_answer = (
"class:answer",
""
if not self.status["result"]
else " %s" % "".join(["*" for _ in self.status["result"]]),
)
return super()._get_prompt_message(pre_answer, post_answer)
+1
View File
@@ -0,0 +1 @@
# Marker file for PEP 561. The InquirerPy package uses inline types.
+219
View File
@@ -0,0 +1,219 @@
"""This module contains the classic entrypoint for creating prompts.
A `PyInquirer <https://github.com/CITGuru/PyInquirer>`_ compatible entrypoint :func:`.prompt`.
"""
from typing import Any, Dict, List, Optional, Tuple, Union
from InquirerPy.exceptions import InvalidArgument, RequiredKeyNotFound
from InquirerPy.prompts.checkbox import CheckboxPrompt
from InquirerPy.prompts.confirm import ConfirmPrompt
from InquirerPy.prompts.expand import ExpandPrompt
from InquirerPy.prompts.filepath import FilePathPrompt
from InquirerPy.prompts.fuzzy import FuzzyPrompt
from InquirerPy.prompts.input import InputPrompt
from InquirerPy.prompts.list import ListPrompt
from InquirerPy.prompts.number import NumberPrompt
from InquirerPy.prompts.rawlist import RawlistPrompt
from InquirerPy.prompts.secret import SecretPrompt
from InquirerPy.utils import (
InquirerPyKeybindings,
InquirerPyQuestions,
InquirerPySessionResult,
get_style,
)
__all__ = ["prompt", "prompt_async"]
question_mapping = {
"confirm": ConfirmPrompt,
"filepath": FilePathPrompt,
"password": SecretPrompt,
"input": InputPrompt,
"list": ListPrompt,
"checkbox": CheckboxPrompt,
"rawlist": RawlistPrompt,
"expand": ExpandPrompt,
"fuzzy": FuzzyPrompt,
"number": NumberPrompt,
}
def _get_questions(questions: InquirerPyQuestions) -> List[Dict[str, Any]]:
"""Process and validate questions.
Args:
questions: List of questions to create prompt.
Returns:
List of validated questions.
"""
if isinstance(questions, dict):
questions = [questions]
if not isinstance(questions, list):
raise InvalidArgument("argument questions should be type of list or dictionary")
return questions
def _get_question(
original_question: Dict[str, Any], result: InquirerPySessionResult, index: int
) -> Tuple[Optional[Dict[str, Any]], str, Union[str, int], str]:
"""Get information from individual question.
Args:
original_question: Original question dictionary.
result: Current prompt session result.
index: Question index.
Returns:
A tuple containing question information in the order of
question dictionary, type of question, name of question, message of question.
"""
question = original_question.copy()
question_type = question.pop("type")
question_name = question.pop("name", index)
message = question.pop("message")
question_when = question.pop("when", None)
if question_when and not question_when(result):
result[question_name] = None
question = None
return question, question_type, question_name, message
async def prompt_async(
questions: InquirerPyQuestions,
style: Optional[Dict[str, str]] = None,
vi_mode: bool = False,
raise_keyboard_interrupt: bool = True,
keybindings: Optional[InquirerPyKeybindings] = None,
style_override: bool = True,
) -> InquirerPySessionResult:
"""Classic syntax entrypoint to create a prompt session via asynchronous method.
Refer to :func:`InquirerPy.resolver.prompt` for detailed documentations.
"""
result: InquirerPySessionResult = {}
if not keybindings:
keybindings = {}
questions = _get_questions(questions=questions)
question_style = get_style(style, style_override)
for index, original_question in enumerate(questions):
try:
question, question_type, question_name, message = _get_question(
original_question=original_question, result=result, index=index
)
if question is None:
continue
args = {
"message": message,
"style": question_style,
"vi_mode": vi_mode,
"raise_keyboard_interrupt": raise_keyboard_interrupt,
"session_result": result,
"keybindings": {**keybindings, **question.pop("keybindings", {})},
}
result[question_name] = await question_mapping[question_type](
**args, **question
).execute_async()
except KeyError:
raise RequiredKeyNotFound
return result
def prompt(
questions: InquirerPyQuestions,
style: Optional[Dict[str, str]] = None,
vi_mode: bool = False,
raise_keyboard_interrupt: bool = True,
keybindings: Optional[InquirerPyKeybindings] = None,
style_override: bool = True,
) -> InquirerPySessionResult:
"""Classic syntax entrypoint to create a prompt session.
Resolve user provided list of questions, display prompts and get the results.
Args:
questions: A list of :ref:`pages/prompt:question` to ask. Refer to documentation for more info.
style: A :class:`dict` containing the style specification for the prompt. Refer to :ref:`pages/style:Style` for more info.
vi_mode: Use vim keybindings for the prompt instead of the default emacs keybindings.
Refer to :ref:`pages/kb:Keybindings` for more info.
raise_keyboard_interrupt: Raise the :class:`KeyboardInterrupt` exception when `ctrl-c` is pressed. If false, the result
will be `None` and the question is skiped.
keybindings: List of custom :ref:`pages/kb:Keybindings` to apply. Refer to documentation for more info.
style_override: Override all default styles. When providing any style customisation, all default styles are removed when this is True.
Returns:
A dictionary containing all of the question answers. The key is the name of the question and the value is the
user answer. If the `name` key is not present as part of the question, then the question index will be used
as the key.
Raises:
RequiredKeyNotFound: When the question is missing required keys.
InvalidArgument: When the provided `questions` argument is not a type of :class:`list` nor :class:`dictionary`.
Examples:
>>> from InquirerPy import prompt
>>> from InquirerPy.validator import NumberValidator
>>> questions = [
... {
... "type": "input",
... "message": "Enter your age:",
... "validate": NumberValidator(),
... "invalid_message": "Input should be number.",
... "default": "18",
... "name": "age",
... "filter": lambda result: int(result),
... "transformer": lambda result: "Adult" if int(result) >= 18 else "Youth",
... },
... {
... "type": "rawlist",
... "message": "What drinks would you like to buy:",
... "default": 2,
... "choices": lambda result: ["Soda", "Cidr", "Water", "Milk"]
... if result["age"] < 18
... else ["Wine", "Beer"],
... "name": "drink",
... },
... {
... "type": "list",
... "message": "Would you like a bag:",
... "choices": ["Yes", "No"],
... "when": lambda result: result["drink"] in {"Wine", "Beer"},
... },
... {"type": "confirm", "message": "Confirm?", "default": True},
... ]
>>> result = prompt(questions=questions)
"""
result: InquirerPySessionResult = {}
if not keybindings:
keybindings = {}
questions = _get_questions(questions=questions)
question_style = get_style(style, style_override)
for index, original_question in enumerate(questions):
try:
question, question_type, question_name, message = _get_question(
original_question=original_question, result=result, index=index
)
if question is None:
continue
args = {
"message": message,
"style": question_style,
"vi_mode": vi_mode,
"raise_keyboard_interrupt": raise_keyboard_interrupt,
"session_result": result,
"keybindings": {**keybindings, **question.pop("keybindings", {})},
}
result[question_name] = question_mapping[question_type](
**args, **question
).execute()
except KeyError:
raise RequiredKeyNotFound
return result
+23
View File
@@ -0,0 +1,23 @@
"""Module contains the :class:`.Separator` class."""
class Separator:
"""A non selectable choice that can be used as part of the choices argument in list type prompts.
It can be used to create some visual separations between choices in list type prompts.
Args:
line: Content to display as the separator.
Example:
>>> from InquirerPy import inquirer
>>> choices = [1, 2, Separator(), 3]
>>> inquirer.select(message="", choices=choices)
"""
def __init__(self, line: str = 15 * "-") -> None:
self._line = line
def __str__(self) -> str:
"""Create string representation of `Separator`."""
return self._line
+290
View File
@@ -0,0 +1,290 @@
"""Module contains shared utility functions and typing aliases."""
import math
import os
import shutil
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
NamedTuple,
Optional,
Tuple,
Union,
)
from prompt_toolkit import print_formatted_text
from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.application.current import get_app
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.styles import Style
from prompt_toolkit.validation import Validator
from InquirerPy.exceptions import InvalidArgument
if TYPE_CHECKING:
from prompt_toolkit.filters.base import FilterOrBool
from InquirerPy.base.control import Choice
__all__ = [
"get_style",
"calculate_height",
"InquirerPyStyle",
"patched_print",
"color_print",
]
class InquirerPyStyle(NamedTuple):
"""`InquirerPy` Style class.
Used as a helper class to enforce the method `get_style` to be used
while also avoiding :class:`dict` to be passed into prompts.
Note:
The class is an instance of :class:`typing.NamedTuple`.
Warning:
You should not directly be using this class besides for type hinting
purposes. Obtain an instance of this class using :func:`.get_style`.
"""
dict: Dict[str, str]
InquirerPySessionResult = Dict[Union[str, int], Optional[Union[str, bool, List[Any]]]]
InquirerPyChoice = Union[List[Any], List["Choice"], List[Dict[str, Any]]]
InquirerPyListChoices = Union[
Callable[["InquirerPySessionResult"], InquirerPyChoice],
InquirerPyChoice,
]
InquirerPyValidate = Union[Callable[[Any], bool], "Validator"]
InquirerPyQuestions = Union[List[Dict[str, Any]], Dict[str, Any]]
InquirerPyMessage = Union[str, Callable[["InquirerPySessionResult"], str]]
InquirerPyDefault = Union[Any, Callable[["InquirerPySessionResult"], Any]]
InquirerPyKeybindings = Dict[
str, List[Dict[str, Union[str, "FilterOrBool", List[str]]]]
]
def get_style(
style: Optional[Dict[str, str]] = None, style_override: bool = True
) -> InquirerPyStyle:
"""Obtain an :class:`.InquirerPyStyle` instance which can be consumed by the `style` parameter in prompts.
Tip:
This function supports ENV variables.
For all the color ENV variable names, refer to the :ref:`ENV <pages/env:Style>` documentation.
Note:
If no style is provided, then a default theme based on `one dark <https://github.com/joshdick/onedark.vim#color-reference>`_
color palette is applied.
Note:
Priority: style parameter -> ENV variable -> default style
Args:
style: The dictionary of style classes and their colors, If nothing is passed, the style will be resolved to the :ref:`pages/style:Default Style`.
style_override: A boolean to determine if the supplied `style` parameter should be merged with the :ref:`pages/style:Default Style` or override them.
By default, the supplied style will overwrite the :ref:`pages/style:Default Style`.
Returns:
An instance of :class:`.InquirerPyStyle`.
Examples:
>>> from InquirerPy import get_style
>>> from InquirerPy import inquirer
>>> style = get_style({"questionmark": "#ffffff", "answer": "#000000"}, style_override=False)
>>> result = inquirer.confirm(message="Confirm?", style=style).execute()
"""
if not style_override or style is None:
if not style:
style = {}
result = {
"questionmark": os.getenv("INQUIRERPY_STYLE_QUESTIONMARK", "#e5c07b"),
"answermark": os.getenv("INQUIRERPY_STYLE_ANSWERMARK", "#e5c07b"),
"answer": os.getenv("INQUIRERPY_STYLE_ANSWER", "#61afef"),
"input": os.getenv("INQUIRERPY_STYLE_INPUT", "#98c379"),
"question": os.getenv("INQUIRERPY_STYLE_QUESTION", ""),
"answered_question": os.getenv("INQUIRERPY_STYLE_ANSWERED_QUESTION", ""),
"instruction": os.getenv("INQUIRERPY_STYLE_INSTRUCTION", "#abb2bf"),
"long_instruction": os.getenv(
"INQUIRERPY_STYLE_LONG_INSTRUCTION", "#abb2bf"
),
"pointer": os.getenv("INQUIRERPY_STYLE_POINTER", "#61afef"),
"checkbox": os.getenv("INQUIRERPY_STYLE_CHECKBOX", "#98c379"),
"separator": os.getenv("INQUIRERPY_STYLE_SEPARATOR", ""),
"skipped": os.getenv("INQUIRERPY_STYLE_SKIPPED", "#5c6370"),
"validator": os.getenv("INQUIRERPY_STYLE_VALIDATOR", ""),
"marker": os.getenv("INQUIRERPY_STYLE_MARKER", "#e5c07b"),
"fuzzy_prompt": os.getenv("INQUIRERPY_STYLE_FUZZY_PROMPT", "#c678dd"),
"fuzzy_info": os.getenv("INQUIRERPY_STYLE_FUZZY_INFO", "#abb2bf"),
"fuzzy_border": os.getenv("INQUIRERPY_STYLE_FUZZY_BORDER", "#4b5263"),
"fuzzy_match": os.getenv("INQUIRERPY_STYLE_FUZZY_MATCH", "#c678dd"),
"spinner_pattern": os.getenv("INQUIRERPY_STYLE_SPINNER_PATTERN", "#e5c07b"),
"spinner_text": os.getenv("INQUIRERPY_STYLE_SPINNER_TEXT", ""),
**style,
}
else:
result = {
"questionmark": os.getenv("INQUIRERPY_STYLE_QUESTIONMARK", ""),
"answermark": os.getenv("INQUIRERPY_STYLE_ANSWERMARK", ""),
"answer": os.getenv("INQUIRERPY_STYLE_ANSWER", ""),
"input": os.getenv("INQUIRERPY_STYLE_INPUT", ""),
"question": os.getenv("INQUIRERPY_STYLE_QUESTION", ""),
"answered_question": os.getenv("INQUIRERPY_STYLE_ANSWERED_QUESTION", ""),
"instruction": os.getenv("INQUIRERPY_STYLE_INSTRUCTION", ""),
"long_instruction": os.getenv("INQUIRERPY_STYLE_LONG_INSTRUCTION", ""),
"pointer": os.getenv("INQUIRERPY_STYLE_POINTER", ""),
"checkbox": os.getenv("INQUIRERPY_STYLE_CHECKBOX", ""),
"separator": os.getenv("INQUIRERPY_STYLE_SEPARATOR", ""),
"skipped": os.getenv("INQUIRERPY_STYLE_SKIPPED", ""),
"validator": os.getenv("INQUIRERPY_STYLE_VALIDATOR", ""),
"marker": os.getenv("INQUIRERPY_STYLE_MARKER", ""),
"fuzzy_prompt": os.getenv("INQUIRERPY_STYLE_FUZZY_PROMPT", ""),
"fuzzy_info": os.getenv("INQUIRERPY_STYLE_FUZZY_INFO", ""),
"fuzzy_border": os.getenv("INQUIRERPY_STYLE_FUZZY_BORDER", ""),
"fuzzy_match": os.getenv("INQUIRERPY_STYLE_FUZZY_MATCH", ""),
"spinner_pattern": os.getenv("INQUIRERPY_STYLE_SPINNER_PATTERN", ""),
"spinner_text": os.getenv("INQUIRERPY_STYLE_SPINNER_TEXT", ""),
**style,
}
if result.get("fuzzy_border"):
result["frame.border"] = result.pop("fuzzy_border")
if result.get("validator"):
result["validation-toolbar"] = result.pop("validator")
result["bottom-toolbar"] = "noreverse"
return InquirerPyStyle(result)
def calculate_height(
height: Optional[Union[int, str]],
max_height: Optional[Union[int, str]],
height_offset: int = 2,
) -> Tuple[Optional[int], int]:
"""Calculate the `height` and `max_height` for the main question contents.
Tip:
The parameter `height`/`max_height` can be specified by either a :class:`string` or :class:`int`.
When `height`/`max_height` is :class:`str`:
It will set the height to a percentage based on the value provided.
You can optionally add the '%' sign which will be ignored while processing.
Example: "60%" or "60" (60% of the current terminal visible lines)
When `height`/`max_height` is :class:`int`:
It will set the height to exact number of lines based on the value provided.
Example: 20 (20 lines in terminal)
Note:
If `max_height` is not provided or is None, the default `max_height` will be configured to `70%` for
best visual presentation in the terminal.
Args:
height: The desired height in either percentage as string or exact value as int.
max_height: Maximum acceptable height in either percentage as string or exact value as int.
height_offset: Height offset to apply to the height.
Returns:
A :class:`tuple` with the first value being the desired height and the second value being
the maximum height.
Raises:
InvalidArgument: The provided `height`/`max_height` is not able to to be converted to int.
Examples:
>>> calculate_height(height="60%", max_height="100%")
"""
try:
_, term_lines = shutil.get_terminal_size()
term_lines = term_lines
if not height:
dimmension_height = None
else:
if isinstance(height, str):
height = height.replace("%", "")
height = int(height)
dimmension_height = (
math.floor(term_lines * (height / 100)) - height_offset
)
else:
dimmension_height = height
if not max_height:
max_height = "70%" if not height else "100%"
if isinstance(max_height, str):
max_height = max_height.replace("%", "")
max_height = int(max_height)
dimmension_max_height = (
math.floor(term_lines * (max_height / 100)) - height_offset
)
else:
dimmension_max_height = max_height
if dimmension_height and dimmension_height > dimmension_max_height:
dimmension_height = dimmension_max_height
if dimmension_height and dimmension_height <= 0:
dimmension_height = 1
if dimmension_max_height <= 0:
dimmension_max_height = 1
return dimmension_height, dimmension_max_height
except ValueError:
raise InvalidArgument(
"prompt argument height/max_height needs to be type of an int or str"
)
def patched_print(*values) -> None:
"""Patched :func:`print` that can print values without interrupting the prompt.
See Also:
:func:`print`
:func:`~prompt_toolkit.application.run_in_terminal`
Args:
*values: Refer to :func:`print`.
Examples:
>>> patched_print("Hello World")
"""
def _print():
print(*values)
run_in_terminal(_print)
def color_print(
formatted_text: List[Tuple[str, str]], style: Optional[Dict[str, str]] = None
) -> None:
"""Print colored text leveraging :func:`~prompt_toolkit.shortcuts.print_formatted_text`.
This function automatically handles printing the text without interrupting the
current prompt.
Args:
formatted_text: A list of formatted_text.
style: Style to apply to `formatted_text` in :class:`dictionary` form.
Example:
>>> color_print(formatted_text=[("class:aa", "hello "), ("class:bb", "world")], style={"aa": "red", "bb": "blue"})
>>> color_print([("red", "yes"), ("", " "), ("blue", "no")])
"""
def _print():
print_formatted_text(
FormattedText(formatted_text),
style=Style.from_dict(style) if style else None,
)
if get_app().is_running:
run_in_terminal(_print)
else:
_print()
+165
View File
@@ -0,0 +1,165 @@
"""Module contains pre-built validators."""
import re
from pathlib import Path
from typing import Optional
from prompt_toolkit.validation import ValidationError, Validator
__all__ = [
"PathValidator",
"EmptyInputValidator",
"PasswordValidator",
"NumberValidator",
]
class NumberValidator(Validator):
""":class:`~prompt_toolkit.validation.Validator` to validate if input is a number.
Args:
message: Error message to display in the validatation toolbar when validation failed.
float_allowed: Allow input to contain floating number (with decimal).
"""
def __init__(
self, message: str = "Input should be a number", float_allowed: bool = False
) -> None:
self._message = message
self._float_allowed = float_allowed
def validate(self, document) -> None:
"""Check if user input is a valid number.
This method is used internally by `prompt_toolkit <https://python-prompt-toolkit.readthedocs.io/en/master/>`_.
See Also:
https://python-prompt-toolkit.readthedocs.io/en/master/pages/asking_for_input.html?highlight=validator#input-validation
"""
try:
if self._float_allowed:
float(document.text)
else:
int(document.text)
except ValueError:
raise ValidationError(
message=self._message, cursor_position=document.cursor_position
)
class PathValidator(Validator):
""":class:`~prompt_toolkit.validation.Validator` to validate if input is a valid filepath on the system.
Args:
message: Error message to display in the validatation toolbar when validation failed.
is_file: Explicitly check if the input is a valid file on the system.
is_dir: Explicitly check if the input is a valid directory/folder on the system.
"""
def __init__(
self,
message: str = "Input is not a valid path",
is_file: bool = False,
is_dir: bool = False,
) -> None:
self._message = message
self._is_file = is_file
self._is_dir = is_dir
def validate(self, document) -> None:
"""Check if user input is a filepath that exists on the system based on conditions.
This method is used internally by `prompt_toolkit <https://python-prompt-toolkit.readthedocs.io/en/master/>`_.
See Also:
https://python-prompt-toolkit.readthedocs.io/en/master/pages/asking_for_input.html?highlight=validator#input-validation
"""
path = Path(document.text).expanduser()
if self._is_file and not path.is_file():
raise ValidationError(
message=self._message,
cursor_position=document.cursor_position,
)
elif self._is_dir and not path.is_dir():
raise ValidationError(
message=self._message,
cursor_position=document.cursor_position,
)
elif not path.exists():
raise ValidationError(
message=self._message,
cursor_position=document.cursor_position,
)
class EmptyInputValidator(Validator):
""":class:`~prompt_toolkit.validation.Validator` to validate if the input is empty.
Args:
message: Error message to display in the validatation toolbar when validation failed.
"""
def __init__(self, message: str = "Input cannot be empty") -> None:
self._message = message
def validate(self, document) -> None:
"""Check if user input is empty.
This method is used internally by `prompt_toolkit <https://python-prompt-toolkit.readthedocs.io/en/master/>`_.
See Also:
https://python-prompt-toolkit.readthedocs.io/en/master/pages/asking_for_input.html?highlight=validator#input-validation
"""
if not len(document.text) > 0:
raise ValidationError(
message=self._message,
cursor_position=document.cursor_position,
)
class PasswordValidator(Validator):
""":class:`~prompt_toolkit.validation.Validator` to validate password compliance.
Args:
message: Error message to display in the validatation toolbar when validation failed.
length: The minimum length of the password.
cap: Password should include at least one capital letter.
special: Password should include at least one special char "@$!%*#?&".
number: Password should include at least one number.
"""
def __init__(
self,
message: str = "Input is not compliant with the password constraints",
length: Optional[int] = None,
cap: bool = False,
special: bool = False,
number: bool = False,
) -> None:
password_pattern = r"^"
if cap:
password_pattern += r"(?=.*[A-Z])"
if special:
password_pattern += r"(?=.*[@$!%*#?&])"
if number:
password_pattern += r"(?=.*[0-9])"
password_pattern += r"."
if length:
password_pattern += r"{%s,}" % length
else:
password_pattern += r"*"
password_pattern += r"$"
self._re = re.compile(password_pattern)
self._message = message
def validate(self, document) -> None:
"""Check if user input is compliant with the specified password constraints.
This method is used internally by `prompt_toolkit <https://python-prompt-toolkit.readthedocs.io/en/master/>`_.
See Also:
https://python-prompt-toolkit.readthedocs.io/en/master/pages/asking_for_input.html?highlight=validator#input-validation
"""
if not self._re.match(document.text):
raise ValidationError(
message=self._message, cursor_position=document.cursor_position
)
+293
View File
@@ -0,0 +1,293 @@
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)
assert self.fp is not None
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")
+122
View File
@@ -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
+498
View File
@@ -0,0 +1,498 @@
"""
Blizzard Mipmap Format (.blp)
Jerome Leclanche <[email protected]>
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:
assert self.fp is not 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)
+517
View File
@@ -0,0 +1,517 @@
#
# 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"""
assert self.fp is not None
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
assert self.fp is not None
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")
+76
View File
@@ -0,0 +1,76 @@
#
# 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:
assert self.fp is not 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")
+173
View File
@@ -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()
+75
View File
@@ -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")
+84
View File
@@ -0,0 +1,84 @@
#
# 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
assert self.fp is not None
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")
+625
View File
@@ -0,0 +1,625 @@
"""
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
Jerome Leclanche <[email protected]>
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 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:
assert self.fp is not 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 = self.fp.read(header_size - 4)
if len(header) != 120:
msg = f"Incomplete header: {len(header)} bytes"
raise OSError(msg)
flags, height, width = struct.unpack("<3I", header[:12])
self._size = (width, height)
extents = (0, 0) + self.size
pitch, depth, mipmaps = struct.unpack("<3I", header[12:24])
struct.unpack("<11I", header[24:68]) # reserved
# pixel format
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84])
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[84 : 84 + 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)
if mask_totals[i]
else 0
)
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")
+481
View File
@@ -0,0 +1,481 @@
#
# 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:
assert self.fp is not 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:
assert self.fp is not None
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")
+382
View File
@@ -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
+152
View File
@@ -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"])
+184
View File
@@ -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"])
+134
View File
@@ -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])
+259
View File
@@ -0,0 +1,259 @@
#
# 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
assert self.fp is not None
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
assert self.fp is not None
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")
+115
View File
@@ -0,0 +1,115 @@
"""
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:
assert self.fp is not 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"])
+103
View File
@@ -0,0 +1,103 @@
#
# 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:
assert self.fp is not 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:
assert self.fp is not 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")
+102
View File
@@ -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
+153
View File
@@ -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))
+75
View File
@@ -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
+76
View File
@@ -0,0 +1,76 @@
#
# 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:
assert self.fp is not 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")
+76
View File
@@ -0,0 +1,76 @@
#
# 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:
assert self.fp is not 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"])
+402
View File
@@ -0,0 +1,402 @@
#
# 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:
assert self.fp is not 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")
+396
View File
@@ -0,0 +1,396 @@
#
# 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
# <[email protected]>.
# https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
#
# Copyright 2008 Bryan Davis
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# 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:
assert self.fp is not 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")
+390
View File
@@ -0,0 +1,390 @@
#
# 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.
assert self.fp is not None
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")
+4246
View File
File diff suppressed because it is too large. Load diff
+311
View File
@@ -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))
+1076
View File
File diff suppressed because it is too large. Load diff
+320
View File
@@ -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",
}
+1036
View File
File diff suppressed because it is too large. Load diff
+243
View File
@@ -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)
+113
View File
@@ -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"))
+938
View File
@@ -0,0 +1,938 @@
#
# 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
self.fp: IO[bytes] | None
self._fp: IO[bytes] | DeferredError
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
# Context manager support
def __enter__(self) -> ImageFile:
return self
def _close_fp(self) -> None:
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 __exit__(self, *args: object) -> None:
if getattr(self, "_exclusive_fp", False):
self._close_fp()
self.fp = None
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 and self.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
assert self.fp is not None
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
+607
View File
@@ -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,
)
+1320
View File
File diff suppressed because it is too large. Load diff
+224
View File
@@ -0,0 +1,224 @@
#
# 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 window:
args += ["-l", str(window)]
elif 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:
if window:
# Determine if the window was in Retina mode or not
# by capturing it without the shadow,
# and checking how different the width is
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
subprocess.call(
["screencapture", "-l", str(window), "-o", "-x", filepath]
)
with Image.open(filepath) as im_no_shadow:
retina = im.width - im_no_shadow.width > 100
os.unlink(filepath)
# Since screencapture's -R does not work with -l,
# crop the image manually
if retina:
left, top, right, bottom = bbox
im_cropped = im.resize(
(right - left, bottom - top),
box=tuple(coord * 2 for coord in bbox),
)
else:
im_cropped = im.crop(bbox)
im.close()
return im_cropped
else:
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
+314
View File
@@ -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
+85
View File
@@ -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)
+317
View File
@@ -0,0 +1,317 @@
# A binary morphology add-on for the Python Imaging Library
#
# History:
# 2014-06-04 Initial version.
#
# Copyright (c) 2014 Dov Grobgeld <[email protected]>
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:
"""
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8" or "edge".
:exception Exception: If the op_name is not recognized.
"""
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]
elif patterns is not None:
self.patterns = patterns
else:
self.patterns = []
def add_patterns(self, patterns: list[str]) -> None:
"""
Append to list of patterns.
:param patterns: Additional patterns.
"""
self.patterns += patterns
def build_default_lut(self) -> bytearray:
"""
Set the current LUT, and return it.
This is the default LUT that patterns will be applied against when building.
"""
symbols = [0, 1]
m = 1 << 4 # pos of current pixel
self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE))
return self.lut
def get_lut(self) -> bytearray | None:
"""
Returns the current LUT
"""
return self.lut
def _string_permute(self, pattern: str, permutation: list[int]) -> str:
"""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]]:
"""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, and return it.
This is the data to be passed into MorphOp."""
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 found takes priority
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.
If the LUT is not provided, then it is built using LutBuilder from the op_name
or the patterns.
:param lut: The LUT data.
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8", "edge".
:exception Exception: If the op_name is not recognized.
"""
if patterns is None and op_name is None:
self.lut = lut
else:
self.lut = LutBuilder(patterns, op_name).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.
:param image: A 1-mode or L-mode image.
:exception Exception: If the current operator is None.
:exception ValueError: If the image is not 1 or L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or L"
raise ValueError(msg)
outimage = Image.new(image.mode, image.size)
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`.
:param image: A 1-mode or L-mode image.
:exception Exception: If the current operator is None.
:exception ValueError: If the image is not 1 or L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or 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 1 or L mode image.
Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See
:ref:`coordinate-system`.
:param image: A 1-mode or L-mode image.
:exception ValueError: If the image is not 1 or L mode."""
if image.mode not in ("1", "L"):
msg = "Image mode must be 1 or 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
:param filename: The file to read from.
:exception Exception: If the length of the file data is not 512.
"""
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.
:param filename: The destination file.
:exception Exception: If the current operator is None.
"""
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
:param lut: A new LUT.
"""
self.lut = lut
+746
View File
@@ -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
# [email protected]
# 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
+287
View File
@@ -0,0 +1,287 @@
#
# 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) // len(self.mode)
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
mode_len = len(self.mode)
if index * mode_len < len(self.palette):
self._palette = (
self._palette[: index * mode_len]
+ bytes(color)
+ self._palette[index * mode_len + mode_len :]
)
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
+20
View File
@@ -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
+219
View File
@@ -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 ([email protected])
#
# 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
+88
View File
@@ -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
+362
View File
@@ -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:]))
+167
View File
@@ -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
+320
View File
@@ -0,0 +1,320 @@
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) -> float:
"""
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.
"""
if isinstance(self.text, str):
multiline = "\n" in self.text
else:
multiline = b"\n" in self.text
if multiline:
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]),
)
assert bbox is not None
return bbox
+266
View File
@@ -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
+136
View File
@@ -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
+247
View File
@@ -0,0 +1,247 @@
#
# The Python Imaging Library.
# $Id$
#
# a Windows DIB display interface
#
# History:
# 1996-05-20 fl Created
# 1996-09-20 fl Fixed subregion exposure
# 1997-09-21 fl Added draw primitive (for tzPrint)
# 2003-05-21 fl Added experimental Window/ImageWindow classes
# 2003-09-05 fl Added fromstring/tostring methods
#
# Copyright (c) Secret Labs AB 1997-2003.
# Copyright (c) Fredrik Lundh 1996-2003.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image
class HDC:
"""
Wraps an HDC integer. The resulting object can be passed to the
:py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose`
methods.
"""
def __init__(self, dc: int) -> None:
self.dc = dc
def __int__(self) -> int:
return self.dc
class HWND:
"""
Wraps an HWND integer. The resulting object can be passed to the
:py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose`
methods, instead of a DC.
"""
def __init__(self, wnd: int) -> None:
self.wnd = wnd
def __int__(self) -> int:
return self.wnd
class Dib:
"""
A Windows bitmap with the given mode and size. The mode can be one of "1",
"L", "P", or "RGB".
If the display requires a palette, this constructor creates a suitable
palette and associates it with the image. For an "L" image, 128 graylevels
are allocated. For an "RGB" image, a 6x6x6 colour cube is used, together
with 20 graylevels.
To make sure that palettes work properly under Windows, you must call the
``palette`` method upon certain events from Windows.
:param image: Either a PIL image, or a mode string. If a mode string is
used, a size must also be given. The mode can be one of "1",
"L", "P", or "RGB".
:param size: If the first argument is a mode string, this
defines the size of the image.
"""
def __init__(
self, image: Image.Image | str, size: tuple[int, int] | None = None
) -> None:
if isinstance(image, str):
mode = image
image = ""
if size is None:
msg = "If first argument is mode, size is required"
raise ValueError(msg)
else:
mode = image.mode
size = image.size
if mode not in ["1", "L", "P", "RGB"]:
mode = Image.getmodebase(mode)
self.image = Image.core.display(mode, size)
self.mode = mode
self.size = size
if image:
assert not isinstance(image, str)
self.paste(image)
def expose(self, handle: int | HDC | HWND) -> None:
"""
Copy the bitmap contents to a device context.
:param handle: Device context (HDC), cast to a Python integer, or an
HDC or HWND instance. In PythonWin, you can use
``CDC.GetHandleAttrib()`` to get a suitable handle.
"""
handle_int = int(handle)
if isinstance(handle, HWND):
dc = self.image.getdc(handle_int)
try:
self.image.expose(dc)
finally:
self.image.releasedc(handle_int, dc)
else:
self.image.expose(handle_int)
def draw(
self,
handle: int | HDC | HWND,
dst: tuple[int, int, int, int],
src: tuple[int, int, int, int] | None = None,
) -> None:
"""
Same as expose, but allows you to specify where to draw the image, and
what part of it to draw.
The destination and source areas are given as 4-tuple rectangles. If
the source is omitted, the entire image is copied. If the source and
the destination have different sizes, the image is resized as
necessary.
"""
if src is None:
src = (0, 0) + self.size
handle_int = int(handle)
if isinstance(handle, HWND):
dc = self.image.getdc(handle_int)
try:
self.image.draw(dc, dst, src)
finally:
self.image.releasedc(handle_int, dc)
else:
self.image.draw(handle_int, dst, src)
def query_palette(self, handle: int | HDC | HWND) -> int:
"""
Installs the palette associated with the image in the given device
context.
This method should be called upon **QUERYNEWPALETTE** and
**PALETTECHANGED** events from Windows. If this method returns a
non-zero value, one or more display palette entries were changed, and
the image should be redrawn.
:param handle: Device context (HDC), cast to a Python integer, or an
HDC or HWND instance.
:return: The number of entries that were changed (if one or more entries,
this indicates that the image should be redrawn).
"""
handle_int = int(handle)
if isinstance(handle, HWND):
handle = self.image.getdc(handle_int)
try:
result = self.image.query_palette(handle)
finally:
self.image.releasedc(handle, handle)
else:
result = self.image.query_palette(handle_int)
return result
def paste(
self, im: Image.Image, box: tuple[int, int, int, int] | None = None
) -> None:
"""
Paste a PIL image into the bitmap image.
: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.
:param box: A 4-tuple defining the left, upper, right, and
lower pixel coordinate. See :ref:`coordinate-system`. If
None is given instead of a tuple, all of the image is
assumed.
"""
im.load()
if self.mode != im.mode:
im = im.convert(self.mode)
if box:
self.image.paste(im.im, box)
else:
self.image.paste(im.im)
def frombytes(self, buffer: bytes) -> None:
"""
Load display memory contents from byte data.
:param buffer: A buffer containing display data (usually
data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`)
"""
self.image.frombytes(buffer)
def tobytes(self) -> bytes:
"""
Copy display memory contents to bytes object.
:return: A bytes object containing display data.
"""
return self.image.tobytes()
class Window:
"""Create a Window with the given title size."""
def __init__(
self, title: str = "PIL", width: int | None = None, height: int | None = None
) -> None:
self.hwnd = Image.core.createwindow(
title, self.__dispatcher, width or 0, height or 0
)
def __dispatcher(self, action: str, *args: int) -> None:
getattr(self, f"ui_handle_{action}")(*args)
def ui_handle_clear(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
pass
def ui_handle_damage(self, x0: int, y0: int, x1: int, y1: int) -> None:
pass
def ui_handle_destroy(self) -> None:
pass
def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
pass
def ui_handle_resize(self, width: int, height: int) -> None:
pass
def mainloop(self) -> None:
Image.core.eventloop()
class ImageWindow(Window):
"""Create an image window which displays the given image."""
def __init__(self, image: Image.Image | Dib, title: str = "PIL") -> None:
if not isinstance(image, Dib):
image = Dib(image)
self.image = image
width, height = image.size
super().__init__(title, width=width, height=height)
def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
self.image.draw(dc, (x0, y0, x1, y1))
+103
View File
@@ -0,0 +1,103 @@
#
# The Python Imaging Library.
# $Id$
#
# IM Tools support for PIL
#
# history:
# 1996-05-27 fl Created (read 8-bit images only)
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.2)
#
# Copyright (c) Secret Labs AB 1997-2001.
# Copyright (c) Fredrik Lundh 1996-2001.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
from . import Image, ImageFile
#
# --------------------------------------------------------------------
field = re.compile(rb"([a-z]*) ([^ \r\n]*)")
##
# Image plugin for IM Tools images.
class ImtImageFile(ImageFile.ImageFile):
format = "IMT"
format_description = "IM Tools"
def _open(self) -> None:
# Quick rejection: if there's not a LF among the first
# 100 bytes, this is (probably) not a text header.
assert self.fp is not None
buffer = self.fp.read(100)
if b"\n" not in buffer:
msg = "not an IM file"
raise SyntaxError(msg)
xsize = ysize = 0
while True:
if buffer:
s = buffer[:1]
buffer = buffer[1:]
else:
s = self.fp.read(1)
if not s:
break
if s == b"\x0c":
# image data begins
self.tile = [
ImageFile._Tile(
"raw",
(0, 0) + self.size,
self.fp.tell() - len(buffer),
self.mode,
)
]
break
else:
# read key/value pair
if b"\n" not in buffer:
buffer += self.fp.read(100)
lines = buffer.split(b"\n")
s += lines.pop(0)
buffer = b"\n".join(lines)
if len(s) == 1 or len(s) > 100:
break
if s[0] == ord(b"*"):
continue # comment
m = field.match(s)
if not m:
break
k, v = m.group(1, 2)
if k == b"width":
xsize = int(v)
self._size = xsize, ysize
elif k == b"height":
ysize = int(v)
self._size = xsize, ysize
elif k == b"pixel" and v == b"n8":
self._mode = "L"
#
# --------------------------------------------------------------------
Image.register_open(ImtImageFile.format, ImtImageFile)
#
# no extension registered (".im" is simply too common)
+233
View File
@@ -0,0 +1,233 @@
#
# The Python Imaging Library.
# $Id$
#
# IPTC/NAA file handling
#
# history:
# 1995-10-01 fl Created
# 1998-03-09 fl Cleaned up and added to PIL
# 2002-06-18 fl Added getiptcinfo helper
#
# Copyright (c) Secret Labs AB 1997-2002.
# Copyright (c) Fredrik Lundh 1995.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from io import BytesIO
from typing import cast
from . import Image, ImageFile
from ._binary import i16be as i16
from ._binary import i32be as i32
COMPRESSION = {1: "raw", 5: "jpeg"}
#
# Helpers
def _i(c: bytes) -> int:
return i32((b"\0\0\0\0" + c)[-4:])
##
# Image plugin for IPTC/NAA datastreams. To read IPTC/NAA fields
# from TIFF and JPEG files, use the <b>getiptcinfo</b> function.
class IptcImageFile(ImageFile.ImageFile):
format = "IPTC"
format_description = "IPTC/NAA"
def getint(self, key: tuple[int, int]) -> int:
return _i(self.info[key])
def field(self) -> tuple[tuple[int, int] | None, int]:
#
# get a IPTC field header
assert self.fp is not None
s = self.fp.read(5)
if not s.strip(b"\x00"):
return None, 0
tag = s[1], s[2]
# syntax
if s[0] != 0x1C or tag[0] not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 240]:
msg = "invalid IPTC/NAA file"
raise SyntaxError(msg)
# field size
size = s[3]
if size > 132:
msg = "illegal field length in IPTC/NAA file"
raise OSError(msg)
elif size == 128:
size = 0
elif size > 128:
size = _i(self.fp.read(size - 128))
else:
size = i16(s, 3)
return tag, size
def _open(self) -> None:
# load descriptive fields
assert self.fp is not None
while True:
offset = self.fp.tell()
tag, size = self.field()
if not tag or tag == (8, 10):
break
if size:
tagdata = self.fp.read(size)
else:
tagdata = None
if tag in self.info:
if isinstance(self.info[tag], list):
self.info[tag].append(tagdata)
else:
self.info[tag] = [self.info[tag], tagdata]
else:
self.info[tag] = tagdata
# mode
layers = self.info[(3, 60)][0]
component = self.info[(3, 60)][1]
if layers == 1 and not component:
self._mode = "L"
band = None
else:
if layers == 3 and component:
self._mode = "RGB"
elif layers == 4 and component:
self._mode = "CMYK"
if (3, 65) in self.info:
band = self.info[(3, 65)][0] - 1
else:
band = 0
# size
self._size = self.getint((3, 20)), self.getint((3, 30))
# compression
try:
compression = COMPRESSION[self.getint((3, 120))]
except KeyError as e:
msg = "Unknown IPTC image compression"
raise OSError(msg) from e
# tile
if tag == (8, 10):
self.tile = [
ImageFile._Tile("iptc", (0, 0) + self.size, offset, (compression, band))
]
def load(self) -> Image.core.PixelAccess | None:
if self.tile:
args = self.tile[0].args
assert isinstance(args, tuple)
compression, band = args
assert self.fp is not None
self.fp.seek(self.tile[0].offset)
# Copy image data to temporary file
o = BytesIO()
if compression == "raw":
# To simplify access to the extracted file,
# prepend a PPM header
o.write(b"P5\n%d %d\n255\n" % self.size)
while True:
type, size = self.field()
if type != (8, 10):
break
while size > 0:
s = self.fp.read(min(size, 8192))
if not s:
break
o.write(s)
size -= len(s)
with Image.open(o) as _im:
if band is not None:
bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode)
bands[band] = _im
im = Image.merge(self.mode, bands)
else:
im = _im
im.load()
self.im = im.im
self.tile = []
return ImageFile.ImageFile.load(self)
Image.register_open(IptcImageFile.format, IptcImageFile)
Image.register_extension(IptcImageFile.format, ".iim")
def getiptcinfo(
im: ImageFile.ImageFile,
) -> dict[tuple[int, int], bytes | list[bytes]] | None:
"""
Get IPTC information from TIFF, JPEG, or IPTC file.
:param im: An image containing IPTC data.
:returns: A dictionary containing IPTC information, or None if
no IPTC information block was found.
"""
from . import JpegImagePlugin, TiffImagePlugin
data = None
info: dict[tuple[int, int], bytes | list[bytes]] = {}
if isinstance(im, IptcImageFile):
# return info dictionary right away
for k, v in im.info.items():
if isinstance(k, tuple):
info[k] = v
return info
elif isinstance(im, JpegImagePlugin.JpegImageFile):
# extract the IPTC/NAA resource
photoshop = im.info.get("photoshop")
if photoshop:
data = photoshop.get(0x0404)
elif isinstance(im, TiffImagePlugin.TiffImageFile):
# get raw data from the IPTC/NAA tag (PhotoShop tags the data
# as 4-byte integers, so we cannot use the get method...)
try:
data = im.tag_v2._tagdata[TiffImagePlugin.IPTC_NAA_CHUNK]
except KeyError:
pass
if data is None:
return None # no properties
# create an IptcImagePlugin object without initializing it
class FakeImage:
pass
fake_im = FakeImage()
fake_im.__class__ = IptcImageFile # type: ignore[assignment]
iptc_im = cast(IptcImageFile, fake_im)
# parse the IPTC information chunk
iptc_im.info = {}
iptc_im.fp = BytesIO(data)
try:
iptc_im._open()
except (IndexError, KeyError):
pass # expected failure
for k, v in iptc_im.info.items():
if isinstance(k, tuple):
info[k] = v
return info
+448
View File
@@ -0,0 +1,448 @@
#
# The Python Imaging Library
# $Id$
#
# JPEG2000 file handling
#
# History:
# 2014-03-12 ajh Created
# 2021-06-30 rogermb Extract dpi information from the 'resc' header box
#
# Copyright (c) 2014 Coriolis Systems Limited
# Copyright (c) 2014 Alastair Houghton
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
import struct
from typing import cast
from . import Image, ImageFile, ImagePalette, _binary
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import IO
class BoxReader:
"""
A small helper class to read fields stored in JPEG2000 header boxes
and to easily step into and read sub-boxes.
"""
def __init__(self, fp: IO[bytes], length: int = -1) -> None:
self.fp = fp
self.has_length = length >= 0
self.length = length
self.remaining_in_box = -1
def _can_read(self, num_bytes: int) -> bool:
if self.has_length and self.fp.tell() + num_bytes > self.length:
# Outside box: ensure we don't read past the known file length
return False
if self.remaining_in_box >= 0:
# Inside box contents: ensure read does not go past box boundaries
return num_bytes <= self.remaining_in_box
else:
return True # No length known, just read
def _read_bytes(self, num_bytes: int) -> bytes:
if not self._can_read(num_bytes):
msg = "Not enough data in header"
raise SyntaxError(msg)
data = self.fp.read(num_bytes)
if len(data) < num_bytes:
msg = f"Expected to read {num_bytes} bytes but only got {len(data)}."
raise OSError(msg)
if self.remaining_in_box > 0:
self.remaining_in_box -= num_bytes
return data
def read_fields(self, field_format: str) -> tuple[int | bytes, ...]:
size = struct.calcsize(field_format)
data = self._read_bytes(size)
return struct.unpack(field_format, data)
def read_boxes(self) -> BoxReader:
size = self.remaining_in_box
data = self._read_bytes(size)
return BoxReader(io.BytesIO(data), size)
def has_next_box(self) -> bool:
if self.has_length:
return self.fp.tell() + self.remaining_in_box < self.length
else:
return True
def next_box_type(self) -> bytes:
# Skip the rest of the box if it has not been read
if self.remaining_in_box > 0:
self.fp.seek(self.remaining_in_box, os.SEEK_CUR)
self.remaining_in_box = -1
# Read the length and type of the next box
lbox, tbox = cast(tuple[int, bytes], self.read_fields(">I4s"))
if lbox == 1:
lbox = cast(int, self.read_fields(">Q")[0])
hlen = 16
else:
hlen = 8
if lbox < hlen or not self._can_read(lbox - hlen):
msg = "Invalid header length"
raise SyntaxError(msg)
self.remaining_in_box = lbox - hlen
return tbox
def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]:
"""Parse the JPEG 2000 codestream to extract the size and component
count from the SIZ marker segment, returning a PIL (size, mode) tuple."""
hdr = fp.read(2)
lsiz = _binary.i16be(hdr)
siz = hdr + fp.read(lsiz - 2)
lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from(
">HHIIIIIIIIH", siz
)
size = (xsiz - xosiz, ysiz - yosiz)
if csiz == 1:
ssiz = struct.unpack_from(">B", siz, 38)
if (ssiz[0] & 0x7F) + 1 > 8:
mode = "I;16"
else:
mode = "L"
elif csiz == 2:
mode = "LA"
elif csiz == 3:
mode = "RGB"
elif csiz == 4:
mode = "RGBA"
else:
msg = "unable to determine J2K image mode"
raise SyntaxError(msg)
return size, mode
def _res_to_dpi(num: int, denom: int, exp: int) -> float | None:
"""Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution,
calculated as (num / denom) * 10^exp and stored in dots per meter,
to floating-point dots per inch."""
if denom == 0:
return None
return (254 * num * (10**exp)) / (10000 * denom)
def _parse_jp2_header(
fp: IO[bytes],
) -> tuple[
tuple[int, int],
str,
str | None,
tuple[float, float] | None,
ImagePalette.ImagePalette | None,
]:
"""Parse the JP2 header box to extract size, component count,
color space information, and optionally DPI information,
returning a (size, mode, mimetype, dpi) tuple."""
# Find the JP2 header box
reader = BoxReader(fp)
header = None
mimetype = None
while reader.has_next_box():
tbox = reader.next_box_type()
if tbox == b"jp2h":
header = reader.read_boxes()
break
elif tbox == b"ftyp":
if reader.read_fields(">4s")[0] == b"jpx ":
mimetype = "image/jpx"
assert header is not None
size = None
mode = None
bpc = None
nc = None
dpi = None # 2-tuple of DPI info, or None
palette = None
while header.has_next_box():
tbox = header.next_box_type()
if tbox == b"ihdr":
height, width, nc, bpc = header.read_fields(">IIHB")
assert isinstance(height, int)
assert isinstance(width, int)
assert isinstance(bpc, int)
size = (width, height)
if nc == 1 and (bpc & 0x7F) > 8:
mode = "I;16"
elif nc == 1:
mode = "L"
elif nc == 2:
mode = "LA"
elif nc == 3:
mode = "RGB"
elif nc == 4:
mode = "RGBA"
elif tbox == b"colr" and nc == 4:
meth, _, _, enumcs = header.read_fields(">BBBI")
if meth == 1 and enumcs == 12:
mode = "CMYK"
elif tbox == b"pclr" and mode in ("L", "LA"):
ne, npc = header.read_fields(">HB")
assert isinstance(ne, int)
assert isinstance(npc, int)
max_bitdepth = 0
for bitdepth in header.read_fields(">" + ("B" * npc)):
assert isinstance(bitdepth, int)
if bitdepth > max_bitdepth:
max_bitdepth = bitdepth
if max_bitdepth <= 8:
palette = ImagePalette.ImagePalette("RGBA" if npc == 4 else "RGB")
for i in range(ne):
color: list[int] = []
for value in header.read_fields(">" + ("B" * npc)):
assert isinstance(value, int)
color.append(value)
palette.getcolor(tuple(color))
mode = "P" if mode == "L" else "PA"
elif tbox == b"res ":
res = header.read_boxes()
while res.has_next_box():
tres = res.next_box_type()
if tres == b"resc":
vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB")
assert isinstance(vrcn, int)
assert isinstance(vrcd, int)
assert isinstance(hrcn, int)
assert isinstance(hrcd, int)
assert isinstance(vrce, int)
assert isinstance(hrce, int)
hres = _res_to_dpi(hrcn, hrcd, hrce)
vres = _res_to_dpi(vrcn, vrcd, vrce)
if hres is not None and vres is not None:
dpi = (hres, vres)
break
if size is None or mode is None:
msg = "Malformed JP2 header"
raise SyntaxError(msg)
return size, mode, mimetype, dpi, palette
##
# Image plugin for JPEG2000 images.
class Jpeg2KImageFile(ImageFile.ImageFile):
format = "JPEG2000"
format_description = "JPEG 2000 (ISO 15444)"
def _open(self) -> None:
assert self.fp is not None
sig = self.fp.read(4)
if sig == b"\xff\x4f\xff\x51":
self.codec = "j2k"
self._size, self._mode = _parse_codestream(self.fp)
self._parse_comment()
else:
sig = sig + self.fp.read(8)
if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a":
self.codec = "jp2"
header = _parse_jp2_header(self.fp)
self._size, self._mode, self.custom_mimetype, dpi, self.palette = header
if dpi is not None:
self.info["dpi"] = dpi
if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"):
hdr = self.fp.read(2)
length = _binary.i16be(hdr)
self.fp.seek(length - 2, os.SEEK_CUR)
self._parse_comment()
else:
msg = "not a JPEG 2000 file"
raise SyntaxError(msg)
self._reduce = 0
self.layers = 0
fd = -1
length = -1
try:
fd = self.fp.fileno()
length = os.fstat(fd).st_size
except Exception:
fd = -1
try:
pos = self.fp.tell()
self.fp.seek(0, io.SEEK_END)
length = self.fp.tell()
self.fp.seek(pos)
except Exception:
length = -1
self.tile = [
ImageFile._Tile(
"jpeg2k",
(0, 0) + self.size,
0,
(self.codec, self._reduce, self.layers, fd, length),
)
]
def _parse_comment(self) -> None:
assert self.fp is not None
while True:
marker = self.fp.read(2)
if not marker:
break
typ = marker[1]
if typ in (0x90, 0xD9):
# Start of tile or end of codestream
break
hdr = self.fp.read(2)
length = _binary.i16be(hdr)
if typ == 0x64:
# Comment
self.info["comment"] = self.fp.read(length - 2)[2:]
break
else:
self.fp.seek(length - 2, os.SEEK_CUR)
@property # type: ignore[override]
def reduce(
self,
) -> (
Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image]
| int
):
# https://github.com/python-pillow/Pillow/issues/4343 found that the
# new Image 'reduce' method was shadowed by this plugin's 'reduce'
# property. This attempts to allow for both scenarios
return self._reduce or super().reduce
@reduce.setter
def reduce(self, value: int) -> None:
self._reduce = value
def load(self) -> Image.core.PixelAccess | None:
if self.tile and self._reduce:
power = 1 << self._reduce
adjust = power >> 1
self._size = (
int((self.size[0] + adjust) / power),
int((self.size[1] + adjust) / power),
)
# Update the reduce and layers settings
t = self.tile[0]
assert isinstance(t[3], tuple)
t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4])
self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)]
return ImageFile.ImageFile.load(self)
def _accept(prefix: bytes) -> bool:
return prefix.startswith(
(b"\xff\x4f\xff\x51", b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a")
)
# ------------------------------------------------------------
# Save support
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
# Get the keyword arguments
info = im.encoderinfo
if isinstance(filename, str):
filename = filename.encode()
if filename.endswith(b".j2k") or info.get("no_jp2", False):
kind = "j2k"
else:
kind = "jp2"
offset = info.get("offset", None)
tile_offset = info.get("tile_offset", None)
tile_size = info.get("tile_size", None)
quality_mode = info.get("quality_mode", "rates")
quality_layers = info.get("quality_layers", None)
if quality_layers is not None and not (
isinstance(quality_layers, (list, tuple))
and all(
isinstance(quality_layer, (int, float)) for quality_layer in quality_layers
)
):
msg = "quality_layers must be a sequence of numbers"
raise ValueError(msg)
num_resolutions = info.get("num_resolutions", 0)
cblk_size = info.get("codeblock_size", None)
precinct_size = info.get("precinct_size", None)
irreversible = info.get("irreversible", False)
progression = info.get("progression", "LRCP")
cinema_mode = info.get("cinema_mode", "no")
mct = info.get("mct", 0)
signed = info.get("signed", False)
comment = info.get("comment")
if isinstance(comment, str):
comment = comment.encode()
plt = info.get("plt", False)
fd = -1
if hasattr(fp, "fileno"):
try:
fd = fp.fileno()
except Exception:
fd = -1
im.encoderconfig = (
offset,
tile_offset,
tile_size,
quality_mode,
quality_layers,
num_resolutions,
cblk_size,
precinct_size,
irreversible,
progression,
cinema_mode,
mct,
signed,
fd,
comment,
plt,
)
ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)])
# ------------------------------------------------------------
# Registry stuff
Image.register_open(Jpeg2KImageFile.format, Jpeg2KImageFile, _accept)
Image.register_save(Jpeg2KImageFile.format, _save)
Image.register_extensions(
Jpeg2KImageFile.format, [".jp2", ".j2k", ".jpc", ".jpf", ".jpx", ".j2c"]
)
Image.register_mime(Jpeg2KImageFile.format, "image/jp2")
+895
View File
@@ -0,0 +1,895 @@
#
# The Python Imaging Library.
# $Id$
#
# JPEG (JFIF) file handling
#
# See "Digital Compression and Coding of Continuous-Tone Still Images,
# Part 1, Requirements and Guidelines" (CCITT T.81 / ISO 10918-1)
#
# History:
# 1995-09-09 fl Created
# 1995-09-13 fl Added full parser
# 1996-03-25 fl Added hack to use the IJG command line utilities
# 1996-05-05 fl Workaround Photoshop 2.5 CMYK polarity bug
# 1996-05-28 fl Added draft support, JFIF version (0.1)
# 1996-12-30 fl Added encoder options, added progression property (0.2)
# 1997-08-27 fl Save mode 1 images as BW (0.3)
# 1998-07-12 fl Added YCbCr to draft and save methods (0.4)
# 1998-10-19 fl Don't hang on files using 16-bit DQT's (0.4.1)
# 2001-04-16 fl Extract DPI settings from JFIF files (0.4.2)
# 2002-07-01 fl Skip pad bytes before markers; identify Exif files (0.4.3)
# 2003-04-25 fl Added experimental EXIF decoder (0.5)
# 2003-06-06 fl Added experimental EXIF GPSinfo decoder
# 2003-09-13 fl Extract COM markers
# 2009-09-06 fl Added icc_profile support (from Florian Hoech)
# 2009-03-06 fl Changed CMYK handling; always use Adobe polarity (0.6)
# 2009-03-08 fl Added subsampling support (from Justin Huff).
#
# Copyright (c) 1997-2003 by Secret Labs AB.
# Copyright (c) 1995-1996 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import array
import io
import math
import os
import struct
import subprocess
import sys
import tempfile
import warnings
from . import Image, ImageFile
from ._binary import i16be as i16
from ._binary import i32be as i32
from ._binary import o8
from ._binary import o16be as o16
from .JpegPresets import presets
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO, Any
from .MpoImagePlugin import MpoImageFile
#
# Parser
def Skip(self: JpegImageFile, marker: int) -> None:
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
ImageFile._safe_read(self.fp, n)
def APP(self: JpegImageFile, marker: int) -> None:
#
# Application marker. Store these in the APP dictionary.
# Also look for well-known application markers.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
app = f"APP{marker & 15}"
self.app[app] = s # compatibility
self.applist.append((app, s))
if marker == 0xFFE0 and s.startswith(b"JFIF"):
# extract JFIF information
self.info["jfif"] = version = i16(s, 5) # version
self.info["jfif_version"] = divmod(version, 256)
# extract JFIF properties
try:
jfif_unit = s[7]
jfif_density = i16(s, 8), i16(s, 10)
except Exception:
pass
else:
if jfif_unit == 1:
self.info["dpi"] = jfif_density
elif jfif_unit == 2: # cm
# 1 dpcm = 2.54 dpi
self.info["dpi"] = tuple(d * 2.54 for d in jfif_density)
self.info["jfif_unit"] = jfif_unit
self.info["jfif_density"] = jfif_density
elif marker == 0xFFE1 and s.startswith(b"Exif\0\0"):
# extract EXIF information
if "exif" in self.info:
self.info["exif"] += s[6:]
else:
self.info["exif"] = s
self._exif_offset = self.fp.tell() - n + 6
elif marker == 0xFFE1 and s.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
self.info["xmp"] = s.split(b"\x00", 1)[1]
elif marker == 0xFFE2 and s.startswith(b"FPXR\0"):
# extract FlashPix information (incomplete)
self.info["flashpix"] = s # FIXME: value will change
elif marker == 0xFFE2 and s.startswith(b"ICC_PROFILE\0"):
# Since an ICC profile can be larger than the maximum size of
# a JPEG marker (64K), we need provisions to split it into
# multiple markers. The format defined by the ICC specifies
# one or more APP2 markers containing the following data:
# Identifying string ASCII "ICC_PROFILE\0" (12 bytes)
# Marker sequence number 1, 2, etc (1 byte)
# Number of markers Total of APP2's used (1 byte)
# Profile data (remainder of APP2 data)
# Decoders should use the marker sequence numbers to
# reassemble the profile, rather than assuming that the APP2
# markers appear in the correct sequence.
self.icclist.append(s)
elif marker == 0xFFED and s.startswith(b"Photoshop 3.0\x00"):
# parse the image resource block
offset = 14
photoshop = self.info.setdefault("photoshop", {})
while s[offset : offset + 4] == b"8BIM":
try:
offset += 4
# resource code
code = i16(s, offset)
offset += 2
# resource name (usually empty)
name_len = s[offset]
# name = s[offset+1:offset+1+name_len]
offset += 1 + name_len
offset += offset & 1 # align
# resource data block
size = i32(s, offset)
offset += 4
data = s[offset : offset + size]
if code == 0x03ED: # ResolutionInfo
photoshop[code] = {
"XResolution": i32(data, 0) / 65536,
"DisplayedUnitsX": i16(data, 4),
"YResolution": i32(data, 8) / 65536,
"DisplayedUnitsY": i16(data, 12),
}
else:
photoshop[code] = data
offset += size
offset += offset & 1 # align
except struct.error:
break # insufficient data
elif marker == 0xFFEE and s.startswith(b"Adobe"):
self.info["adobe"] = i16(s, 5)
# extract Adobe custom properties
try:
adobe_transform = s[11]
except IndexError:
pass
else:
self.info["adobe_transform"] = adobe_transform
elif marker == 0xFFE2 and s.startswith(b"MPF\0"):
# extract MPO information
self.info["mp"] = s[4:]
# offset is current location minus buffer size
# plus constant header size
self.info["mpoffset"] = self.fp.tell() - n + 4
def COM(self: JpegImageFile, marker: int) -> None:
#
# Comment marker. Store these in the APP dictionary.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
self.info["comment"] = s
self.app["COM"] = s # compatibility
self.applist.append(("COM", s))
def SOF(self: JpegImageFile, marker: int) -> None:
#
# Start of frame marker. Defines the size and mode of the
# image. JPEG is colour blind, so we use some simple
# heuristics to map the number of layers to an appropriate
# mode. Note that this could be made a bit brighter, by
# looking for JFIF and Adobe APP markers.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
self._size = i16(s, 3), i16(s, 1)
if self._im is not None and self.size != self.im.size:
self._im = None
self.bits = s[0]
if self.bits != 8:
msg = f"cannot handle {self.bits}-bit layers"
raise SyntaxError(msg)
self.layers = s[5]
if self.layers == 1:
self._mode = "L"
elif self.layers == 3:
self._mode = "RGB"
elif self.layers == 4:
self._mode = "CMYK"
else:
msg = f"cannot handle {self.layers}-layer images"
raise SyntaxError(msg)
if marker in [0xFFC2, 0xFFC6, 0xFFCA, 0xFFCE]:
self.info["progressive"] = self.info["progression"] = 1
if self.icclist:
# fixup icc profile
self.icclist.sort() # sort by sequence number
if self.icclist[0][13] == len(self.icclist):
profile = [p[14:] for p in self.icclist]
icc_profile = b"".join(profile)
else:
icc_profile = None # wrong number of fragments
self.info["icc_profile"] = icc_profile
self.icclist = []
for i in range(6, len(s), 3):
t = s[i : i + 3]
# 4-tuples: id, vsamp, hsamp, qtable
self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2]))
def DQT(self: JpegImageFile, marker: int) -> None:
#
# Define quantization table. Note that there might be more
# than one table in each marker.
# FIXME: The quantization tables can be used to estimate the
# compression quality.
assert self.fp is not None
n = i16(self.fp.read(2)) - 2
s = ImageFile._safe_read(self.fp, n)
while len(s):
v = s[0]
precision = 1 if (v // 16 == 0) else 2 # in bytes
qt_length = 1 + precision * 64
if len(s) < qt_length:
msg = "bad quantization table marker"
raise SyntaxError(msg)
data = array.array("B" if precision == 1 else "H", s[1:qt_length])
if sys.byteorder == "little" and precision > 1:
data.byteswap() # the values are always big-endian
self.quantization[v & 15] = [data[i] for i in zigzag_index]
s = s[qt_length:]
#
# JPEG marker table
MARKER = {
0xFFC0: ("SOF0", "Baseline DCT", SOF),
0xFFC1: ("SOF1", "Extended Sequential DCT", SOF),
0xFFC2: ("SOF2", "Progressive DCT", SOF),
0xFFC3: ("SOF3", "Spatial lossless", SOF),
0xFFC4: ("DHT", "Define Huffman table", Skip),
0xFFC5: ("SOF5", "Differential sequential DCT", SOF),
0xFFC6: ("SOF6", "Differential progressive DCT", SOF),
0xFFC7: ("SOF7", "Differential spatial", SOF),
0xFFC8: ("JPG", "Extension", None),
0xFFC9: ("SOF9", "Extended sequential DCT (AC)", SOF),
0xFFCA: ("SOF10", "Progressive DCT (AC)", SOF),
0xFFCB: ("SOF11", "Spatial lossless DCT (AC)", SOF),
0xFFCC: ("DAC", "Define arithmetic coding conditioning", Skip),
0xFFCD: ("SOF13", "Differential sequential DCT (AC)", SOF),
0xFFCE: ("SOF14", "Differential progressive DCT (AC)", SOF),
0xFFCF: ("SOF15", "Differential spatial (AC)", SOF),
0xFFD0: ("RST0", "Restart 0", None),
0xFFD1: ("RST1", "Restart 1", None),
0xFFD2: ("RST2", "Restart 2", None),
0xFFD3: ("RST3", "Restart 3", None),
0xFFD4: ("RST4", "Restart 4", None),
0xFFD5: ("RST5", "Restart 5", None),
0xFFD6: ("RST6", "Restart 6", None),
0xFFD7: ("RST7", "Restart 7", None),
0xFFD8: ("SOI", "Start of image", None),
0xFFD9: ("EOI", "End of image", None),
0xFFDA: ("SOS", "Start of scan", Skip),
0xFFDB: ("DQT", "Define quantization table", DQT),
0xFFDC: ("DNL", "Define number of lines", Skip),
0xFFDD: ("DRI", "Define restart interval", Skip),
0xFFDE: ("DHP", "Define hierarchical progression", SOF),
0xFFDF: ("EXP", "Expand reference component", Skip),
0xFFE0: ("APP0", "Application segment 0", APP),
0xFFE1: ("APP1", "Application segment 1", APP),
0xFFE2: ("APP2", "Application segment 2", APP),
0xFFE3: ("APP3", "Application segment 3", APP),
0xFFE4: ("APP4", "Application segment 4", APP),
0xFFE5: ("APP5", "Application segment 5", APP),
0xFFE6: ("APP6", "Application segment 6", APP),
0xFFE7: ("APP7", "Application segment 7", APP),
0xFFE8: ("APP8", "Application segment 8", APP),
0xFFE9: ("APP9", "Application segment 9", APP),
0xFFEA: ("APP10", "Application segment 10", APP),
0xFFEB: ("APP11", "Application segment 11", APP),
0xFFEC: ("APP12", "Application segment 12", APP),
0xFFED: ("APP13", "Application segment 13", APP),
0xFFEE: ("APP14", "Application segment 14", APP),
0xFFEF: ("APP15", "Application segment 15", APP),
0xFFF0: ("JPG0", "Extension 0", None),
0xFFF1: ("JPG1", "Extension 1", None),
0xFFF2: ("JPG2", "Extension 2", None),
0xFFF3: ("JPG3", "Extension 3", None),
0xFFF4: ("JPG4", "Extension 4", None),
0xFFF5: ("JPG5", "Extension 5", None),
0xFFF6: ("JPG6", "Extension 6", None),
0xFFF7: ("JPG7", "Extension 7", None),
0xFFF8: ("JPG8", "Extension 8", None),
0xFFF9: ("JPG9", "Extension 9", None),
0xFFFA: ("JPG10", "Extension 10", None),
0xFFFB: ("JPG11", "Extension 11", None),
0xFFFC: ("JPG12", "Extension 12", None),
0xFFFD: ("JPG13", "Extension 13", None),
0xFFFE: ("COM", "Comment", COM),
}
def _accept(prefix: bytes) -> bool:
# Magic number was taken from https://en.wikipedia.org/wiki/JPEG
return prefix.startswith(b"\xff\xd8\xff")
##
# Image plugin for JPEG and JFIF images.
class JpegImageFile(ImageFile.ImageFile):
format = "JPEG"
format_description = "JPEG (ISO 10918)"
def _open(self) -> None:
assert self.fp is not None
s = self.fp.read(3)
if not _accept(s):
msg = "not a JPEG file"
raise SyntaxError(msg)
s = b"\xff"
# Create attributes
self.bits = self.layers = 0
self._exif_offset = 0
# JPEG specifics (internal)
self.layer: list[tuple[int, int, int, int]] = []
self._huffman_dc: dict[Any, Any] = {}
self._huffman_ac: dict[Any, Any] = {}
self.quantization: dict[int, list[int]] = {}
self.app: dict[str, bytes] = {} # compatibility
self.applist: list[tuple[str, bytes]] = []
self.icclist: list[bytes] = []
while True:
i = s[0]
if i == 0xFF:
s = s + self.fp.read(1)
i = i16(s)
else:
# Skip non-0xFF junk
s = self.fp.read(1)
continue
if i in MARKER:
name, description, handler = MARKER[i]
if handler is not None:
handler(self, i)
if i == 0xFFDA: # start of scan
rawmode = self.mode
if self.mode == "CMYK":
rawmode = "CMYK;I" # assume adobe conventions
self.tile = [
ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, ""))
]
# self.__offset = self.fp.tell()
break
s = self.fp.read(1)
elif i in {0, 0xFFFF}:
# padded marker or junk; move on
s = b"\xff"
elif i == 0xFF00: # Skip extraneous data (escaped 0xFF)
s = self.fp.read(1)
else:
msg = "no marker found"
raise SyntaxError(msg)
self._read_dpi_from_exif()
def __getstate__(self) -> list[Any]:
return super().__getstate__() + [self.layers, self.layer]
def __setstate__(self, state: list[Any]) -> None:
self.layers, self.layer = state[6:]
super().__setstate__(state)
def load_read(self, read_bytes: int) -> bytes:
"""
internal: read more image data
For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker
so libjpeg can finish decoding
"""
assert self.fp is not None
s = self.fp.read(read_bytes)
if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"):
# Premature EOF.
# Pretend file is finished adding EOI marker
self._ended = True
return b"\xff\xd9"
return s
def draft(
self, mode: str | None, size: tuple[int, int] | None
) -> tuple[str, tuple[int, int, float, float]] | None:
if len(self.tile) != 1:
return None
# Protect from second call
if self.decoderconfig:
return None
d, e, o, a = self.tile[0]
scale = 1
original_size = self.size
assert isinstance(a, tuple)
if a[0] == "RGB" and mode in ["L", "YCbCr"]:
self._mode = mode
a = mode, ""
if size:
scale = min(self.size[0] // size[0], self.size[1] // size[1])
for s in [8, 4, 2, 1]:
if scale >= s:
break
assert e is not None
e = (
e[0],
e[1],
(e[2] - e[0] + s - 1) // s + e[0],
(e[3] - e[1] + s - 1) // s + e[1],
)
self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s)
scale = s
self.tile = [ImageFile._Tile(d, e, o, a)]
self.decoderconfig = (scale, 0)
box = (0, 0, original_size[0] / scale, original_size[1] / scale)
return self.mode, box
def load_djpeg(self) -> None:
# ALTERNATIVE: handle JPEGs via the IJG command line utilities
f, path = tempfile.mkstemp()
os.close(f)
if os.path.exists(self.filename):
subprocess.check_call(["djpeg", "-outfile", path, self.filename])
else:
try:
os.unlink(path)
except OSError:
pass
msg = "Invalid Filename"
raise ValueError(msg)
try:
with Image.open(path) as _im:
_im.load()
self.im = _im.im
finally:
try:
os.unlink(path)
except OSError:
pass
self._mode = self.im.mode
self._size = self.im.size
self.tile = []
def _getexif(self) -> dict[int, Any] | None:
return _getexif(self)
def _read_dpi_from_exif(self) -> None:
# If DPI isn't in JPEG header, fetch from EXIF
if "dpi" in self.info or "exif" not in self.info:
return
try:
exif = self.getexif()
resolution_unit = exif[0x0128]
x_resolution = exif[0x011A]
try:
dpi = float(x_resolution[0]) / x_resolution[1]
except TypeError:
dpi = x_resolution
if math.isnan(dpi):
msg = "DPI is not a number"
raise ValueError(msg)
if resolution_unit == 3: # cm
# 1 dpcm = 2.54 dpi
dpi *= 2.54
self.info["dpi"] = dpi, dpi
except (
struct.error, # truncated EXIF
KeyError, # dpi not included
SyntaxError, # invalid/unreadable EXIF
TypeError, # dpi is an invalid float
ValueError, # dpi is an invalid float
ZeroDivisionError, # invalid dpi rational value
):
self.info["dpi"] = 72, 72
def _getmp(self) -> dict[int, Any] | None:
return _getmp(self)
def _getexif(self: JpegImageFile) -> dict[int, Any] | None:
if "exif" not in self.info:
return None
return self.getexif()._get_merged_dict()
def _getmp(self: JpegImageFile) -> dict[int, Any] | None:
# Extract MP information. This method was inspired by the "highly
# experimental" _getexif version that's been in use for years now,
# itself based on the ImageFileDirectory class in the TIFF plugin.
# The MP record essentially consists of a TIFF file embedded in a JPEG
# application marker.
try:
data = self.info["mp"]
except KeyError:
return None
file_contents = io.BytesIO(data)
head = file_contents.read(8)
endianness = ">" if head.startswith(b"\x4d\x4d\x00\x2a") else "<"
# process dictionary
from . import TiffImagePlugin
try:
info = TiffImagePlugin.ImageFileDirectory_v2(head)
file_contents.seek(info.next)
info.load(file_contents)
mp = dict(info)
except Exception as e:
msg = "malformed MP Index (unreadable directory)"
raise SyntaxError(msg) from e
# it's an error not to have a number of images
try:
quant = mp[0xB001]
except KeyError as e:
msg = "malformed MP Index (no number of images)"
raise SyntaxError(msg) from e
# get MP entries
mpentries = []
try:
rawmpentries = mp[0xB002]
for entrynum in range(quant):
unpackedentry = struct.unpack_from(
f"{endianness}LLLHH", rawmpentries, entrynum * 16
)
labels = ("Attribute", "Size", "DataOffset", "EntryNo1", "EntryNo2")
mpentry = dict(zip(labels, unpackedentry))
mpentryattr = {
"DependentParentImageFlag": bool(mpentry["Attribute"] & (1 << 31)),
"DependentChildImageFlag": bool(mpentry["Attribute"] & (1 << 30)),
"RepresentativeImageFlag": bool(mpentry["Attribute"] & (1 << 29)),
"Reserved": (mpentry["Attribute"] & (3 << 27)) >> 27,
"ImageDataFormat": (mpentry["Attribute"] & (7 << 24)) >> 24,
"MPType": mpentry["Attribute"] & 0x00FFFFFF,
}
if mpentryattr["ImageDataFormat"] == 0:
mpentryattr["ImageDataFormat"] = "JPEG"
else:
msg = "unsupported picture format in MPO"
raise SyntaxError(msg)
mptypemap = {
0x000000: "Undefined",
0x010001: "Large Thumbnail (VGA Equivalent)",
0x010002: "Large Thumbnail (Full HD Equivalent)",
0x020001: "Multi-Frame Image (Panorama)",
0x020002: "Multi-Frame Image: (Disparity)",
0x020003: "Multi-Frame Image: (Multi-Angle)",
0x030000: "Baseline MP Primary Image",
}
mpentryattr["MPType"] = mptypemap.get(mpentryattr["MPType"], "Unknown")
mpentry["Attribute"] = mpentryattr
mpentries.append(mpentry)
mp[0xB002] = mpentries
except KeyError as e:
msg = "malformed MP Index (bad MP Entry)"
raise SyntaxError(msg) from e
# Next we should try and parse the individual image unique ID list;
# we don't because I've never seen this actually used in a real MPO
# file and so can't test it.
return mp
# --------------------------------------------------------------------
# stuff to save JPEG files
RAWMODE = {
"1": "L",
"L": "L",
"RGB": "RGB",
"RGBX": "RGB",
"CMYK": "CMYK;I", # assume adobe conventions
"YCbCr": "YCbCr",
}
# fmt: off
zigzag_index = (
0, 1, 5, 6, 14, 15, 27, 28,
2, 4, 7, 13, 16, 26, 29, 42,
3, 8, 12, 17, 25, 30, 41, 43,
9, 11, 18, 24, 31, 40, 44, 53,
10, 19, 23, 32, 39, 45, 52, 54,
20, 22, 33, 38, 46, 51, 55, 60,
21, 34, 37, 47, 50, 56, 59, 61,
35, 36, 48, 49, 57, 58, 62, 63,
)
samplings = {
(1, 1, 1, 1, 1, 1): 0,
(2, 1, 1, 1, 1, 1): 1,
(2, 2, 1, 1, 1, 1): 2,
}
# fmt: on
def get_sampling(im: Image.Image) -> int:
# There's no subsampling when images have only 1 layer
# (grayscale images) or when they are CMYK (4 layers),
# so set subsampling to the default value.
#
# NOTE: currently Pillow can't encode JPEG to YCCK format.
# If YCCK support is added in the future, subsampling code will have
# to be updated (here and in JpegEncode.c) to deal with 4 layers.
if not isinstance(im, JpegImageFile) or im.layers in (1, 4):
return -1
sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3]
return samplings.get(sampling, -1)
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.width == 0 or im.height == 0:
msg = "cannot write empty image as JPEG"
raise ValueError(msg)
try:
rawmode = RAWMODE[im.mode]
except KeyError as e:
msg = f"cannot write mode {im.mode} as JPEG"
raise OSError(msg) from e
info = im.encoderinfo
dpi = [round(x) for x in info.get("dpi", (0, 0))]
quality = info.get("quality", -1)
subsampling = info.get("subsampling", -1)
qtables = info.get("qtables")
if quality == "keep":
quality = -1
subsampling = "keep"
qtables = "keep"
elif quality in presets:
preset = presets[quality]
quality = -1
subsampling = preset.get("subsampling", -1)
qtables = preset.get("quantization")
elif not isinstance(quality, int):
msg = "Invalid quality setting"
raise ValueError(msg)
else:
if subsampling in presets:
subsampling = presets[subsampling].get("subsampling", -1)
if isinstance(qtables, str) and qtables in presets:
qtables = presets[qtables].get("quantization")
if subsampling == "4:4:4":
subsampling = 0
elif subsampling == "4:2:2":
subsampling = 1
elif subsampling == "4:2:0":
subsampling = 2
elif subsampling == "4:1:1":
# For compatibility. Before Pillow 4.3, 4:1:1 actually meant 4:2:0.
# Set 4:2:0 if someone is still using that value.
subsampling = 2
elif subsampling == "keep":
if im.format != "JPEG":
msg = "Cannot use 'keep' when original image is not a JPEG"
raise ValueError(msg)
subsampling = get_sampling(im)
def validate_qtables(
qtables: (
str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None
),
) -> list[list[int]] | None:
if qtables is None:
return qtables
if isinstance(qtables, str):
try:
lines = [
int(num)
for line in qtables.splitlines()
for num in line.split("#", 1)[0].split()
]
except ValueError as e:
msg = "Invalid quantization table"
raise ValueError(msg) from e
else:
qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)]
if isinstance(qtables, (tuple, list, dict)):
if isinstance(qtables, dict):
qtables = [
qtables[key] for key in range(len(qtables)) if key in qtables
]
elif isinstance(qtables, tuple):
qtables = list(qtables)
if not (0 < len(qtables) < 5):
msg = "None or too many quantization tables"
raise ValueError(msg)
for idx, table in enumerate(qtables):
try:
if len(table) != 64:
msg = "Invalid quantization table"
raise TypeError(msg)
table_array = array.array("H", table)
except TypeError as e:
msg = "Invalid quantization table"
raise ValueError(msg) from e
else:
qtables[idx] = list(table_array)
return qtables
if qtables == "keep":
if im.format != "JPEG":
msg = "Cannot use 'keep' when original image is not a JPEG"
raise ValueError(msg)
qtables = getattr(im, "quantization", None)
qtables = validate_qtables(qtables)
extra = info.get("extra", b"")
MAX_BYTES_IN_MARKER = 65533
if xmp := info.get("xmp"):
overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00"
max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
if len(xmp) > max_data_bytes_in_marker:
msg = "XMP data is too long"
raise ValueError(msg)
size = o16(2 + overhead_len + len(xmp))
extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp
if icc_profile := info.get("icc_profile"):
overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers))
max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
markers = []
while icc_profile:
markers.append(icc_profile[:max_data_bytes_in_marker])
icc_profile = icc_profile[max_data_bytes_in_marker:]
i = 1
for marker in markers:
size = o16(2 + overhead_len + len(marker))
extra += (
b"\xff\xe2"
+ size
+ b"ICC_PROFILE\0"
+ o8(i)
+ o8(len(markers))
+ marker
)
i += 1
comment = info.get("comment", im.info.get("comment"))
# "progressive" is the official name, but older documentation
# says "progression"
# FIXME: issue a warning if the wrong form is used (post-1.1.7)
progressive = info.get("progressive", False) or info.get("progression", False)
optimize = info.get("optimize", False)
exif = info.get("exif", b"")
if isinstance(exif, Image.Exif):
exif = exif.tobytes()
if len(exif) > MAX_BYTES_IN_MARKER:
msg = "EXIF data is too long"
raise ValueError(msg)
# get keyword arguments
im.encoderconfig = (
quality,
progressive,
info.get("smooth", 0),
optimize,
info.get("keep_rgb", False),
info.get("streamtype", 0),
dpi,
subsampling,
info.get("restart_marker_blocks", 0),
info.get("restart_marker_rows", 0),
qtables,
comment,
extra,
exif,
)
# if we optimize, libjpeg needs a buffer big enough to hold the whole image
# in a shot. Guessing on the size, at im.size bytes. (raw pixel size is
# channels*size, this is a value that's been used in a django patch.
# https://github.com/matthewwithanm/django-imagekit/issues/50
if optimize or progressive:
# CMYK can be bigger
if im.mode == "CMYK":
bufsize = 4 * im.size[0] * im.size[1]
# keep sets quality to -1, but the actual value may be high.
elif quality >= 95 or quality == -1:
bufsize = 2 * im.size[0] * im.size[1]
else:
bufsize = im.size[0] * im.size[1]
if exif:
bufsize += len(exif) + 5
if extra:
bufsize += len(extra) + 1
else:
# The EXIF info needs to be written as one block, + APP1, + one spare byte.
# Ensure that our buffer is big enough. Same with the icc_profile block.
bufsize = max(len(exif) + 5, len(extra) + 1)
ImageFile._save(
im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize
)
##
# Factory for making JPEG and MPO instances
def jpeg_factory(
fp: IO[bytes], filename: str | bytes | None = None
) -> JpegImageFile | MpoImageFile:
im = JpegImageFile(fp, filename)
try:
mpheader = im._getmp()
if mpheader is not None and mpheader[45057] > 1:
for segment, content in im.applist:
if segment == "APP1" and b' hdrgm:Version="' in content:
# Ultra HDR images are not yet supported
return im
# It's actually an MPO
from .MpoImagePlugin import MpoImageFile
# Don't reload everything, just convert it.
im = MpoImageFile.adopt(im, mpheader)
except (TypeError, IndexError):
# It is really a JPEG
pass
except SyntaxError:
warnings.warn(
"Image appears to be a malformed MPO file, it will be "
"interpreted as a base JPEG file"
)
return im
# ---------------------------------------------------------------------
# Registry stuff
Image.register_open(JpegImageFile.format, jpeg_factory, _accept)
Image.register_save(JpegImageFile.format, _save)
Image.register_extensions(JpegImageFile.format, [".jfif", ".jpe", ".jpg", ".jpeg"])
Image.register_mime(JpegImageFile.format, "image/jpeg")
+242
View File
@@ -0,0 +1,242 @@
"""
JPEG quality settings equivalent to the Photoshop settings.
Can be used when saving JPEG files.
The following presets are available by default:
``web_low``, ``web_medium``, ``web_high``, ``web_very_high``, ``web_maximum``,
``low``, ``medium``, ``high``, ``maximum``.
More presets can be added to the :py:data:`presets` dict if needed.
To apply the preset, specify::
quality="preset_name"
To apply only the quantization table::
qtables="preset_name"
To apply only the subsampling setting::
subsampling="preset_name"
Example::
im.save("image_name.jpg", quality="web_high")
Subsampling
-----------
Subsampling is the practice of encoding images by implementing less resolution
for chroma information than for luma information.
(ref.: https://en.wikipedia.org/wiki/Chroma_subsampling)
Possible subsampling values are 0, 1 and 2 that correspond to 4:4:4, 4:2:2 and
4:2:0.
You can get the subsampling of a JPEG with the
:func:`.JpegImagePlugin.get_sampling` function.
In JPEG compressed data a JPEG marker is used instead of an EXIF tag.
(ref.: https://exiv2.org/tags.html)
Quantization tables
-------------------
They are values use by the DCT (Discrete cosine transform) to remove
*unnecessary* information from the image (the lossy part of the compression).
(ref.: https://en.wikipedia.org/wiki/Quantization_matrix#Quantization_matrices,
https://en.wikipedia.org/wiki/JPEG#Quantization)
You can get the quantization tables of a JPEG with::
im.quantization
This will return a dict with a number of lists. You can pass this dict
directly as the qtables argument when saving a JPEG.
The quantization table format in presets is a list with sublists. These formats
are interchangeable.
Libjpeg ref.:
https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html
"""
from __future__ import annotations
# fmt: off
presets = {
'web_low': {'subsampling': 2, # "4:2:0"
'quantization': [
[20, 16, 25, 39, 50, 46, 62, 68,
16, 18, 23, 38, 38, 53, 65, 68,
25, 23, 31, 38, 53, 65, 68, 68,
39, 38, 38, 53, 65, 68, 68, 68,
50, 38, 53, 65, 68, 68, 68, 68,
46, 53, 65, 68, 68, 68, 68, 68,
62, 65, 68, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68],
[21, 25, 32, 38, 54, 68, 68, 68,
25, 28, 24, 38, 54, 68, 68, 68,
32, 24, 32, 43, 66, 68, 68, 68,
38, 38, 43, 53, 68, 68, 68, 68,
54, 54, 66, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68,
68, 68, 68, 68, 68, 68, 68, 68]
]},
'web_medium': {'subsampling': 2, # "4:2:0"
'quantization': [
[16, 11, 11, 16, 23, 27, 31, 30,
11, 12, 12, 15, 20, 23, 23, 30,
11, 12, 13, 16, 23, 26, 35, 47,
16, 15, 16, 23, 26, 37, 47, 64,
23, 20, 23, 26, 39, 51, 64, 64,
27, 23, 26, 37, 51, 64, 64, 64,
31, 23, 35, 47, 64, 64, 64, 64,
30, 30, 47, 64, 64, 64, 64, 64],
[17, 15, 17, 21, 20, 26, 38, 48,
15, 19, 18, 17, 20, 26, 35, 43,
17, 18, 20, 22, 26, 30, 46, 53,
21, 17, 22, 28, 30, 39, 53, 64,
20, 20, 26, 30, 39, 48, 64, 64,
26, 26, 30, 39, 48, 63, 64, 64,
38, 35, 46, 53, 64, 64, 64, 64,
48, 43, 53, 64, 64, 64, 64, 64]
]},
'web_high': {'subsampling': 0, # "4:4:4"
'quantization': [
[6, 4, 4, 6, 9, 11, 12, 16,
4, 5, 5, 6, 8, 10, 12, 12,
4, 5, 5, 6, 10, 12, 14, 19,
6, 6, 6, 11, 12, 15, 19, 28,
9, 8, 10, 12, 16, 20, 27, 31,
11, 10, 12, 15, 20, 27, 31, 31,
12, 12, 14, 19, 27, 31, 31, 31,
16, 12, 19, 28, 31, 31, 31, 31],
[7, 7, 13, 24, 26, 31, 31, 31,
7, 12, 16, 21, 31, 31, 31, 31,
13, 16, 17, 31, 31, 31, 31, 31,
24, 21, 31, 31, 31, 31, 31, 31,
26, 31, 31, 31, 31, 31, 31, 31,
31, 31, 31, 31, 31, 31, 31, 31,
31, 31, 31, 31, 31, 31, 31, 31,
31, 31, 31, 31, 31, 31, 31, 31]
]},
'web_very_high': {'subsampling': 0, # "4:4:4"
'quantization': [
[2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 4, 5, 7, 9,
2, 2, 2, 4, 5, 7, 9, 12,
3, 3, 4, 5, 8, 10, 12, 12,
4, 4, 5, 7, 10, 12, 12, 12,
5, 5, 7, 9, 12, 12, 12, 12,
6, 6, 9, 12, 12, 12, 12, 12],
[3, 3, 5, 9, 13, 15, 15, 15,
3, 4, 6, 11, 14, 12, 12, 12,
5, 6, 9, 14, 12, 12, 12, 12,
9, 11, 14, 12, 12, 12, 12, 12,
13, 14, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12]
]},
'web_maximum': {'subsampling': 0, # "4:4:4"
'quantization': [
[1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 2,
1, 1, 1, 1, 1, 1, 2, 2,
1, 1, 1, 1, 1, 2, 2, 3,
1, 1, 1, 1, 2, 2, 3, 3,
1, 1, 1, 2, 2, 3, 3, 3,
1, 1, 2, 2, 3, 3, 3, 3],
[1, 1, 1, 2, 2, 3, 3, 3,
1, 1, 1, 2, 3, 3, 3, 3,
1, 1, 1, 3, 3, 3, 3, 3,
2, 2, 3, 3, 3, 3, 3, 3,
2, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3]
]},
'low': {'subsampling': 2, # "4:2:0"
'quantization': [
[18, 14, 14, 21, 30, 35, 34, 17,
14, 16, 16, 19, 26, 23, 12, 12,
14, 16, 17, 21, 23, 12, 12, 12,
21, 19, 21, 23, 12, 12, 12, 12,
30, 26, 23, 12, 12, 12, 12, 12,
35, 23, 12, 12, 12, 12, 12, 12,
34, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12],
[20, 19, 22, 27, 20, 20, 17, 17,
19, 25, 23, 14, 14, 12, 12, 12,
22, 23, 14, 14, 12, 12, 12, 12,
27, 14, 14, 12, 12, 12, 12, 12,
20, 14, 12, 12, 12, 12, 12, 12,
20, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12]
]},
'medium': {'subsampling': 2, # "4:2:0"
'quantization': [
[12, 8, 8, 12, 17, 21, 24, 17,
8, 9, 9, 11, 15, 19, 12, 12,
8, 9, 10, 12, 19, 12, 12, 12,
12, 11, 12, 21, 12, 12, 12, 12,
17, 15, 19, 12, 12, 12, 12, 12,
21, 19, 12, 12, 12, 12, 12, 12,
24, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12],
[13, 11, 13, 16, 20, 20, 17, 17,
11, 14, 14, 14, 14, 12, 12, 12,
13, 14, 14, 14, 12, 12, 12, 12,
16, 14, 14, 12, 12, 12, 12, 12,
20, 14, 12, 12, 12, 12, 12, 12,
20, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12]
]},
'high': {'subsampling': 0, # "4:4:4"
'quantization': [
[6, 4, 4, 6, 9, 11, 12, 16,
4, 5, 5, 6, 8, 10, 12, 12,
4, 5, 5, 6, 10, 12, 12, 12,
6, 6, 6, 11, 12, 12, 12, 12,
9, 8, 10, 12, 12, 12, 12, 12,
11, 10, 12, 12, 12, 12, 12, 12,
12, 12, 12, 12, 12, 12, 12, 12,
16, 12, 12, 12, 12, 12, 12, 12],
[7, 7, 13, 24, 20, 20, 17, 17,
7, 12, 16, 14, 14, 12, 12, 12,
13, 16, 14, 14, 12, 12, 12, 12,
24, 14, 14, 12, 12, 12, 12, 12,
20, 14, 12, 12, 12, 12, 12, 12,
20, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12,
17, 12, 12, 12, 12, 12, 12, 12]
]},
'maximum': {'subsampling': 0, # "4:4:4"
'quantization': [
[2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 3, 4, 5, 6,
2, 2, 2, 2, 4, 5, 7, 9,
2, 2, 2, 4, 5, 7, 9, 12,
3, 3, 4, 5, 8, 10, 12, 12,
4, 4, 5, 7, 10, 12, 12, 12,
5, 5, 7, 9, 12, 12, 12, 12,
6, 6, 9, 12, 12, 12, 12, 12],
[3, 3, 5, 9, 13, 15, 15, 15,
3, 4, 6, 10, 14, 12, 12, 12,
5, 6, 9, 14, 12, 12, 12, 12,
9, 10, 14, 12, 12, 12, 12, 12,
13, 14, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12,
15, 12, 12, 12, 12, 12, 12, 12]
]},
}
# fmt: on
+78
View File
@@ -0,0 +1,78 @@
#
# The Python Imaging Library.
# $Id$
#
# Basic McIdas support for PIL
#
# History:
# 1997-05-05 fl Created (8-bit images only)
# 2009-03-08 fl Added 16/32-bit support.
#
# Thanks to Richard Jones and Craig Swank for specs and samples.
#
# 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 struct
from . import Image, ImageFile
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\x00\x00\x00\x00\x00\x00\x00\x04")
##
# Image plugin for McIdas area images.
class McIdasImageFile(ImageFile.ImageFile):
format = "MCIDAS"
format_description = "McIdas area file"
def _open(self) -> None:
# parse area file directory
assert self.fp is not None
s = self.fp.read(256)
if not _accept(s) or len(s) != 256:
msg = "not an McIdas area file"
raise SyntaxError(msg)
self.area_descriptor_raw = s
self.area_descriptor = w = [0, *struct.unpack("!64i", s)]
# get mode
if w[11] == 1:
mode = rawmode = "L"
elif w[11] == 2:
mode = rawmode = "I;16B"
elif w[11] == 4:
# FIXME: add memory map support
mode = "I"
rawmode = "I;32B"
else:
msg = "unsupported McIdas format"
raise SyntaxError(msg)
self._mode = mode
self._size = w[10], w[9]
offset = w[34] + w[15]
stride = w[15] + w[10] * w[11] * w[14]
self.tile = [
ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))
]
# --------------------------------------------------------------------
# registry
Image.register_open(McIdasImageFile.format, McIdasImageFile, _accept)
# no default extension
+103
View File
@@ -0,0 +1,103 @@
#
# The Python Imaging Library.
# $Id$
#
# Microsoft Image Composer support for PIL
#
# Notes:
# uses TiffImagePlugin.py to read the actual image streams
#
# History:
# 97-01-20 fl Created
#
# 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, TiffImagePlugin
#
# --------------------------------------------------------------------
def _accept(prefix: bytes) -> bool:
return prefix.startswith(olefile.MAGIC)
##
# Image plugin for Microsoft's Image Composer file format.
class MicImageFile(TiffImagePlugin.TiffImageFile):
format = "MIC"
format_description = "Microsoft Image Composer"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
# read the OLE directory and see if this is a likely
# to be a Microsoft Image Composer file
try:
self.ole = olefile.OleFileIO(self.fp)
except OSError as e:
msg = "not an MIC file; invalid OLE file"
raise SyntaxError(msg) from e
# find ACI subfiles with Image members (maybe not the
# best way to identify MIC files, but what the... ;-)
self.images = [
path
for path in self.ole.listdir()
if path[1:] and path[0].endswith(".ACI") and path[1] == "Image"
]
# if we didn't find any images, this is probably not
# an MIC file.
if not self.images:
msg = "not an MIC file; no image entries"
raise SyntaxError(msg)
self.frame = -1
self._n_frames = len(self.images)
self.is_animated = self._n_frames > 1
assert self.fp is not None
self.__fp = self.fp
self.seek(0)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
filename = self.images[frame]
self.fp = self.ole.openstream(filename)
TiffImagePlugin.TiffImageFile._open(self)
self.frame = frame
def tell(self) -> int:
return self.frame
def close(self) -> None:
self.__fp.close()
self.ole.close()
super().close()
def __exit__(self, *args: object) -> None:
self.__fp.close()
self.ole.close()
super().__exit__()
#
# --------------------------------------------------------------------
Image.register_open(MicImageFile.format, MicImageFile, _accept)
Image.register_extension(MicImageFile.format, ".mic")
+84
View File
@@ -0,0 +1,84 @@
#
# The Python Imaging Library.
# $Id$
#
# MPEG file handling
#
# History:
# 95-09-09 fl Created
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1995.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image, ImageFile
from ._binary import i8
from ._typing import SupportsRead
#
# Bitstream parser
class BitStream:
def __init__(self, fp: SupportsRead[bytes]) -> None:
self.fp = fp
self.bits = 0
self.bitbuffer = 0
def next(self) -> int:
return i8(self.fp.read(1))
def peek(self, bits: int) -> int:
while self.bits < bits:
self.bitbuffer = (self.bitbuffer << 8) + self.next()
self.bits += 8
return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1
def skip(self, bits: int) -> None:
while self.bits < bits:
self.bitbuffer = (self.bitbuffer << 8) + i8(self.fp.read(1))
self.bits += 8
self.bits = self.bits - bits
def read(self, bits: int) -> int:
v = self.peek(bits)
self.bits = self.bits - bits
return v
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\x00\x00\x01\xb3")
##
# Image plugin for MPEG streams. This plugin can identify a stream,
# but it cannot read it.
class MpegImageFile(ImageFile.ImageFile):
format = "MPEG"
format_description = "MPEG"
def _open(self) -> None:
assert self.fp is not None
s = BitStream(self.fp)
if s.read(32) != 0x1B3:
msg = "not an MPEG file"
raise SyntaxError(msg)
self._mode = "RGB"
self._size = s.read(12), s.read(12)
# --------------------------------------------------------------------
# Registry stuff
Image.register_open(MpegImageFile.format, MpegImageFile, _accept)
Image.register_extensions(MpegImageFile.format, [".mpg", ".mpeg"])
Image.register_mime(MpegImageFile.format, "video/mpeg")
+204
View File
@@ -0,0 +1,204 @@
#
# The Python Imaging Library.
# $Id$
#
# MPO file handling
#
# See "Multi-Picture Format" (CIPA DC-007-Translation 2009, Standard of the
# Camera & Imaging Products Association)
#
# The multi-picture object combines multiple JPEG images (with a modified EXIF
# data format) into a single file. While it can theoretically be used much like
# a GIF animation, it is commonly used to represent 3D photographs and is (as
# of this writing) the most commonly used format by 3D cameras.
#
# History:
# 2014-03-13 Feneric Created
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
import struct
from typing import IO, Any, cast
from . import (
Image,
ImageFile,
ImageSequence,
JpegImagePlugin,
TiffImagePlugin,
)
from ._binary import o32le
from ._util import DeferredError
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
JpegImagePlugin._save(im, fp, filename)
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
append_images = im.encoderinfo.get("append_images", [])
if not append_images and not getattr(im, "is_animated", False):
_save(im, fp, filename)
return
mpf_offset = 28
offsets: list[int] = []
im_sequences = [im, *append_images]
total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences)
for im_sequence in im_sequences:
for im_frame in ImageSequence.Iterator(im_sequence):
if not offsets:
# APP2 marker
ifd_length = 66 + 16 * total
im_frame.encoderinfo["extra"] = (
b"\xff\xe2"
+ struct.pack(">H", 6 + ifd_length)
+ b"MPF\0"
+ b" " * ifd_length
)
exif = im_frame.encoderinfo.get("exif")
if isinstance(exif, Image.Exif):
exif = exif.tobytes()
im_frame.encoderinfo["exif"] = exif
if exif:
mpf_offset += 4 + len(exif)
JpegImagePlugin._save(im_frame, fp, filename)
offsets.append(fp.tell())
else:
encoderinfo = im_frame._attach_default_encoderinfo(im)
im_frame.save(fp, "JPEG")
im_frame.encoderinfo = encoderinfo
offsets.append(fp.tell() - offsets[-1])
ifd = TiffImagePlugin.ImageFileDirectory_v2()
ifd[0xB000] = b"0100"
ifd[0xB001] = len(offsets)
mpentries = b""
data_offset = 0
for i, size in enumerate(offsets):
if i == 0:
mptype = 0x030000 # Baseline MP Primary Image
else:
mptype = 0x000000 # Undefined
mpentries += struct.pack("<LLLHH", mptype, size, data_offset, 0, 0)
if i == 0:
data_offset -= mpf_offset
data_offset += size
ifd[0xB002] = mpentries
fp.seek(mpf_offset)
fp.write(b"II\x2a\x00" + o32le(8) + ifd.tobytes(8))
fp.seek(0, os.SEEK_END)
##
# Image plugin for MPO images.
class MpoImageFile(JpegImagePlugin.JpegImageFile):
format = "MPO"
format_description = "MPO (CIPA DC-007)"
_close_exclusive_fp_after_loading = False
def _open(self) -> None:
assert self.fp is not None
self.fp.seek(0) # prep the fp in order to pass the JPEG test
JpegImagePlugin.JpegImageFile._open(self)
self._after_jpeg_open()
def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None:
self.mpinfo = mpheader if mpheader is not None else self._getmp()
if self.mpinfo is None:
msg = "Image appears to be a malformed MPO file"
raise ValueError(msg)
self.n_frames = self.mpinfo[0xB001]
self.__mpoffsets = [
mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002]
]
self.__mpoffsets[0] = 0
# Note that the following assertion will only be invalid if something
# gets broken within JpegImagePlugin.
assert self.n_frames == len(self.__mpoffsets)
del self.info["mpoffset"] # no longer needed
self.is_animated = self.n_frames > 1
assert self.fp is not None
self._fp = self.fp # FIXME: hack
self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame
self.__frame = 0
self.offset = 0
# for now we can only handle reading and individual frame extraction
self.readonly = 1
def load_seek(self, pos: int) -> None:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self._fp.seek(pos)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.fp = self._fp
self.offset = self.__mpoffsets[frame]
original_exif = self.info.get("exif")
if "exif" in self.info:
del self.info["exif"]
self.fp.seek(self.offset + 2) # skip SOI marker
if not self.fp.read(2):
msg = "No data found for frame"
raise ValueError(msg)
self.fp.seek(self.offset)
JpegImagePlugin.JpegImageFile._open(self)
if self.info.get("exif") != original_exif:
self._reload_exif()
self.tile = [
ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])
]
self.__frame = frame
def tell(self) -> int:
return self.__frame
@staticmethod
def adopt(
jpeg_instance: JpegImagePlugin.JpegImageFile,
mpheader: dict[int, Any] | None = None,
) -> MpoImageFile:
"""
Transform the instance of JpegImageFile into
an instance of MpoImageFile.
After the call, the JpegImageFile is extended
to be an MpoImageFile.
This is essentially useful when opening a JPEG
file that reveals itself as an MPO, to avoid
double call to _open.
"""
jpeg_instance.__class__ = MpoImageFile
mpo_instance = cast(MpoImageFile, jpeg_instance)
mpo_instance._after_jpeg_open(mpheader)
return mpo_instance
# ---------------------------------------------------------------------
# Registry stuff
# Note that since MPO shares a factory with JPEG, we do not need to do a
# separate registration for it here.
# Image.register_open(MpoImageFile.format,
# JpegImagePlugin.jpeg_factory, _accept)
Image.register_save(MpoImageFile.format, _save)
Image.register_save_all(MpoImageFile.format, _save_all)
Image.register_extension(MpoImageFile.format, ".mpo")
Image.register_mime(MpoImageFile.format, "image/mpo")
+200
View File
@@ -0,0 +1,200 @@
#
# The Python Imaging Library.
#
# MSP file handling
#
# This is the format used by the Paint program in Windows 1 and 2.
#
# History:
# 95-09-05 fl Created
# 97-01-03 fl Read/write MSP images
# 17-02-21 es Fixed RLE interpretation
#
# Copyright (c) Secret Labs AB 1997.
# Copyright (c) Fredrik Lundh 1995-97.
# Copyright (c) Eric Soroos 2017.
#
# See the README file for information on usage and redistribution.
#
# More info on this format: https://archive.org/details/gg243631
# Page 313:
# Figure 205. Windows Paint Version 1: "DanM" Format
# Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03
#
# See also: https://www.fileformat.info/format/mspaint/egff.htm
from __future__ import annotations
import io
import struct
from typing import IO
from . import Image, ImageFile
from ._binary import i16le as i16
from ._binary import o16le as o16
#
# read MSP files
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"DanM", b"LinS"))
##
# Image plugin for Windows MSP images. This plugin supports both
# uncompressed (Windows 1.0).
class MspImageFile(ImageFile.ImageFile):
format = "MSP"
format_description = "Windows Paint"
def _open(self) -> None:
# Header
assert self.fp is not None
s = self.fp.read(32)
if not _accept(s):
msg = "not an MSP file"
raise SyntaxError(msg)
# Header checksum
checksum = 0
for i in range(0, 32, 2):
checksum = checksum ^ i16(s, i)
if checksum != 0:
msg = "bad MSP checksum"
raise SyntaxError(msg)
self._mode = "1"
self._size = i16(s, 4), i16(s, 6)
if s.startswith(b"DanM"):
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, "1")]
else:
self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)]
class MspDecoder(ImageFile.PyDecoder):
# The algo for the MSP decoder is from
# https://www.fileformat.info/format/mspaint/egff.htm
# cc-by-attribution -- That page references is taken from the
# Encyclopedia of Graphics File Formats and is licensed by
# O'Reilly under the Creative Common/Attribution license
#
# For RLE encoded files, the 32byte header is followed by a scan
# line map, encoded as one 16bit word of encoded byte length per
# line.
#
# NOTE: the encoded length of the line can be 0. This was not
# handled in the previous version of this encoder, and there's no
# mention of how to handle it in the documentation. From the few
# examples I've seen, I've assumed that it is a fill of the
# background color, in this case, white.
#
#
# Pseudocode of the decoder:
# Read a BYTE value as the RunType
# If the RunType value is zero
# Read next byte as the RunCount
# Read the next byte as the RunValue
# Write the RunValue byte RunCount times
# If the RunType value is non-zero
# Use this value as the RunCount
# Read and write the next RunCount bytes literally
#
# e.g.:
# 0x00 03 ff 05 00 01 02 03 04
# would yield the bytes:
# 0xff ff ff 00 01 02 03 04
#
# which are then interpreted as a bit packed mode '1' image
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
img = io.BytesIO()
blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8))
try:
self.fd.seek(32)
rowmap = struct.unpack_from(
f"<{self.state.ysize}H", self.fd.read(self.state.ysize * 2)
)
except struct.error as e:
msg = "Truncated MSP file in row map"
raise OSError(msg) from e
for x, rowlen in enumerate(rowmap):
try:
if rowlen == 0:
img.write(blank_line)
continue
row = self.fd.read(rowlen)
if len(row) != rowlen:
msg = f"Truncated MSP file, expected {rowlen} bytes on row {x}"
raise OSError(msg)
idx = 0
while idx < rowlen:
runtype = row[idx]
idx += 1
if runtype == 0:
(runcount, runval) = struct.unpack_from("Bc", row, idx)
img.write(runval * runcount)
idx += 2
else:
runcount = runtype
img.write(row[idx : idx + runcount])
idx += runcount
except struct.error as e:
msg = f"Corrupted MSP file in row {x}"
raise OSError(msg) from e
self.set_as_raw(img.getvalue(), "1")
return -1, 0
Image.register_decoder("MSP", MspDecoder)
#
# write MSP files (uncompressed only)
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode != "1":
msg = f"cannot write mode {im.mode} as MSP"
raise OSError(msg)
# create MSP header
header = [0] * 16
header[0], header[1] = i16(b"Da"), i16(b"nM") # version 1
header[2], header[3] = im.size
header[4], header[5] = 1, 1
header[6], header[7] = 1, 1
header[8], header[9] = im.size
checksum = 0
for h in header:
checksum = checksum ^ h
header[12] = checksum # FIXME: is this the right field?
# header
for h in header:
fp.write(o16(h))
# image body
ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, "1")])
#
# registry
Image.register_open(MspImageFile.format, MspImageFile, _accept)
Image.register_save(MspImageFile.format, _save)
Image.register_extension(MspImageFile.format, ".msp")
+237
View File
@@ -0,0 +1,237 @@
#
# The Python Imaging Library
# $Id$
#
# Simple PostScript graphics interface
#
# History:
# 1996-04-20 fl Created
# 1999-01-10 fl Added gsave/grestore to image method
# 2005-05-04 fl Fixed floating point issue in image (from Eric Etheridge)
#
# Copyright (c) 1997-2005 by Secret Labs AB. All rights reserved.
# Copyright (c) 1996 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import sys
from typing import IO
from . import EpsImagePlugin
TYPE_CHECKING = False
##
# Simple PostScript graphics interface.
class PSDraw:
"""
Sets up printing to the given file. If ``fp`` is omitted,
``sys.stdout.buffer`` is assumed.
"""
def __init__(self, fp: IO[bytes] | None = None) -> None:
if not fp:
fp = sys.stdout.buffer
self.fp = fp
def begin_document(self, id: str | None = None) -> None:
"""Set up printing of a document. (Write PostScript DSC header.)"""
# FIXME: incomplete
self.fp.write(
b"%!PS-Adobe-3.0\n"
b"save\n"
b"/showpage { } def\n"
b"%%EndComments\n"
b"%%BeginDocument\n"
)
# self.fp.write(ERROR_PS) # debugging!
self.fp.write(EDROFF_PS)
self.fp.write(VDI_PS)
self.fp.write(b"%%EndProlog\n")
self.isofont: dict[bytes, int] = {}
def end_document(self) -> None:
"""Ends printing. (Write PostScript DSC footer.)"""
self.fp.write(b"%%EndDocument\nrestore showpage\n%%End\n")
if hasattr(self.fp, "flush"):
self.fp.flush()
def setfont(self, font: str, size: int) -> None:
"""
Selects which font to use.
:param font: A PostScript font name
:param size: Size in points.
"""
font_bytes = bytes(font, "UTF-8")
if font_bytes not in self.isofont:
# reencode font
self.fp.write(
b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font_bytes, font_bytes)
)
self.isofont[font_bytes] = 1
# rough
self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font_bytes))
def line(self, xy0: tuple[int, int], xy1: tuple[int, int]) -> None:
"""
Draws a line between the two points. Coordinates are given in
PostScript point coordinates (72 points per inch, (0, 0) is the lower
left corner of the page).
"""
self.fp.write(b"%d %d %d %d Vl\n" % (*xy0, *xy1))
def rectangle(self, box: tuple[int, int, int, int]) -> None:
"""
Draws a rectangle.
:param box: A tuple of four integers, specifying left, bottom, width and
height.
"""
self.fp.write(b"%d %d M 0 %d %d Vr\n" % box)
def text(self, xy: tuple[int, int], text: str) -> None:
"""
Draws text at the given position. You must use
:py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method.
"""
text_bytes = bytes(text, "UTF-8")
text_bytes = b"\\(".join(text_bytes.split(b"("))
text_bytes = b"\\)".join(text_bytes.split(b")"))
self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,)))
if TYPE_CHECKING:
from . import Image
def image(
self, box: tuple[int, int, int, int], im: Image.Image, dpi: int | None = None
) -> None:
"""Draw a PIL image, centered in the given box."""
# default resolution depends on mode
if not dpi:
if im.mode == "1":
dpi = 200 # fax
else:
dpi = 100 # grayscale
# image size (on paper)
x = im.size[0] * 72 / dpi
y = im.size[1] * 72 / dpi
# max allowed size
xmax = float(box[2] - box[0])
ymax = float(box[3] - box[1])
if x > xmax:
y = y * xmax / x
x = xmax
if y > ymax:
x = x * ymax / y
y = ymax
dx = (xmax - x) / 2 + box[0]
dy = (ymax - y) / 2 + box[1]
self.fp.write(b"gsave\n%f %f translate\n" % (dx, dy))
if (x, y) != im.size:
# EpsImagePlugin._save prints the image at (0,0,xsize,ysize)
sx = x / im.size[0]
sy = y / im.size[1]
self.fp.write(b"%f %f scale\n" % (sx, sy))
EpsImagePlugin._save(im, self.fp, "", 0)
self.fp.write(b"\ngrestore\n")
# --------------------------------------------------------------------
# PostScript driver
#
# EDROFF.PS -- PostScript driver for Edroff 2
#
# History:
# 94-01-25 fl: created (edroff 2.04)
#
# Copyright (c) Fredrik Lundh 1994.
#
EDROFF_PS = b"""\
/S { show } bind def
/P { moveto show } bind def
/M { moveto } bind def
/X { 0 rmoveto } bind def
/Y { 0 exch rmoveto } bind def
/E { findfont
dup maxlength dict begin
{
1 index /FID ne { def } { pop pop } ifelse
} forall
/Encoding exch def
dup /FontName exch def
currentdict end definefont pop
} bind def
/F { findfont exch scalefont dup setfont
[ exch /setfont cvx ] cvx bind def
} bind def
"""
#
# VDI.PS -- PostScript driver for VDI meta commands
#
# History:
# 94-01-25 fl: created (edroff 2.04)
#
# Copyright (c) Fredrik Lundh 1994.
#
VDI_PS = b"""\
/Vm { moveto } bind def
/Va { newpath arcn stroke } bind def
/Vl { moveto lineto stroke } bind def
/Vc { newpath 0 360 arc closepath } bind def
/Vr { exch dup 0 rlineto
exch dup 0 exch rlineto
exch neg 0 rlineto
0 exch neg rlineto
setgray fill } bind def
/Tm matrix def
/Ve { Tm currentmatrix pop
translate scale newpath 0 0 .5 0 360 arc closepath
Tm setmatrix
} bind def
/Vf { currentgray exch setgray fill setgray } bind def
"""
#
# ERROR.PS -- Error handler
#
# History:
# 89-11-21 fl: created (pslist 1.10)
#
ERROR_PS = b"""\
/landscape false def
/errorBUF 200 string def
/errorNL { currentpoint 10 sub exch pop 72 exch moveto } def
errordict begin /handleerror {
initmatrix /Courier findfont 10 scalefont setfont
newpath 72 720 moveto $error begin /newerror false def
(PostScript Error) show errorNL errorNL
(Error: ) show
/errorname load errorBUF cvs show errorNL errorNL
(Command: ) show
/command load dup type /stringtype ne { errorBUF cvs } if show
errorNL errorNL
(VMstatus: ) show
vmstatus errorBUF cvs show ( bytes available, ) show
errorBUF cvs show ( bytes used at level ) show
errorBUF cvs show errorNL errorNL
(Operand stargck: ) show errorNL /ostargck load {
dup type /stringtype ne { errorBUF cvs } if 72 0 rmoveto show errorNL
} forall errorNL
(Execution stargck: ) show errorNL /estargck load {
dup type /stringtype ne { errorBUF cvs } if 72 0 rmoveto show errorNL
} forall
end showpage
} def end
"""
+54
View File
@@ -0,0 +1,54 @@
#
# Python Imaging Library
# $Id$
#
# stuff to read simple, teragon-style palette 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.
#
from __future__ import annotations
from typing import IO
from ._binary import o8
class PaletteFile:
"""File handler for Teragon-style palette files."""
rawmode = "RGB"
def __init__(self, fp: IO[bytes]) -> None:
palette = [o8(i) * 3 for i in range(256)]
while True:
s = fp.readline()
if not s:
break
if s.startswith(b"#"):
continue
if len(s) > 100:
msg = "bad palette file"
raise SyntaxError(msg)
v = [int(x) for x in s.split()]
try:
[i, r, g, b] = v
except ValueError:
[i, r] = v
g = b = r
if 0 <= i <= 255:
palette[i] = o8(r) + o8(g) + o8(b)
self.palette = b"".join(palette)
def getpalette(self) -> tuple[bytes, str]:
return self.palette, self.rawmode
+217
View File
@@ -0,0 +1,217 @@
#
# The Python Imaging Library.
# $Id$
#
##
# Image plugin for Palm pixmap images (output only).
##
from __future__ import annotations
from typing import IO
from . import Image, ImageFile
from ._binary import o8
from ._binary import o16be as o16b
# fmt: off
_Palm8BitColormapValues = (
(255, 255, 255), (255, 204, 255), (255, 153, 255), (255, 102, 255),
(255, 51, 255), (255, 0, 255), (255, 255, 204), (255, 204, 204),
(255, 153, 204), (255, 102, 204), (255, 51, 204), (255, 0, 204),
(255, 255, 153), (255, 204, 153), (255, 153, 153), (255, 102, 153),
(255, 51, 153), (255, 0, 153), (204, 255, 255), (204, 204, 255),
(204, 153, 255), (204, 102, 255), (204, 51, 255), (204, 0, 255),
(204, 255, 204), (204, 204, 204), (204, 153, 204), (204, 102, 204),
(204, 51, 204), (204, 0, 204), (204, 255, 153), (204, 204, 153),
(204, 153, 153), (204, 102, 153), (204, 51, 153), (204, 0, 153),
(153, 255, 255), (153, 204, 255), (153, 153, 255), (153, 102, 255),
(153, 51, 255), (153, 0, 255), (153, 255, 204), (153, 204, 204),
(153, 153, 204), (153, 102, 204), (153, 51, 204), (153, 0, 204),
(153, 255, 153), (153, 204, 153), (153, 153, 153), (153, 102, 153),
(153, 51, 153), (153, 0, 153), (102, 255, 255), (102, 204, 255),
(102, 153, 255), (102, 102, 255), (102, 51, 255), (102, 0, 255),
(102, 255, 204), (102, 204, 204), (102, 153, 204), (102, 102, 204),
(102, 51, 204), (102, 0, 204), (102, 255, 153), (102, 204, 153),
(102, 153, 153), (102, 102, 153), (102, 51, 153), (102, 0, 153),
(51, 255, 255), (51, 204, 255), (51, 153, 255), (51, 102, 255),
(51, 51, 255), (51, 0, 255), (51, 255, 204), (51, 204, 204),
(51, 153, 204), (51, 102, 204), (51, 51, 204), (51, 0, 204),
(51, 255, 153), (51, 204, 153), (51, 153, 153), (51, 102, 153),
(51, 51, 153), (51, 0, 153), (0, 255, 255), (0, 204, 255),
(0, 153, 255), (0, 102, 255), (0, 51, 255), (0, 0, 255),
(0, 255, 204), (0, 204, 204), (0, 153, 204), (0, 102, 204),
(0, 51, 204), (0, 0, 204), (0, 255, 153), (0, 204, 153),
(0, 153, 153), (0, 102, 153), (0, 51, 153), (0, 0, 153),
(255, 255, 102), (255, 204, 102), (255, 153, 102), (255, 102, 102),
(255, 51, 102), (255, 0, 102), (255, 255, 51), (255, 204, 51),
(255, 153, 51), (255, 102, 51), (255, 51, 51), (255, 0, 51),
(255, 255, 0), (255, 204, 0), (255, 153, 0), (255, 102, 0),
(255, 51, 0), (255, 0, 0), (204, 255, 102), (204, 204, 102),
(204, 153, 102), (204, 102, 102), (204, 51, 102), (204, 0, 102),
(204, 255, 51), (204, 204, 51), (204, 153, 51), (204, 102, 51),
(204, 51, 51), (204, 0, 51), (204, 255, 0), (204, 204, 0),
(204, 153, 0), (204, 102, 0), (204, 51, 0), (204, 0, 0),
(153, 255, 102), (153, 204, 102), (153, 153, 102), (153, 102, 102),
(153, 51, 102), (153, 0, 102), (153, 255, 51), (153, 204, 51),
(153, 153, 51), (153, 102, 51), (153, 51, 51), (153, 0, 51),
(153, 255, 0), (153, 204, 0), (153, 153, 0), (153, 102, 0),
(153, 51, 0), (153, 0, 0), (102, 255, 102), (102, 204, 102),
(102, 153, 102), (102, 102, 102), (102, 51, 102), (102, 0, 102),
(102, 255, 51), (102, 204, 51), (102, 153, 51), (102, 102, 51),
(102, 51, 51), (102, 0, 51), (102, 255, 0), (102, 204, 0),
(102, 153, 0), (102, 102, 0), (102, 51, 0), (102, 0, 0),
(51, 255, 102), (51, 204, 102), (51, 153, 102), (51, 102, 102),
(51, 51, 102), (51, 0, 102), (51, 255, 51), (51, 204, 51),
(51, 153, 51), (51, 102, 51), (51, 51, 51), (51, 0, 51),
(51, 255, 0), (51, 204, 0), (51, 153, 0), (51, 102, 0),
(51, 51, 0), (51, 0, 0), (0, 255, 102), (0, 204, 102),
(0, 153, 102), (0, 102, 102), (0, 51, 102), (0, 0, 102),
(0, 255, 51), (0, 204, 51), (0, 153, 51), (0, 102, 51),
(0, 51, 51), (0, 0, 51), (0, 255, 0), (0, 204, 0),
(0, 153, 0), (0, 102, 0), (0, 51, 0), (17, 17, 17),
(34, 34, 34), (68, 68, 68), (85, 85, 85), (119, 119, 119),
(136, 136, 136), (170, 170, 170), (187, 187, 187), (221, 221, 221),
(238, 238, 238), (192, 192, 192), (128, 0, 0), (128, 0, 128),
(0, 128, 0), (0, 128, 128), (0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0),
(0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0))
# fmt: on
# so build a prototype image to be used for palette resampling
def build_prototype_image() -> Image.Image:
image = Image.new("L", (1, len(_Palm8BitColormapValues)))
image.putdata(list(range(len(_Palm8BitColormapValues))))
palettedata: tuple[int, ...] = ()
for colormapValue in _Palm8BitColormapValues:
palettedata += colormapValue
palettedata += (0, 0, 0) * (256 - len(_Palm8BitColormapValues))
image.putpalette(palettedata)
return image
Palm8BitColormapImage = build_prototype_image()
# OK, we now have in Palm8BitColormapImage,
# a "P"-mode image with the right palette
#
# --------------------------------------------------------------------
_FLAGS = {"custom-colormap": 0x4000, "is-compressed": 0x8000, "has-transparent": 0x2000}
_COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00}
#
# --------------------------------------------------------------------
##
# (Internal) Image save plugin for the Palm format.
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode == "P":
rawmode = "P"
bpp = 8
version = 1
elif im.mode == "L":
if im.encoderinfo.get("bpp") in (1, 2, 4):
# this is 8-bit grayscale, so we shift it to get the high-order bits,
# and invert it because
# Palm does grayscale from white (0) to black (1)
bpp = im.encoderinfo["bpp"]
maxval = (1 << bpp) - 1
shift = 8 - bpp
im = im.point(lambda x: maxval - (x >> shift))
elif im.info.get("bpp") in (1, 2, 4):
# here we assume that even though the inherent mode is 8-bit grayscale,
# only the lower bpp bits are significant.
# We invert them to match the Palm.
bpp = im.info["bpp"]
maxval = (1 << bpp) - 1
im = im.point(lambda x: maxval - (x & maxval))
else:
msg = f"cannot write mode {im.mode} as Palm"
raise OSError(msg)
# we ignore the palette here
im._mode = "P"
rawmode = f"P;{bpp}"
version = 1
elif im.mode == "1":
# monochrome -- write it inverted, as is the Palm standard
rawmode = "1;I"
bpp = 1
version = 0
else:
msg = f"cannot write mode {im.mode} as Palm"
raise OSError(msg)
#
# make sure image data is available
im.load()
# write header
cols = im.size[0]
rows = im.size[1]
rowbytes = int((cols + (16 // bpp - 1)) / (16 // bpp)) * 2
transparent_index = 0
compression_type = _COMPRESSION_TYPES["none"]
flags = 0
if im.mode == "P":
flags |= _FLAGS["custom-colormap"]
colormap = im.im.getpalette()
colors = len(colormap) // 3
colormapsize = 4 * colors + 2
else:
colormapsize = 0
if "offset" in im.info:
offset = (rowbytes * rows + 16 + 3 + colormapsize) // 4
else:
offset = 0
fp.write(o16b(cols) + o16b(rows) + o16b(rowbytes) + o16b(flags))
fp.write(o8(bpp))
fp.write(o8(version))
fp.write(o16b(offset))
fp.write(o8(transparent_index))
fp.write(o8(compression_type))
fp.write(o16b(0)) # reserved by Palm
# now write colormap if necessary
if colormapsize:
fp.write(o16b(colors))
for i in range(colors):
fp.write(o8(i))
fp.write(colormap[3 * i : 3 * i + 3])
# now convert data to raw form
ImageFile._save(
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))]
)
if hasattr(fp, "flush"):
fp.flush()
#
# --------------------------------------------------------------------
Image.register_save("Palm", _save)
Image.register_extension("Palm", ".palm")
Image.register_mime("Palm", "image/palm")
+68
View File
@@ -0,0 +1,68 @@
#
# The Python Imaging Library.
# $Id$
#
# PCD file handling
#
# History:
# 96-05-10 fl Created
# 96-05-27 fl Added draft mode (128x192, 256x384)
#
# 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, ImageFile
##
# Image plugin for PhotoCD images. This plugin only reads the 768x512
# image from the file; higher resolutions are encoded in a proprietary
# encoding.
class PcdImageFile(ImageFile.ImageFile):
format = "PCD"
format_description = "Kodak PhotoCD"
def _open(self) -> None:
# rough
assert self.fp is not None
self.fp.seek(2048)
s = self.fp.read(1539)
if not s.startswith(b"PCD_"):
msg = "not a PCD file"
raise SyntaxError(msg)
orientation = s[1538] & 3
self.tile_post_rotate = None
if orientation == 1:
self.tile_post_rotate = 90
elif orientation == 3:
self.tile_post_rotate = 270
self._mode = "RGB"
self._size = (512, 768) if orientation in (1, 3) else (768, 512)
self.tile = [ImageFile._Tile("pcd", (0, 0, 768, 512), 96 * 2048)]
def load_prepare(self) -> None:
if self._im is None and self.tile_post_rotate:
self.im = Image.core.new(self.mode, (768, 512))
ImageFile.ImageFile.load_prepare(self)
def load_end(self) -> None:
if self.tile_post_rotate:
# Handle rotated PCDs
self.im = self.rotate(self.tile_post_rotate, expand=True).im
#
# registry
Image.register_open(PcdImageFile.format, PcdImageFile)
Image.register_extension(PcdImageFile.format, ".pcd")
+258
View File
@@ -0,0 +1,258 @@
#
# THIS IS WORK IN PROGRESS
#
# The Python Imaging Library
# $Id$
#
# portable compiled font file parser
#
# history:
# 1997-08-19 fl created
# 2003-09-13 fl fixed loading of unicode fonts
#
# 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.
#
from __future__ import annotations
import io
from . import FontFile, Image
from ._binary import i8
from ._binary import i16be as b16
from ._binary import i16le as l16
from ._binary import i32be as b32
from ._binary import i32le as l32
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import BinaryIO
# --------------------------------------------------------------------
# declarations
PCF_MAGIC = 0x70636601 # "\x01fcp"
PCF_PROPERTIES = 1 << 0
PCF_ACCELERATORS = 1 << 1
PCF_METRICS = 1 << 2
PCF_BITMAPS = 1 << 3
PCF_INK_METRICS = 1 << 4
PCF_BDF_ENCODINGS = 1 << 5
PCF_SWIDTHS = 1 << 6
PCF_GLYPH_NAMES = 1 << 7
PCF_BDF_ACCELERATORS = 1 << 8
BYTES_PER_ROW: list[Callable[[int], int]] = [
lambda bits: ((bits + 7) >> 3),
lambda bits: ((bits + 15) >> 3) & ~1,
lambda bits: ((bits + 31) >> 3) & ~3,
lambda bits: ((bits + 63) >> 3) & ~7,
]
def sz(s: bytes, o: int) -> bytes:
return s[o : s.index(b"\0", o)]
class PcfFontFile(FontFile.FontFile):
"""Font file plugin for the X11 PCF format."""
name = "name"
def __init__(self, fp: BinaryIO, charset_encoding: str = "iso8859-1"):
self.charset_encoding = charset_encoding
magic = l32(fp.read(4))
if magic != PCF_MAGIC:
msg = "not a PCF file"
raise SyntaxError(msg)
super().__init__()
count = l32(fp.read(4))
self.toc = {}
for i in range(count):
type = l32(fp.read(4))
self.toc[type] = l32(fp.read(4)), l32(fp.read(4)), l32(fp.read(4))
self.fp = fp
self.info = self._load_properties()
metrics = self._load_metrics()
bitmaps = self._load_bitmaps(metrics)
encoding = self._load_encoding()
#
# create glyph structure
for ch, ix in enumerate(encoding):
if ix is not None:
(
xsize,
ysize,
left,
right,
width,
ascent,
descent,
attributes,
) = metrics[ix]
self.glyph[ch] = (
(width, 0),
(left, descent - ysize, xsize + left, descent),
(0, 0, xsize, ysize),
bitmaps[ix],
)
def _getformat(
self, tag: int
) -> tuple[BinaryIO, int, Callable[[bytes], int], Callable[[bytes], int]]:
format, size, offset = self.toc[tag]
fp = self.fp
fp.seek(offset)
format = l32(fp.read(4))
if format & 4:
i16, i32 = b16, b32
else:
i16, i32 = l16, l32
return fp, format, i16, i32
def _load_properties(self) -> dict[bytes, bytes | int]:
#
# font properties
properties = {}
fp, format, i16, i32 = self._getformat(PCF_PROPERTIES)
nprops = i32(fp.read(4))
# read property description
p = [(i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4))) for _ in range(nprops)]
if nprops & 3:
fp.seek(4 - (nprops & 3), io.SEEK_CUR) # pad
data = fp.read(i32(fp.read(4)))
for k, s, v in p:
property_value: bytes | int = sz(data, v) if s else v
properties[sz(data, k)] = property_value
return properties
def _load_metrics(self) -> list[tuple[int, int, int, int, int, int, int, int]]:
#
# font metrics
metrics: list[tuple[int, int, int, int, int, int, int, int]] = []
fp, format, i16, i32 = self._getformat(PCF_METRICS)
append = metrics.append
if (format & 0xFF00) == 0x100:
# "compressed" metrics
for i in range(i16(fp.read(2))):
left = i8(fp.read(1)) - 128
right = i8(fp.read(1)) - 128
width = i8(fp.read(1)) - 128
ascent = i8(fp.read(1)) - 128
descent = i8(fp.read(1)) - 128
xsize = right - left
ysize = ascent + descent
append((xsize, ysize, left, right, width, ascent, descent, 0))
else:
# "jumbo" metrics
for i in range(i32(fp.read(4))):
left = i16(fp.read(2))
right = i16(fp.read(2))
width = i16(fp.read(2))
ascent = i16(fp.read(2))
descent = i16(fp.read(2))
attributes = i16(fp.read(2))
xsize = right - left
ysize = ascent + descent
append((xsize, ysize, left, right, width, ascent, descent, attributes))
return metrics
def _load_bitmaps(
self, metrics: list[tuple[int, int, int, int, int, int, int, int]]
) -> list[Image.Image]:
#
# bitmap data
fp, format, i16, i32 = self._getformat(PCF_BITMAPS)
nbitmaps = i32(fp.read(4))
if nbitmaps != len(metrics):
msg = "Wrong number of bitmaps"
raise OSError(msg)
offsets = [i32(fp.read(4)) for _ in range(nbitmaps)]
bitmap_sizes = [i32(fp.read(4)) for _ in range(4)]
# byteorder = format & 4 # non-zero => MSB
bitorder = format & 8 # non-zero => MSB
padindex = format & 3
bitmapsize = bitmap_sizes[padindex]
offsets.append(bitmapsize)
data = fp.read(bitmapsize)
pad = BYTES_PER_ROW[padindex]
mode = "1;R"
if bitorder:
mode = "1"
bitmaps = []
for i in range(nbitmaps):
xsize, ysize = metrics[i][:2]
b, e = offsets[i : i + 2]
bitmaps.append(
Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize))
)
return bitmaps
def _load_encoding(self) -> list[int | None]:
fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS)
first_col, last_col = i16(fp.read(2)), i16(fp.read(2))
first_row, last_row = i16(fp.read(2)), i16(fp.read(2))
i16(fp.read(2)) # default
nencoding = (last_col - first_col + 1) * (last_row - first_row + 1)
# map character code to bitmap index
encoding: list[int | None] = [None] * min(256, nencoding)
encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)]
for i in range(first_col, len(encoding)):
try:
encoding_offset = encoding_offsets[
ord(bytearray([i]).decode(self.charset_encoding))
]
if encoding_offset != 0xFFFF:
encoding[i] = encoding_offset
except UnicodeDecodeError:
# character is not supported in selected encoding
pass
return encoding
+228
View File
@@ -0,0 +1,228 @@
#
# The Python Imaging Library.
# $Id$
#
# PCX file handling
#
# This format was originally used by ZSoft's popular PaintBrush
# program for the IBM PC. It is also supported by many MS-DOS and
# Windows applications, including the Windows PaintBrush program in
# Windows 3.
#
# history:
# 1995-09-01 fl Created
# 1996-05-20 fl Fixed RGB support
# 1997-01-03 fl Fixed 2-bit and 4-bit support
# 1999-02-03 fl Fixed 8-bit support (broken in 1.0b1)
# 1999-02-07 fl Added write support
# 2002-06-09 fl Made 2-bit and 4-bit support a bit more robust
# 2002-07-30 fl Seek from to current position, not beginning of file
# 2003-06-03 fl Extract DPI settings (info["dpi"])
#
# 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 logging
from typing import IO
from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16
from ._binary import o8
from ._binary import o16le as o16
logger = logging.getLogger(__name__)
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 2 and prefix[0] == 10 and prefix[1] in [0, 2, 3, 5]
##
# Image plugin for Paintbrush images.
class PcxImageFile(ImageFile.ImageFile):
format = "PCX"
format_description = "Paintbrush"
def _open(self) -> None:
# header
assert self.fp is not None
s = self.fp.read(68)
if not _accept(s):
msg = "not a PCX file"
raise SyntaxError(msg)
# image
bbox = i16(s, 4), i16(s, 6), i16(s, 8) + 1, i16(s, 10) + 1
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
msg = "bad PCX image size"
raise SyntaxError(msg)
logger.debug("BBox: %s %s %s %s", *bbox)
offset = self.fp.tell() + 60
# format
version = s[1]
bits = s[3]
planes = s[65]
provided_stride = i16(s, 66)
logger.debug(
"PCX version %s, bits %s, planes %s, stride %s",
version,
bits,
planes,
provided_stride,
)
self.info["dpi"] = i16(s, 12), i16(s, 14)
if bits == 1 and planes == 1:
mode = rawmode = "1"
elif bits == 1 and planes in (2, 4):
mode = "P"
rawmode = f"P;{planes}L"
self.palette = ImagePalette.raw("RGB", s[16:64])
elif version == 5 and bits == 8 and planes == 1:
mode = rawmode = "L"
# FIXME: hey, this doesn't work with the incremental loader !!!
self.fp.seek(-769, io.SEEK_END)
s = self.fp.read(769)
if len(s) == 769 and s[0] == 12:
# check if the palette is linear grayscale
for i in range(256):
if s[i * 3 + 1 : i * 3 + 4] != o8(i) * 3:
mode = rawmode = "P"
break
if mode == "P":
self.palette = ImagePalette.raw("RGB", s[1:])
elif version == 5 and bits == 8 and planes == 3:
mode = "RGB"
rawmode = "RGB;L"
else:
msg = "unknown PCX mode"
raise OSError(msg)
self._mode = mode
self._size = bbox[2] - bbox[0], bbox[3] - bbox[1]
# Don't trust the passed in stride.
# Calculate the approximate position for ourselves.
# CVE-2020-35653
stride = (self._size[0] * bits + 7) // 8
# While the specification states that this must be even,
# not all images follow this
if provided_stride != stride:
stride += stride % 2
bbox = (0, 0) + self.size
logger.debug("size: %sx%s", *self.size)
self.tile = [ImageFile._Tile("pcx", bbox, offset, (rawmode, planes * stride))]
# --------------------------------------------------------------------
# save PCX files
SAVE = {
# mode: (version, bits, planes, raw mode)
"1": (2, 1, 1, "1"),
"L": (5, 8, 1, "L"),
"P": (5, 8, 1, "P"),
"RGB": (5, 8, 3, "RGB;L"),
}
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
try:
version, bits, planes, rawmode = SAVE[im.mode]
except KeyError as e:
msg = f"Cannot save {im.mode} images as PCX"
raise ValueError(msg) from e
# bytes per plane
stride = (im.size[0] * bits + 7) // 8
# stride should be even
stride += stride % 2
# Stride needs to be kept in sync with the PcxEncode.c version.
# Ideally it should be passed in in the state, but the bytes value
# gets overwritten.
logger.debug(
"PcxImagePlugin._save: xwidth: %d, bits: %d, stride: %d",
im.size[0],
bits,
stride,
)
# under windows, we could determine the current screen size with
# "Image.core.display_mode()[1]", but I think that's overkill...
screen = im.size
dpi = 100, 100
# PCX header
fp.write(
o8(10)
+ o8(version)
+ o8(1)
+ o8(bits)
+ o16(0)
+ o16(0)
+ o16(im.size[0] - 1)
+ o16(im.size[1] - 1)
+ o16(dpi[0])
+ o16(dpi[1])
+ b"\0" * 24
+ b"\xff" * 24
+ b"\0"
+ o8(planes)
+ o16(stride)
+ o16(1)
+ o16(screen[0])
+ o16(screen[1])
+ b"\0" * 54
)
assert fp.tell() == 128
ImageFile._save(
im, fp, [ImageFile._Tile("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))]
)
if im.mode == "P":
# colour palette
fp.write(o8(12))
palette = im.im.getpalette("RGB", "RGB")
palette += b"\x00" * (768 - len(palette))
fp.write(palette) # 768 bytes
elif im.mode == "L":
# grayscale palette
fp.write(o8(12))
for i in range(256):
fp.write(o8(i) * 3)
# --------------------------------------------------------------------
# registry
Image.register_open(PcxImageFile.format, PcxImageFile, _accept)
Image.register_save(PcxImageFile.format, _save)
Image.register_extension(PcxImageFile.format, ".pcx")
Image.register_mime(PcxImageFile.format, "image/x-pcx")
+311
View File
@@ -0,0 +1,311 @@
#
# The Python Imaging Library.
# $Id$
#
# PDF (Acrobat) file handling
#
# History:
# 1996-07-16 fl Created
# 1997-01-18 fl Fixed header
# 2004-02-21 fl Fixes for 1/L/CMYK images, etc.
# 2004-02-24 fl Fixes for 1 and P images.
#
# Copyright (c) 1997-2004 by Secret Labs AB. All rights reserved.
# Copyright (c) 1996-1997 by Fredrik Lundh.
#
# See the README file for information on usage and redistribution.
#
##
# Image plugin for PDF images (output only).
##
from __future__ import annotations
import io
import math
import os
import time
from typing import IO, Any
from . import Image, ImageFile, ImageSequence, PdfParser, features
#
# --------------------------------------------------------------------
# object ids:
# 1. catalogue
# 2. pages
# 3. image
# 4. page
# 5. page contents
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, save_all=True)
##
# (Internal) Image save plugin for the PDF format.
def _write_image(
im: Image.Image,
filename: str | bytes,
existing_pdf: PdfParser.PdfParser,
image_refs: list[PdfParser.IndirectReference],
) -> tuple[PdfParser.IndirectReference, str]:
# FIXME: Should replace ASCIIHexDecode with RunLengthDecode
# (packbits) or LZWDecode (tiff/lzw compression). Note that
# PDF 1.2 also supports Flatedecode (zip compression).
params = None
decode = None
#
# Get image characteristics
width, height = im.size
dict_obj: dict[str, Any] = {"BitsPerComponent": 8}
if im.mode == "1":
if features.check("libtiff"):
decode_filter = "CCITTFaxDecode"
dict_obj["BitsPerComponent"] = 1
params = PdfParser.PdfArray(
[
PdfParser.PdfDict(
{
"K": -1,
"BlackIs1": True,
"Columns": width,
"Rows": height,
}
)
]
)
else:
decode_filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "L":
decode_filter = "DCTDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "LA":
decode_filter = "JPXDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
procset = "ImageB" # grayscale
dict_obj["SMaskInData"] = 1
elif im.mode == "P":
decode_filter = "ASCIIHexDecode"
palette = im.getpalette()
assert palette is not None
dict_obj["ColorSpace"] = [
PdfParser.PdfName("Indexed"),
PdfParser.PdfName("DeviceRGB"),
len(palette) // 3 - 1,
PdfParser.PdfBinary(palette),
]
procset = "ImageI" # indexed color
if "transparency" in im.info:
smask = im.convert("LA").getchannel("A")
smask.encoderinfo = {}
image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0]
dict_obj["SMask"] = image_ref
elif im.mode == "RGB":
decode_filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB")
procset = "ImageC" # color images
elif im.mode == "RGBA":
decode_filter = "JPXDecode"
procset = "ImageC" # color images
dict_obj["SMaskInData"] = 1
elif im.mode == "CMYK":
decode_filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK")
procset = "ImageC" # color images
decode = [1, 0, 1, 0, 1, 0, 1, 0]
else:
msg = f"cannot save mode {im.mode}"
raise ValueError(msg)
#
# image
op = io.BytesIO()
if decode_filter == "ASCIIHexDecode":
ImageFile._save(im, op, [ImageFile._Tile("hex", (0, 0) + im.size, 0, im.mode)])
elif decode_filter == "CCITTFaxDecode":
im.save(
op,
"TIFF",
compression="group4",
# use a single strip
strip_size=math.ceil(width / 8) * height,
)
elif decode_filter == "DCTDecode":
Image.SAVE["JPEG"](im, op, filename)
elif decode_filter == "JPXDecode":
del dict_obj["BitsPerComponent"]
Image.SAVE["JPEG2000"](im, op, filename)
else:
msg = f"unsupported PDF filter ({decode_filter})"
raise ValueError(msg)
stream = op.getvalue()
filter: PdfParser.PdfArray | PdfParser.PdfName
if decode_filter == "CCITTFaxDecode":
stream = stream[8:]
filter = PdfParser.PdfArray([PdfParser.PdfName(decode_filter)])
else:
filter = PdfParser.PdfName(decode_filter)
image_ref = image_refs.pop(0)
existing_pdf.write_obj(
image_ref,
stream=stream,
Type=PdfParser.PdfName("XObject"),
Subtype=PdfParser.PdfName("Image"),
Width=width, # * 72.0 / x_resolution,
Height=height, # * 72.0 / y_resolution,
Filter=filter,
Decode=decode,
DecodeParms=params,
**dict_obj,
)
return image_ref, procset
def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
) -> None:
is_appending = im.encoderinfo.get("append", False)
filename_str = filename.decode() if isinstance(filename, bytes) else filename
if is_appending:
existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b")
else:
existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b")
dpi = im.encoderinfo.get("dpi")
if dpi:
x_resolution = dpi[0]
y_resolution = dpi[1]
else:
x_resolution = y_resolution = im.encoderinfo.get("resolution", 72.0)
info = {
"title": (
None if is_appending else os.path.splitext(os.path.basename(filename))[0]
),
"author": None,
"subject": None,
"keywords": None,
"creator": None,
"producer": None,
"creationDate": None if is_appending else time.gmtime(),
"modDate": None if is_appending else time.gmtime(),
}
for k, default in info.items():
v = im.encoderinfo.get(k) if k in im.encoderinfo else default
if v:
existing_pdf.info[k[0].upper() + k[1:]] = v
#
# make sure image data is available
im.load()
existing_pdf.start_writing()
existing_pdf.write_header()
existing_pdf.write_comment("created by Pillow PDF driver")
#
# pages
ims = [im]
if save_all:
append_images = im.encoderinfo.get("append_images", [])
for append_im in append_images:
append_im.encoderinfo = im.encoderinfo.copy()
ims.append(append_im)
number_of_pages = 0
image_refs = []
page_refs = []
contents_refs = []
for im in ims:
im_number_of_pages = 1
if save_all:
im_number_of_pages = getattr(im, "n_frames", 1)
number_of_pages += im_number_of_pages
for i in range(im_number_of_pages):
image_refs.append(existing_pdf.next_object_id(0))
if im.mode == "P" and "transparency" in im.info:
image_refs.append(existing_pdf.next_object_id(0))
page_refs.append(existing_pdf.next_object_id(0))
contents_refs.append(existing_pdf.next_object_id(0))
existing_pdf.pages.append(page_refs[-1])
#
# catalog and list of pages
existing_pdf.write_catalog()
page_number = 0
for im_sequence in ims:
im_pages: ImageSequence.Iterator | list[Image.Image] = (
ImageSequence.Iterator(im_sequence) if save_all else [im_sequence]
)
for im in im_pages:
image_ref, procset = _write_image(im, filename, existing_pdf, image_refs)
#
# page
existing_pdf.write_page(
page_refs[page_number],
Resources=PdfParser.PdfDict(
ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)],
XObject=PdfParser.PdfDict(image=image_ref),
),
MediaBox=[
0,
0,
im.width * 72.0 / x_resolution,
im.height * 72.0 / y_resolution,
],
Contents=contents_refs[page_number],
)
#
# page contents
page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % (
im.width * 72.0 / x_resolution,
im.height * 72.0 / y_resolution,
)
existing_pdf.write_obj(contents_refs[page_number], stream=page_contents)
page_number += 1
#
# trailer
existing_pdf.write_xref_and_trailer()
if hasattr(fp, "flush"):
fp.flush()
existing_pdf.close()
#
# --------------------------------------------------------------------
Image.register_save("PDF", _save)
Image.register_save_all("PDF", _save_all)
Image.register_extension("PDF", ".pdf")
Image.register_mime("PDF", "application/pdf")
Loaded 100 of 1039 files, more files were not shown because too many files have changed in this diff. Show more