Merge branch 'main' into add-dashed-line-support

This commit is contained in:
Andrew Murray 2026-05-13 09:58:47 +10:00 committed by GitHub
commit d5dac21dfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
133 changed files with 3701 additions and 855 deletions

View File

@ -1 +1 @@
cibuildwheel==3.3.1
cibuildwheel==3.4.1

View File

@ -1,4 +1,4 @@
mypy==1.19.1
mypy==1.20.2
arro3-compute
arro3-core
IceSpringPySideStubs-PyQt6

View File

@ -0,0 +1 @@
check-jsonschema==0.37.1

3
.github/FUNDING.yml vendored
View File

@ -1 +1,2 @@
tidelift: "pypi/pillow"
github: python-pillow
tidelift: pypi/pillow

424
.github/INCIDENT_RESPONSE.md vendored Normal file
View File

@ -0,0 +1,424 @@
# Incident Response Plan — Pillow
This document describes how the Pillow maintainers detect, triage, fix, communicate, and
learn from security incidents. It supplements the existing [Security Policy](SECURITY.md)
and [Release Checklist](../RELEASING.md).
---
## 1. Preparation
Maintaining readiness before an incident occurs reduces response time and errors under pressure.
### 1.1 Version Support Matrix
Security fixes are applied to the **latest stable release only**. Users on older versions
are expected to upgrade. Reporters should assume only the latest release will receive a patch.
| Branch | Status |
|---|---|
| `main` / latest stable | ✅ Security fixes applied |
| All older releases | ❌ No security support — please upgrade |
### 1.2 Team Readiness
The four members of the Pillow core team are in regular contact and share collective
responsibility for incident response. Any core team member may act as Incident Lead.
Contact details are known to all team members.
### 1.3 Readiness Review
At each quarterly release, maintainers should re-read this document and update any stale content.
---
## 2. Scope
This plan covers:
| Incident type | Examples |
|---|---|
| Vulnerability in Pillow's own Python or C code | Buffer overflow in an image decoder, integer overflow in `ImagingNew` |
| Vulnerability in a bundled or wheel-shipped C library | libjpeg, libwebp, libtiff, libpng, openjpeg, libavif |
| Supply-chain compromise | Malicious commit, stolen maintainer credentials, tampered PyPI wheel |
| CI/CD or infrastructure compromise | GitHub Actions secret leak, Codecov breach, PyPI token exposure |
| Critical non-security regression | Data-loss bug shipped in a release, crash on all supported platforms |
---
## 3. Definitions
| Term | Meaning |
|---|---|
| **Incident** | Any event that compromises or threatens the confidentiality, integrity, or availability of Pillow's code, release artifacts, or infrastructure. |
| **Vulnerability** | A security flaw in Pillow or a bundled library that can be exploited by a crafted image or API call. |
| **Incident Lead** | The maintainer who owns coordination of the response from triage to closure. |
| **Embargo** | A period during which fix details are kept private to allow coordinated patching before public disclosure. |
| **Yank** | A PyPI action that keeps a release downloadable by pinned users but removes it from default `pip install` resolution. |
| **CVE** | Common Vulnerabilities and Exposures — a public identifier assigned to a specific vulnerability. |
| **CNA** | CVE Numbering Authority — GitHub is a CNA and can assign CVEs directly through the advisory workflow. |
---
## 4. Roles
| Role | Responsibility |
|---|---|
| **Incident Lead** | First maintainer to triage the report. Owns the incident until resolution. |
| **Patch Owner** | Writes and tests the fix (may be the same person as Incident Lead). |
| **Release Manager** | Cuts the point release following [RELEASING.md](../RELEASING.md). |
| **Communications Owner** | Drafts the GitHub Security Advisory, announces on Mastodon, notifies distros. |
| **Tidelift Contact** | For reports that arrive via Tidelift, coordinate through the Tidelift security portal. |
One person may fill multiple roles.
---
## 5. Severity Classification
Use the [CVSS 4.0](https://www.first.org/cvss/v4.0/specification-document) base score as
a guide, mapped to the following levels:
| Severity | CVSS | Definition | Target Response SLA |
|---|---|---|---|
| **Critical** | 9.0 10.0 | Remote code execution, arbitrary write, or complete integrity/confidentiality loss achievable by opening a crafted image | Best effort; embargoed release where possible |
| **High** | 7.0 8.9 | Heap/stack buffer overflow, use-after-free, or significant information disclosure | Best effort |
| **Medium** | 4.0 6.9 | Denial of service via crafted image, out-of-bounds read, limited info disclosure | Next scheduled quarterly release, or earlier point release if needed |
| **Low** | 0.1 3.9 | Minor information disclosure, unlikely to be exploitable in practice | Next quarterly release |
Supply-chain and CI/CD incidents are always treated as **Critical** regardless of CVSS.
> **Note:** These are good-faith targets for a small volunteer maintainer team, not contractual SLAs. Public safety and transparency will always be prioritised, even when timing varies.
---
## 6. Detection Sources
Vulnerabilities and incidents may be reported or discovered through:
1. **GitHub private security advisory** — preferred channel; see [SECURITY.md](SECURITY.md)
2. **Tidelift security contact**<https://tidelift.com/docs/security>
3. **External researcher / coordinated disclosure** — e.g. Google Project Zero, vendor PSIRT
4. **Automated scanning** — Dependabot, GitHub code-scanning (CodeQL), CI fuzzing
5. **Distro security teams** — Debian, Red Hat, Ubuntu, Alpine may report upstream
6. **User bug report** — public issue (reassess if it has security implications and convert to a private advisory if needed)
---
## 7. Response Process
### 7.1 Triage (all severities)
1. **Acknowledge receipt** to the reporter within **72 hours** using the template in
[Appendix A](#appendix-a-communication-templates). Ask the reporter:
- How they would like to be credited (name, handle, or anonymous)
- Whether they intend to publish their own advisory, and if so, their preferred timeline
- Thank them explicitly — reporters do the project a favour by disclosing privately.
2. Reproduce the issue. If the report is invalid, close it and notify the reporter.
3. Assign a severity level ([Section 5: Severity Classification](#5-severity-classification)).
4. If the GitHub Security Advisory was not created by the reporter, create one now and keep
it **private** until the fix is released. Add the reporter as a collaborator if they wish
to be involved.
5. **Request a CVE** through the GitHub Security Advisory workflow (GitHub is a CVE
Numbering Authority — no separate MITRE form required). The CVE is reserved privately
and published automatically when the advisory goes public.
6. **Escalation** — Escalate beyond the core maintainer team if any of the following apply:
- The fix requires changes to CPython or a dependency outside Pillow's control → contact the relevant upstream immediately
- A legal concern arises (e.g. GDPR-reportable data exposure) → contact the project's legal/fiscal sponsor
- The Incident Lead is unreachable for > 24 hours on a Critical issue → any other maintainer may assume the role
### 7.2 Fix Development
1. Develop the fix in a **private fork** or directly in the private security advisory
workspace on GitHub. Do **not** push to a public branch before the embargo lifts.
2. Write a regression test that fails before the fix and passes after.
3. Review the patch with at least one other maintainer.
### 7.3 Standard (Non-Embargoed) Release
For Medium and Low severity, or when no distro pre-notification is needed:
1. Merge the fix to `main`, then cherry-pick to all affected release branches
(see [RELEASING.md — Point release](../RELEASING.md)).
2. Amend commit messages to include the CVE identifier.
3. Follow the [Point release](../RELEASING.md#point-release) process in RELEASING.md to
tag, push, and confirm wheels are live on PyPI.
4. Publish the GitHub Security Advisory (this simultaneously publishes the CVE).
### 7.4 Embargoed Release
For Critical and High severity where distro pre-notification improves user safety:
1. Prepare patches against all affected release branches and test locally.
2. Agree on an **embargo date** with the reporter (typically 714 days out, up to 90 days for
complex issues).
3. Privately send the patch to distros via the
[linux-distros](https://oss-security.openwall.org/wiki/mailing-lists/distros) mailing list
or directly to individual distro security teams.
4. On the embargo date:
- Amend commit messages with the CVE identifier.
- Follow the [Embargoed release](../RELEASING.md#embargoed-release) process in
RELEASING.md to tag, push, and confirm wheels are live on PyPI.
- Publish the GitHub Security Advisory.
### 7.5 Supply-Chain / Infrastructure Compromise
1. **Immediately** revoke any potentially compromised credentials:
- PyPI API tokens
- GitHub personal access tokens and OAuth apps
- Codecov or other CI service tokens
2. Audit recent commits and releases for tampering:
- Verify release tags against known-good SHAs
- Re-inspect any wheel published since the potential compromise window
3. If a PyPI release is suspected to be tampered: yank it immediately via the
[PyPI release management page](https://pypi.org/manage/project/Pillow/releases/)
(login required); see [https://pypi.org/security/](https://pypi.org/security/) for
reporting to the PyPI security team.
4. Issue a public advisory describing the scope and any user action required.
### 7.6 Recovery
After the fix is released and the advisory is public:
1. Verify that the patched wheels are live on PyPI and passing CI across all supported platforms.
2. Confirm any yanked releases are handled correctly .
3. Resume normal development operations on `main`.
4. Monitor the GitHub issue tracker and Mastodon for user reports of residual problems for at least **72 hours** post-release.
5. Close the private GitHub Security Advisory once recovery is confirmed.
---
## 8. Communication
### Internal (during embargo)
- Use the **private GitHub Security Advisory** thread for coordination with the reporter.
- Use private communication channels for all other coordination.
- Do not discuss details in public issues, PRs, or Gitter/IRC channels.
### External (at or after disclosure)
| Audience | Channel | Timing |
|---|---|---|
| General users | [GitHub Security Advisory](https://github.com/python-pillow/Pillow/security/advisories) | At release |
| PyPI ecosystem | CVE published via advisory | At release |
| Downstream distros | Direct email or linux-distros list | Before embargo date (embargoed) |
| Tidelift subscribers | Tidelift security portal | At release (or coordinated) |
| Community | [Mastodon @pillow](https://fosstodon.org/@pillow) | At release |
**Advisory content should include:**
- CVE identifier and CVSS score
- Affected Pillow versions
- Fixed version(s)
- Nature of the vulnerability (without full exploit details if still fresh)
- Credit to the reporter (with their consent)
- Upgrade instructions (`python3 -m pip install --upgrade Pillow`)
---
## 9. Dependency Map
Understanding what Pillow depends on (upstream) and what depends on Pillow (downstream)
is essential for scoping impact and coordinating notifications during an incident.
### 9.1 Upstream Dependencies
#### Bundled C libraries (shipped in official wheels)
These libraries are compiled into Pillow's binary wheels. A CVE in any of them may
require a Pillow point release even if Pillow's own code is unchanged.
| Library | Purpose | Security advisory tracker |
|---|---|---|
| [libjpeg-turbo](https://libjpeg-turbo.org/) | JPEG encode/decode | [GitHub](https://github.com/libjpeg-turbo/libjpeg-turbo/security) |
| [libpng](http://www.libpng.org/pub/png/libpng.html) | PNG encode/decode within FreeType 2, OpenJPEG and WebP | [SourceForge](https://sourceforge.net/p/libpng/bugs/) |
| [libtiff](https://libtiff.gitlab.io/libtiff/) | TIFF encode/decode | [GitLab](https://gitlab.com/libtiff/libtiff/-/work_items) |
| [libwebp](https://chromium.googlesource.com/webm/libwebp) | WebP encode/decode | [Chromium tracker](https://issues.webmproject.org/issues) |
| [libavif](https://github.com/AOMediaCodec/libavif) | AVIF encode/decode | [GitHub](https://github.com/AOMediaCodec/libavif/security) |
| [aom](https://aomedia.googlesource.com/aom/) | AV1 codec (AVIF) | [Chromium tracker](https://aomedia.issues.chromium.org/issues) |
| [dav1d](https://code.videolan.org/videolan/dav1d) | AV1 decode (AVIF) | [VideoLAN Security](https://www.videolan.org/security/) |
| [openjpeg](https://www.openjpeg.org/) | JPEG 2000 encode/decode | [GitHub](https://github.com/uclouvain/openjpeg/security) |
| [freetype2](https://freetype.org/) | Font rendering | [GitLab](https://gitlab.freedesktop.org/freetype/freetype/-/work_items) |
| [lcms2](https://www.littlecms.com/) | ICC color management | [GitHub](https://github.com/mm2/Little-CMS/security) |
| [harfbuzz](https://harfbuzz.github.io/) | Text shaping (via raqm) | [GitHub](https://github.com/harfbuzz/harfbuzz/security) |
| [raqm](https://github.com/HOST-Oman/libraqm) | Complex text layout | [GitHub](https://github.com/HOST-Oman/libraqm) |
| [fribidi](https://github.com/fribidi/fribidi) | Unicode bidi (via raqm) | [GitHub](https://github.com/fribidi/fribidi) |
| [zlib](https://zlib.net/) | Deflate compression | [zlib.net](https://zlib.net/) |
| [liblzma / xz-utils](https://tukaani.org/xz/) | XZ/LZMA compression | [GitHub](https://github.com/tukaani-project/xz/security) |
| [bzip2](https://gitlab.com/bzip2/bzip2) | BZ2 compression | [GitLab](https://gitlab.com/bzip2/bzip2/-/work_items) |
| [zstd](https://github.com/facebook/zstd) | Zstandard compression | [GitHub](https://github.com/facebook/zstd/security) |
| [brotli](https://github.com/google/brotli) | Brotli compression | [GitHub](https://github.com/google/brotli/security) |
| [libyuv](https://chromium.googlesource.com/libyuv/libyuv/) | YUV conversion | [Chromium tracker](https://libyuv.issues.chromium.org/issues) |
#### Python-level dependencies
| Package | Required? | Purpose |
|---|---|---|
| `setuptools` | Build-time only | Package build backend |
| `pybind11` | Build-time only | Compile C files in parallel |
| `olefile` | Optional (`fpx`, `mic` extras) | OLE2 container parsing (FPX, MIC formats) |
| `defusedxml` | Optional (`xmp` extra) | Safe XML parsing for XMP metadata |
See [`pyproject.toml`](../pyproject.toml) for the complete and authoritative list of
optional dependencies.
### 9.2 Responding to an Upstream Vulnerability
When a CVE is published for a bundled C library:
1. Assess whether the vulnerable code path is reachable through Pillow's API.
2. If reachable, treat as a Pillow vulnerability and follow [Section 5: Severity Classification](#5-severity-classification).
3. Update the bundled library version in the wheel build scripts and rebuild wheels.
4. Reference the upstream CVE in Pillow's release notes and GitHub Security Advisory.
5. If not reachable, document the rationale in a public issue so downstream distributors
can make informed decisions about patching their system packages.
### 9.3 Downstream Dependencies
A vulnerability in Pillow can have wide impact. Notify or consider the blast radius of
these downstream consumers when assessing severity and planning communications.
#### Linux distribution packages
| Distribution | Package name | Security contact |
|---|---|---|
| Debian / Ubuntu | `python3-pil` | [Debian Security](https://www.debian.org/security/) / [Ubuntu Security](https://ubuntu.com/security) |
| Fedora / RHEL / CentOS | `python3-pillow` | [Red Hat Security](https://access.redhat.com/security/) |
| Alpine Linux | `py3-pillow` | [Alpine security](https://security.alpinelinux.org/) |
| Arch Linux | `python-pillow` | [Arch security tracker](https://security.archlinux.org/) |
| Homebrew | `pillow` | [Homebrew maintainers](https://github.com/Homebrew/homebrew-core/security) |
| conda-forge | `pillow` | [conda-forge](https://github.com/conda-forge/pillow-feedstock) |
#### Major Python ecosystem consumers
These are high-profile projects known to depend on Pillow; a critical vulnerability may
warrant proactive notification.
| Project | Usage |
|---|---|
| [matplotlib](https://matplotlib.org/) | Image I/O for plots |
| [scikit-image](https://scikit-image.org/) | Image processing |
| [torchvision](https://github.com/pytorch/vision) (PyTorch) | Dataset loading, transforms |
| [Keras / TensorFlow](https://keras.io/) | Image preprocessing utilities |
| [Django](https://www.djangoproject.com/) | `ImageField` validation and thumbnail generation |
| [Wagtail](https://wagtail.org/) | CMS image renditions |
| [Plone](https://plone.org/) | CMS image handling |
| [Jupyter / IPython](https://jupyter.org/) | Inline image display |
| [ReportLab](https://www.reportlab.com/) | PDF image embedding |
| [Tidelift subscribers](https://tidelift.com/) | Enterprise consumers (coordinated via Tidelift) |
#### Pillow ecosystem plugins
Third-party plugins extend Pillow and are distributed separately on PyPI. Their
maintainers should be notified for Critical/High issues that affect the plugin API
or the formats they decode. See the
[full plugin list](https://pillow.readthedocs.io/en/stable/handbook/third-party-plugins.html#plugin-list).
---
## 11. Plan Maintenance
This document is a living record. It should be kept current so it is useful when an incident actually occurs. Revisit it during the [Section 1.3 readiness review](#13-readiness-review) at each quarterly release.
---
## 12. References
- [Security Policy](SECURITY.md)
- [Release Checklist](../RELEASING.md)
- [Contributing Guide](CONTRIBUTING.md)
- [Tidelift Security Contact](https://tidelift.com/docs/security)
- [GitHub: Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)
- [GitHub as a CVE Numbering Authority (CNA)](https://docs.github.com/en/code-security/security-advisories/working-with-repository-security-advisories/about-repository-security-advisories)
- [FIRST CVSS 4.0 Calculator](https://www.first.org/cvss/calculator/4.0)
- [linux-distros mailing list](https://oss-security.openwall.org/wiki/mailing-lists/distros)
- [OpenSSF CVD Guide](https://github.com/ossf/oss-vulnerability-guide) *(basis for this plan)*
---
## Appendix A: Communication Templates
### A.1 Reporter Acknowledgment
> Subject: Re: [Security] \<brief issue description\>
>
> Hi \<name\>,
>
> Thank you for taking the time to report this issue. We appreciate it.
>
> We have received your report and will review it as soon as possible. We will
> keep you updated on our progress.
>
> Questions:
>
> - How would you like to be credited in the advisory? (name, handle,
> organisation, or anonymous)
> - Do you plan to publish your own write-up or advisory? If so, do you have a
> disclosure date in mind?
>
> We apply coordinated disclosure principles to all vulnerability reports. If
> you have any questions or concerns at any point, please reply to this thread.
>
> Thank you again,
> The Pillow team
### A.2 Embargoed Distro Notification
> Subject: [EMBARGOED] Pillow security issue — \<CVE-XXXX-XXXXX\> — disclosure \<DATE\>
>
> This is an embargoed notification of a vulnerability in Pillow. Please keep this
> information confidential until the disclosure date listed below.
>
> **CVE:** \<CVE-XXXX-XXXXX\>
>
> **Affected versions:** \<e.g. Pillow < 11.x.x\>
>
> **Fixed version:** \<version\>
>
> **Severity:** \<Critical / High / Medium / Low\> (CVSS \<score\>: \<vector\>)
>
> **Reporter:** \<name / affiliation, or "reported privately"\>
>
> **Public disclosure date:** \<DATE TIME UTC\>
>
> **Summary:**
> \<One paragraph describing the vulnerability class and impact without a full exploit.\>
>
> **Proof of concept:**
> \<Minimal reproducer or attached patch.\>
>
> **Remediation:**
> Upgrade to Pillow \<fixed version\>. No known workaround.
>
> Please do not share this information, issue public patches, or make user communications
> before the disclosure date. We will notify this list immediately if the date changes.
>
> — The Pillow maintainers
### A.3 Public Disclosure Advisory
*(Published as a GitHub Security Advisory; the CVE and date are included automatically.)*
> **Summary:** \<One-paragraph technical summary.\>
>
> **CVE:** \<CVE-XXXX-XXXXX\>
>
> **Affected versions:** Pillow \< \<fixed version\>
>
> **Fixed version:** \<version\>
>
> **Severity:** \<rating\> (CVSS \<score\>)
>
> **Reporter:** \<credited name / "reported privately"\>
>
> **Details:**
> \<Fuller technical description. Include attack scenario where helpful.\>
>
> **Remediation:**
> ```
> python3 -m pip install --upgrade Pillow
> ```
>
> **Timeline:**
> - Reported: \<date\>
> - Fixed: \<date\>
> - Disclosed: \<date\>

20
.github/SECURITY.md vendored
View File

@ -1,5 +1,21 @@
# Security policy
To report sensitive vulnerability information, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure.
## Reporting a vulnerability
If your organisation/employer is a distributor of Pillow and would like advance notification of security-related bugs, please let us know your preferred contact method.
To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new).
If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/docs/security). Tidelift will coordinate the fix and disclosure.
**DO NOT report sensitive vulnerability information in public.**
## Threat model
Pillow's primary attack surface is parsing untrusted image data. A full STRIDE threat model covering spoofing, tampering, repudiation, information disclosure, denial of service, and elevation of privilege is maintained in the [Security handbook page](https://pillow.readthedocs.io/en/latest/handbook/security.html).
Key risks to be aware of when using Pillow to process untrusted images:
- **Decompression bombs** — do not set `Image.MAX_IMAGE_PIXELS = None` in production.
- **EPS files invoke Ghostscript** — block EPS input at the application layer unless strictly required.
- **`ImageMath.unsafe_eval()`** — never pass user-controlled strings to this function; use `lambda_eval` instead.
- **C extension memory safety** — keep Pillow and its bundled C libraries (libjpeg, libpng, libtiff, libwebp, etc.) up to date.
- **Sandboxing** — for high-risk deployments, run image processing in a sandboxed subprocess.

271
.github/compare-dist-sizes.py vendored Normal file
View File

@ -0,0 +1,271 @@
"""Compare sizes of newly-built dists against the latest release on PyPI.
Fetches file sizes for the latest Pillow release from the PyPI JSON API
(no download required) and compares them to a directory of freshly-built
wheels and sdist. Outputs a table to stdout (and to
`$GITHUB_STEP_SUMMARY` if set).
Usage:
`uv run .github/compare-dist-sizes.py <dist-dir>`
"""
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "humanize",
# "prettytable",
# "termcolor",
# ]
# ///
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
import humanize
from prettytable import PrettyTable, TableStyle
from termcolor import colored
PYPI_JSON_URL = "https://pypi.org/pypi/pillow/json"
# Wheel filename: {distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl
# sdist filename: {distribution}-{version}.tar.gz
WHEEL_RE = re.compile(
r"^[^-]+-[^-]+(?:-(?P<build>\d[^-]*))?"
r"-(?P<python>[^-]+)-(?P<abi>[^-]+)-(?P<platform>[^-]+)\.whl$",
re.IGNORECASE,
)
SDIST_RE = re.compile(
r"^(?P<dist>[^-]+)-(?P<version>.+)\.tar\.gz$",
re.IGNORECASE,
)
def key_for(filename: str) -> str:
"""Return a version-independent identifier for a dist file."""
if m := WHEEL_RE.match(filename):
build = f"{m['build']}-" if m["build"] else ""
return f"wheel:{build}{m['python']}-{m['abi']}-{m['platform']}"
if SDIST_RE.match(filename):
return "sdist"
msg = f"Unexpected dist name: {filename}"
raise ValueError(msg)
def display_for(filename: str) -> str:
"""Strip the `pillow-{version}-` prefix for compact table display."""
if m := WHEEL_RE.match(filename):
build = f"{m['build']}-" if m["build"] else ""
return f"{build}{m['python']}-{m['abi']}-{m['platform']}.whl"
if SDIST_RE.match(filename):
return "sdist (.tar.gz)"
return filename
def fetch_pypi_sizes() -> tuple[str, dict[str, tuple[str, int]]]:
"""Return (version, {key: (filename, size)}) for the latest PyPI release."""
with urllib.request.urlopen(PYPI_JSON_URL) as response:
data = json.load(response)
version = data["info"]["version"]
sizes: dict[str, tuple[str, int]] = {}
for entry in data.get("urls", []):
filename = entry["filename"]
key = key_for(filename)
sizes[key] = (filename, entry["size"])
return version, sizes
def collect_local_sizes(dist_dir: Path) -> dict[str, tuple[str, int]]:
sizes: dict[str, tuple[str, int]] = {}
for path in sorted(dist_dir.iterdir()):
if not path.is_file():
continue
key = key_for(path.name)
sizes[key] = (path.name, path.stat().st_size)
return sizes
def human(n: int | None) -> str:
if n is None:
return "n/a"
return humanize.naturalsize(n)
def pct_change(before: int | None, after: int | None) -> str:
if before is None or after is None:
return "n/a"
delta = 0 if before == 0 else (after - before) / before * 100
return f"{delta:+.2f}%"
def pct_severity(text: str) -> dict[str, str] | None:
"""Return status indicators based on the change percent."""
if text == "n/a":
return None
pct = float(text.rstrip("%"))
if pct >= 5:
return {"color": "red", "emoji": "🔴"}
if pct > 0:
return {"color": "yellow", "emoji": "🟡"}
else:
return {"color": "green", "emoji": "🟢"}
def render_table(
baseline_label: str,
baseline_sizes: dict[str, tuple[str, int]],
local_sizes: dict[str, tuple[str, int]],
*,
markdown: bool,
) -> str:
table = PrettyTable()
table.set_style(TableStyle.MARKDOWN if markdown else TableStyle.SINGLE_BORDER)
table.field_names = ["File", "Size before", "Size now", "Change"]
table.align = "r"
table.align["File"] = "l"
def style(cells: list[str], role: str) -> list[str]:
severity = pct_severity(cells[3])
if markdown:
if severity:
cells[3] = f"{severity['emoji']} {cells[3]}"
if role == "orphan":
return [f"*{c}*" for c in cells]
if role == "summary":
return [f"**{c}**" for c in cells]
return cells
if role == "orphan":
return [colored(c, "dark_grey") for c in cells]
bold_attrs = ["bold"] if role == "summary" else []
if bold_attrs:
cells[:3] = [colored(c, attrs=bold_attrs) for c in cells[:3]]
if severity:
cells[3] = colored(cells[3], severity["color"], attrs=bold_attrs)
elif bold_attrs:
cells[3] = colored(cells[3], attrs=bold_attrs)
return cells
keys = list(set(baseline_sizes) | set(local_sizes))
# Put sdist first for readability
keys.sort(key=lambda k: (k != "sdist", k))
wheel_before = []
wheel_after = []
total_before = []
total_after = []
for key in keys:
baseline_entry = baseline_sizes.get(key)
local_entry = local_sizes.get(key)
display_name = display_for((local_entry or baseline_entry)[0])
before = baseline_entry[1] if baseline_entry else None
after = local_entry[1] if local_entry else None
if after is None:
# Removed since baseline: ignore in totals
role = "orphan"
else:
# Present locally (in both, or newly added): count in totals
total_after.append(after)
if before is not None:
total_before.append(before)
if key != "sdist":
wheel_after.append(after)
if before is not None:
wheel_before.append(before)
role = "data"
cells = [
display_name,
human(before),
human(after),
pct_change(before, after),
]
table.add_row(style(cells, role))
if not markdown:
table.add_divider()
if wheel_after:
avg_before = sum(wheel_before) // len(wheel_before) if wheel_before else None
table.add_row(
style(
[
f"wheel average ({len(wheel_after)} wheels)",
human(avg_before),
human(sum(wheel_after) // len(wheel_after)),
pct_change(avg_before, sum(wheel_after) // len(wheel_after)),
],
"summary",
)
)
table.add_row(
style(
[
f"wheel total ({len(wheel_after)} wheels)",
human(sum(wheel_before)),
human(sum(wheel_after)),
pct_change(sum(wheel_before), sum(wheel_after)),
],
"summary",
),
divider=not markdown,
)
if total_after:
table.add_row(
style(
[
f"artifacts total ({len(total_after)} artifacts)",
human(sum(total_before)),
human(sum(total_after)),
pct_change(sum(total_before), sum(total_after)),
],
"summary",
)
)
title = f"## Dist size comparison vs {baseline_label}"
if not markdown:
title = colored(title, attrs=["bold"])
return f"{title}\n\n{table.get_string()}\n"
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"dist_dir",
type=Path,
help="Directory containing newly-built wheels and sdist",
)
args = parser.parse_args()
if not args.dist_dir.is_dir():
print(f"error: {args.dist_dir} is not a directory", file=sys.stderr)
return 1
baseline_version, baseline_sizes = fetch_pypi_sizes()
baseline_label = f"Pillow {baseline_version} on PyPI"
local_sizes = collect_local_sizes(args.dist_dir)
print(render_table(baseline_label, baseline_sizes, local_sizes, markdown=False))
if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"):
with open(summary_path, "a", encoding="utf-8") as f:
f.write(
render_table(baseline_label, baseline_sizes, local_sizes, markdown=True)
)
return 0
if __name__ == "__main__":
sys.exit(main())

19
.github/dependencies.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
"brotli": "1.2.0",
"bzip2": "1.0.8",
"freetype": "2.14.3",
"fribidi": "1.0.16",
"harfbuzz": "14.2.0",
"jpegturbo": "3.1.4.1",
"lcms2": "2.19",
"libavif": "1.4.1",
"libimagequant": "4.4.1",
"libpng": "1.6.58",
"libwebp": "1.6.0",
"libxcb": "1.17.0",
"openjpeg": "2.5.4",
"tiff": "4.7.1",
"xz": "5.8.3",
"zlib-ng": "2.3.3",
"zstd": "1.5.7"
}

560
.github/generate-sbom.py vendored Executable file
View File

@ -0,0 +1,560 @@
#!/usr/bin/env python3
"""Generate a CycloneDX 1.7 SBOM for Pillow's C extensions and their
vendored/optional native library dependencies.
Usage:
python3 .github/generate-sbom.py [output-file]
Output defaults to pillow-{version}.cdx.json in the current directory.
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import difflib
import hashlib
import json
import urllib.request
import uuid
from pathlib import Path
def get_version() -> str:
version_file = Path(__file__).parent.parent / "src" / "PIL" / "_version.py"
return version_file.read_text(encoding="utf-8").split('"')[1]
def load_dep_versions() -> dict[str, str]:
deps_file = Path(__file__).parent / "dependencies.json"
return json.loads(deps_file.read_text(encoding="utf-8"))
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def upstream_diff_b64(
upstream_url: str,
upstream_display: bytes,
local_path: Path,
local_display: bytes,
) -> str:
"""
Fetch an upstream file and return a base64-encoded unified diff vs the local copy.
"""
with urllib.request.urlopen(upstream_url) as resp:
upstream_text = resp.read()
local_text = local_path.read_bytes()
diff_lines = difflib.diff_bytes(
difflib.unified_diff,
upstream_text.splitlines(keepends=True),
local_text.splitlines(keepends=True),
fromfile=b"a/" + upstream_display,
tofile=b"b/" + local_display,
)
return base64.b64encode(b"".join(diff_lines)).decode()
def generate(version: str) -> dict:
serial = str(uuid.uuid4())
now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
purl = f"pkg:pypi/pillow@{version}"
root = Path(__file__).parent.parent
thirdparty = root / "src" / "thirdparty"
versions = load_dep_versions()
metadata_component = {
"bom-ref": purl,
"type": "library",
"name": "Pillow",
"version": version,
"description": "Python Imaging Library (fork)",
"licenses": [{"license": {"id": "MIT-CMU"}}],
"purl": purl,
"externalReferences": [
{"type": "website", "url": "https://python-pillow.github.io"},
{"type": "vcs", "url": "https://github.com/python-pillow/Pillow"},
{"type": "documentation", "url": "https://pillow.readthedocs.io"},
{
"type": "security-contact",
"url": "https://github.com/python-pillow/Pillow/security/policy",
},
],
}
c_extensions = [
("PIL._avif", "AVIF image format extension"),
(
"PIL._imaging",
"Core image processing extension "
"(decode, encode, map, display, outline, path, libImaging)",
),
("PIL._imagingcms", "LittleCMS2 colour management extension"),
("PIL._imagingft", "FreeType font rendering extension"),
("PIL._imagingmath", "Image math operations extension"),
("PIL._imagingmorph", "Image morphology extension"),
("PIL._imagingtk", "Tk/Tcl display extension"),
("PIL._webp", "WebP image format extension"),
]
ext_components = [
{
"bom-ref": f"{purl}#c-ext/{name}",
"type": "library",
"name": name,
"version": version,
"description": desc,
"licenses": [{"license": {"id": "MIT-CMU"}}],
"purl": f"{purl}#c-ext/{name}",
}
for name, desc in c_extensions
]
vendored_components = [
{
"bom-ref": f"{purl}#thirdparty/fribidi-shim",
"type": "library",
"name": "fribidi-shim",
"version": "1.x",
"description": "FriBiDi runtime-loading shim "
"(vendored in src/thirdparty/fribidi-shim/); "
"loads libfribidi dynamically",
"licenses": [{"license": {"id": "LGPL-2.1-or-later"}}],
"hashes": [
{
"alg": "SHA-256",
"content": sha256_file(thirdparty / "fribidi-shim" / "fribidi.c"),
}
],
"pedigree": {
"notes": "Pillow-authored shim; not taken from an upstream project."
},
"externalReferences": [
{"type": "website", "url": "https://github.com/fribidi/fribidi"},
],
},
{
"bom-ref": "pkg:github/python/pythoncapi-compat",
"type": "library",
"name": "pythoncapi_compat",
"description": "Backport header for new CPython C-API functions "
"(vendored in src/thirdparty/pythoncapi_compat.h)",
"licenses": [{"license": {"id": "0BSD"}}],
"hashes": [
{
"alg": "SHA-256",
"content": sha256_file(thirdparty / "pythoncapi_compat.h"),
}
],
"pedigree": {
"notes": "Vendored unmodified from upstream python/pythoncapi-compat."
},
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/python/pythoncapi-compat",
},
],
},
{
"bom-ref": f"{purl}#thirdparty/raqm",
"type": "library",
"name": "raqm",
"version": "0.10.5",
"description": "Complex text layout library "
"(vendored in src/thirdparty/raqm/)",
"licenses": [{"license": {"id": "MIT"}}],
"hashes": [
{
"alg": "SHA-256",
"content": sha256_file(thirdparty / "raqm" / "raqm.c"),
}
],
"pedigree": {
"ancestors": [
{
"bom-ref": "pkg:github/HOST-Oman/libraqm@0.10.5#upstream",
"type": "library",
"name": "raqm",
"version": "0.10.5",
"purl": "pkg:github/HOST-Oman/libraqm@0.10.5",
"externalReferences": [
{
"type": "distribution",
"url": "https://github.com/HOST-Oman/libraqm/releases/tag/v0.10.5",
}
],
}
],
"patches": [
{
"type": "unofficial",
"diff": {
"text": {
# raqm-version.h.in → raqm-version.h:
# template @RAQM_VERSION_*@ placeholders replaced
# with literal 0.10.5 values; filename changed to
# drop the .in suffix; minor indentation fix.
"content": upstream_diff_b64(
"https://raw.githubusercontent.com/HOST-Oman/libraqm/v0.10.5/src/raqm-version.h.in",
b"src/raqm-version.h.in",
thirdparty / "raqm" / "raqm-version.h",
b"src/raqm-version.h",
),
"encoding": "base64",
}
},
},
{
"type": "unofficial",
"diff": {
"text": {
# raqm.c: wrap the <fribidi.h> include in an
# #ifdef HAVE_FRIBIDI_SYSTEM guard so that when
# building without a system FriBiDi Pillow's own
# fribidi-shim is used instead.
"content": upstream_diff_b64(
"https://raw.githubusercontent.com/HOST-Oman/libraqm/v0.10.5/src/raqm.c",
b"src/raqm.c",
thirdparty / "raqm" / "raqm.c",
b"src/raqm.c",
),
"encoding": "base64",
}
},
},
],
"notes": (
"Vendored from upstream HOST-Oman/libraqm v0.10.5 with two "
"Pillow-specific modifications: (1) raqm-version.h.in was "
"pre-processed into raqm-version.h with version placeholders "
"replaced by literal values; (2) raqm.c wraps the <fribidi.h> "
"include in an #ifdef HAVE_FRIBIDI_SYSTEM guard so Pillow's "
"bundled fribidi-shim is used when a system FriBiDi is absent."
),
},
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/python-pillow/Pillow/tree/main/src/thirdparty/raqm",
},
],
},
]
native_deps = [
{
"bom-ref": "pkg:generic/freetype2",
"type": "library",
"name": "FreeType",
"version": versions["freetype"],
"scope": "optional",
"description": "Font rendering (optional, used by PIL._imagingft). "
"Required for text/font support.",
"licenses": [{"license": {"id": "FTL"}}],
"externalReferences": [
{"type": "website", "url": "https://freetype.org"},
{
"type": "distribution",
"url": "https://download.savannah.gnu.org/releases/freetype/",
},
],
},
{
"bom-ref": "pkg:generic/fribidi",
"type": "library",
"name": "FriBiDi",
"version": versions["fribidi"],
"scope": "optional",
"description": "Unicode bidi algorithm library (optional, "
"loaded at runtime by fribidi-shim).",
"licenses": [{"license": {"id": "LGPL-2.1-or-later"}}],
"externalReferences": [
{"type": "website", "url": "https://github.com/fribidi/fribidi"},
{
"type": "distribution",
"url": "https://github.com/fribidi/fribidi/releases",
},
],
},
{
"bom-ref": "pkg:generic/harfbuzz",
"type": "library",
"name": "HarfBuzz",
"version": versions["harfbuzz"],
"scope": "optional",
"description": "Text shaping (optional, required by libraqm "
"for complex text layout).",
"licenses": [{"license": {"id": "MIT"}}],
"externalReferences": [
{"type": "website", "url": "https://harfbuzz.github.io"},
{
"type": "distribution",
"url": "https://github.com/harfbuzz/harfbuzz/releases",
},
],
},
{
"bom-ref": "pkg:generic/libavif",
"type": "library",
"name": "libavif",
"version": versions["libavif"],
"scope": "optional",
"description": "AVIF codec (optional, used by PIL._avif).",
"licenses": [{"license": {"id": "BSD-2-Clause"}}],
"externalReferences": [
{"type": "website", "url": "https://github.com/AOMediaCodec/libavif"},
{
"type": "distribution",
"url": "https://github.com/AOMediaCodec/libavif/releases",
},
],
},
{
"bom-ref": "pkg:generic/libimagequant",
"type": "library",
"name": "libimagequant",
"version": versions["libimagequant"],
"scope": "optional",
"description": "Improved colour quantization (optional).",
"licenses": [{"license": {"id": "GPL-3.0-or-later"}}],
"externalReferences": [
{"type": "website", "url": "https://pngquant.org/lib/"},
{
"type": "distribution",
"url": "https://github.com/ImageOptim/libimagequant/tags",
},
],
},
{
"bom-ref": "pkg:generic/libjpeg",
"type": "library",
"name": "libjpeg / libjpeg-turbo",
"version": versions["jpegturbo"],
"description": "JPEG codec (required by default; disable with "
"-C jpeg=disable).",
"licenses": [
{"license": {"id": "IJG"}},
{"license": {"id": "BSD-3-Clause"}},
],
"externalReferences": [
{"type": "website", "url": "https://ijg.org"},
{"type": "website", "url": "https://libjpeg-turbo.org"},
{
"type": "distribution",
"url": "https://github.com/libjpeg-turbo/libjpeg-turbo/releases",
},
],
},
{
"bom-ref": "pkg:generic/libtiff",
"type": "library",
"name": "libtiff",
"version": versions["tiff"],
"scope": "optional",
"description": "TIFF codec (optional).",
"licenses": [{"license": {"id": "libtiff"}}],
"externalReferences": [
{"type": "website", "url": "https://libtiff.gitlab.io/libtiff/"},
{
"type": "distribution",
"url": "https://download.osgeo.org/libtiff/",
},
],
},
{
"bom-ref": "pkg:generic/libwebp",
"type": "library",
"name": "libwebp",
"version": versions["libwebp"],
"scope": "optional",
"description": "WebP codec (optional, used by PIL._webp).",
"licenses": [{"license": {"id": "BSD-3-Clause"}}],
"externalReferences": [
{
"type": "website",
"url": "https://chromium.googlesource.com/webm/libwebp",
},
{
"type": "distribution",
"url": "https://chromium.googlesource.com/webm/libwebp",
},
],
},
{
"bom-ref": "pkg:generic/libxcb",
"type": "library",
"name": "libxcb",
"version": versions["libxcb"],
"scope": "optional",
"description": "X11 screen-grab support (optional, "
"used by PIL._imaging on macOS and Linux).",
"licenses": [{"license": {"id": "X11"}}],
"externalReferences": [
{"type": "website", "url": "https://xcb.freedesktop.org"},
{
"type": "distribution",
"url": "https://xcb.freedesktop.org/dist/",
},
],
},
{
"bom-ref": "pkg:generic/littlecms2",
"type": "library",
"name": "Little CMS 2",
"version": versions["lcms2"],
"scope": "optional",
"description": "Colour management (optional, used by PIL._imagingcms).",
"licenses": [{"license": {"id": "MIT"}}],
"externalReferences": [
{"type": "website", "url": "https://www.littlecms.com"},
{
"type": "distribution",
"url": "https://github.com/mm2/Little-CMS/releases",
},
],
},
{
"bom-ref": "pkg:generic/openjpeg",
"type": "library",
"name": "OpenJPEG",
"version": versions["openjpeg"],
"scope": "optional",
"description": "JPEG 2000 codec (optional).",
"licenses": [{"license": {"id": "BSD-2-Clause"}}],
"externalReferences": [
{"type": "website", "url": "https://www.openjpeg.org"},
{
"type": "distribution",
"url": "https://github.com/uclouvain/openjpeg/releases",
},
],
},
{
"bom-ref": "pkg:pypi/pybind11",
"type": "library",
"name": "pybind11",
"scope": "excluded",
"description": "Parallel C compilation library (build-time dependency).",
"licenses": [{"license": {"id": "BSD-3-Clause"}}],
"externalReferences": [
{"type": "website", "url": "https://pybind11.readthedocs.io"},
{
"type": "distribution",
"url": "https://github.com/pybind/pybind11/releases",
},
],
},
{
"bom-ref": "pkg:generic/zlib",
"type": "library",
"name": "zlib",
"version": versions["zlib-ng"],
"description": "Deflate/PNG compression (required by default; "
"disable with -C zlib=disable).",
"licenses": [{"license": {"id": "Zlib"}}],
"externalReferences": [
{"type": "website", "url": "https://zlib.net"},
{"type": "distribution", "url": "https://zlib.net"},
],
},
]
dependencies = [
{
"ref": purl,
"dependsOn": [e["bom-ref"] for e in ext_components],
},
{
"ref": f"{purl}#c-ext/PIL._avif",
"dependsOn": ["pkg:generic/libavif"],
},
{
"ref": f"{purl}#c-ext/PIL._imaging",
"dependsOn": [
"pkg:generic/libimagequant",
"pkg:generic/libjpeg",
"pkg:generic/libtiff",
"pkg:generic/libxcb",
"pkg:generic/openjpeg",
"pkg:generic/zlib",
],
},
{
"ref": f"{purl}#c-ext/PIL._imagingcms",
"dependsOn": ["pkg:generic/littlecms2"],
},
{
"ref": f"{purl}#c-ext/PIL._imagingft",
"dependsOn": [
"pkg:generic/freetype2",
"pkg:generic/fribidi",
"pkg:generic/harfbuzz",
f"{purl}#thirdparty/fribidi-shim",
f"{purl}#thirdparty/raqm",
],
},
{
"ref": f"{purl}#c-ext/PIL._webp",
"dependsOn": ["pkg:generic/libwebp"],
},
{
"ref": f"{purl}#thirdparty/raqm",
"dependsOn": [
"pkg:generic/harfbuzz",
f"{purl}#thirdparty/fribidi-shim",
],
},
]
return {
"$schema": "http://cyclonedx.org/schema/bom-1.7.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": f"urn:uuid:{serial}",
"version": 1,
"metadata": {
"timestamp": now,
"lifecycles": [{"phase": "build"}],
"tools": {
"components": [
{
"type": "application",
"name": "generate-sbom.py",
"group": "pillow",
}
]
},
"component": metadata_component,
},
"components": ext_components + vendored_components + native_deps,
"dependencies": dependencies,
}
def main() -> None:
version = get_version()
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"output",
nargs="?",
type=Path,
default=Path(f"pillow-{version}.cdx.json"),
help="output file",
)
args = parser.parse_args()
sbom = generate(version)
args.output.write_text(json.dumps(sbom, indent=2) + "\n", encoding="utf-8")
print(
f"Wrote {args.output} (Pillow {version}, {len(sbom['components'])} components)"
)
if __name__ == "__main__":
main()

165
.github/renovate.json vendored
View File

@ -7,16 +7,169 @@
"Dependency"
],
"minimumReleaseAge": "7 days",
"prCreation": "not-pending",
"schedule": [
"* * 3 * *"
],
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"brotli\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "brotli",
"packageNameTemplate": "google/brotli",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"bzip2\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "bzip2",
"packageNameTemplate": "bzip2/bzip2",
"datasourceTemplate": "gitlab-tags",
"extractVersionTemplate": "^bzip2-(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"freetype\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "freetype",
"packageNameTemplate": "freetype/freetype",
"datasourceTemplate": "gitlab-tags",
"registryUrlTemplate": "https://gitlab.freedesktop.org",
"extractVersionTemplate": "^VER-(?<version>[\\d-]+)$",
"versioningTemplate": "regex:^(?<major>\\d+)[.-](?<minor>\\d+)[.-](?<patch>\\d+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"fribidi\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "fribidi",
"packageNameTemplate": "fribidi/fribidi",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"harfbuzz\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "harfbuzz",
"packageNameTemplate": "harfbuzz/harfbuzz",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"jpegturbo\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "jpegturbo",
"packageNameTemplate": "libjpeg-turbo/libjpeg-turbo",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"lcms2\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "lcms2",
"packageNameTemplate": "mm2/Little-CMS",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^lcms(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"libavif\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "libavif",
"packageNameTemplate": "AOMediaCodec/libavif",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"libimagequant\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "libimagequant",
"packageNameTemplate": "ImageOptim/libimagequant",
"datasourceTemplate": "github-tags"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"libpng\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "libpng",
"packageNameTemplate": "pnggroup/libpng",
"datasourceTemplate": "github-tags",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"libwebp\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "libwebp",
"packageNameTemplate": "webmproject/libwebp",
"datasourceTemplate": "github-tags",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"libxcb\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "libxcb",
"packageNameTemplate": "xorg/lib/libxcb",
"datasourceTemplate": "gitlab-tags",
"registryUrlTemplate": "https://gitlab.freedesktop.org",
"extractVersionTemplate": "^libxcb-(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"openjpeg\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "openjpeg",
"packageNameTemplate": "uclouvain/openjpeg",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"tiff\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "tiff",
"packageNameTemplate": "libtiff/libtiff",
"datasourceTemplate": "gitlab-tags",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"xz\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "xz",
"packageNameTemplate": "tukaani-project/xz",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"zlib-ng\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "zlib-ng",
"packageNameTemplate": "zlib-ng/zlib-ng",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"managerFilePatterns": ["/^\\.github/dependencies\\.json$/"],
"matchStrings": ["\"zstd\":\\s*\"(?<currentValue>\\d+[^\"]*)\""],
"depNameTemplate": "zstd",
"packageNameTemplate": "facebook/zstd",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
}
],
"packageRules": [
{
"groupName": "github-actions",
"matchManagers": [
"github-actions"
],
"matchManagers": ["github-actions"],
"separateMajorMinor": false
}
],
"schedule": [
"* * 3 * *"
]
}

13
.github/workflows/Brewfile vendored Normal file
View File

@ -0,0 +1,13 @@
brew "aom"
brew "dav1d"
brew "freetype"
brew "ghostscript"
brew "jpeg-turbo"
brew "libimagequant"
brew "libraqm"
brew "libtiff"
brew "little-cms2"
brew "openjpeg"
brew "rav1e"
brew "svt-av1"
brew "webp"

View File

@ -4,17 +4,14 @@ on:
push:
branches:
- "**"
paths:
paths: &paths
- ".github/dependencies.json"
- ".github/workflows/cifuzz.yml"
- ".github/workflows/wheels-dependencies.sh"
- "**.c"
- "**.h"
pull_request:
paths:
- ".github/workflows/cifuzz.yml"
- ".github/workflows/wheels-dependencies.sh"
- "**.c"
- "**.h"
paths: *paths
workflow_dispatch:
permissions:
@ -33,27 +30,27 @@ jobs:
steps:
- name: Build Fuzzers
id: build
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master
with:
oss-fuzz-project-name: 'pillow'
language: python
dry-run: false
- name: Run Fuzzers
id: run
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@d87225267726cf7ce1a3e17cf103c5ac943c4f05 # master
with:
oss-fuzz-project-name: 'pillow'
fuzz-seconds: 600
language: python
dry-run: false
- name: Upload New Crash
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure() && steps.build.outcome == 'success'
with:
name: artifacts
path: ./out/artifacts
- name: Upload Legacy Crash
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.run.outcome == 'success'
with:
name: crash

View File

@ -4,15 +4,12 @@ on:
push:
branches:
- "**"
paths:
paths: &paths
- ".github/workflows/docs.yml"
- "docs/**"
- "src/PIL/**"
pull_request:
paths:
- ".github/workflows/docs.yml"
- "docs/**"
- "src/PIL/**"
paths: *paths
workflow_dispatch:
permissions:
@ -32,12 +29,12 @@ jobs:
name: Docs
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
cache: pip
@ -49,21 +46,21 @@ jobs:
run: python3 .github/workflows/system-info.py
- name: Cache libavif
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-libavif
with:
path: ~/cache-libavif
key: ${{ runner.os }}-libavif-${{ hashFiles('depends/install_libavif.sh', 'depends/libavif-svt4.patch') }}
- name: Cache libimagequant
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-libimagequant
with:
path: ~/cache-libimagequant
key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }}
- name: Cache libwebp
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-libwebp
with:
path: ~/cache-libwebp

View File

@ -18,14 +18,14 @@ jobs:
runs-on: ubuntu-latest
name: Lint
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Lint
run: uvx --with tox-uv tox -e lint
- name: Mypy

View File

@ -2,20 +2,7 @@
set -e
brew install \
aom \
dav1d \
freetype \
ghostscript \
jpeg-turbo \
libimagequant \
libraqm \
libtiff \
little-cms2 \
openjpeg \
rav1e \
svt-av1 \
webp
brew bundle --file=.github/workflows/Brewfile
export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig"
python3 -m pip install coverage

View File

@ -26,6 +26,6 @@ jobs:
runs-on: ubuntu-latest
steps:
# Drafts your next release notes as pull requests are merged into "main"
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -17,7 +17,7 @@ env:
jobs:
stale:
if: github.repository_owner == 'python-pillow'
if: github.event.repository.fork == false
permissions:
issues: write
@ -25,7 +25,7 @@ jobs:
steps:
- name: "Check issues"
uses: actions/stale@v10
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: "Awaiting OP Action"

View File

@ -4,19 +4,14 @@ on:
push:
branches:
- "**"
paths-ignore:
paths-ignore: &paths-ignore
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
paths-ignore: *paths-ignore
workflow_dispatch:
permissions:
@ -39,39 +34,37 @@ jobs:
os: ["ubuntu-latest"]
docker: [
# Run slower jobs first to give them a headstart and reduce waiting time
ubuntu-24.04-noble-ppc64le,
ubuntu-24.04-noble-s390x,
ubuntu-26.04-resolute-ppc64le,
ubuntu-26.04-resolute-s390x,
# Then run the remainder
alpine,
amazon-2-amd64,
amazon-2023-amd64,
arch,
centos-stream-9-amd64,
centos-stream-10-amd64,
debian-12-bookworm-x86,
debian-12-bookworm-amd64,
debian-13-trixie-x86,
debian-13-trixie-amd64,
fedora-42-amd64,
fedora-43-amd64,
fedora-44-amd64,
gentoo,
ubuntu-22.04-jammy-amd64,
ubuntu-24.04-noble-amd64,
ubuntu-26.04-resolute-amd64,
]
dockerTag: [main]
include:
- docker: "ubuntu-24.04-noble-ppc64le"
- docker: "ubuntu-26.04-resolute-ppc64le"
qemu-arch: "ppc64le"
- docker: "ubuntu-24.04-noble-s390x"
- docker: "ubuntu-26.04-resolute-s390x"
qemu-arch: "s390x"
- docker: "ubuntu-24.04-noble-arm64v8"
- docker: "ubuntu-26.04-resolute-arm64v8"
os: "ubuntu-24.04-arm"
dockerTag: main
name: ${{ matrix.docker }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -80,7 +73,7 @@ jobs:
- name: Set up QEMU
if: "matrix.qemu-arch"
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
with:
platforms: ${{ matrix.qemu-arch }}
@ -108,11 +101,10 @@ jobs:
.ci/after_success.sh
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
flags: GHA_Docker
name: ${{ matrix.docker }}
token: ${{ secrets.CODECOV_ORG_TOKEN }}
success:
permissions:

View File

@ -4,19 +4,14 @@ on:
push:
branches:
- "**"
paths-ignore:
paths-ignore: &paths-ignore
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
paths-ignore: *paths-ignore
workflow_dispatch:
permissions:
@ -46,7 +41,7 @@ jobs:
steps:
- name: Checkout Pillow
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -87,9 +82,8 @@ jobs:
.ci/test.sh
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
files: ./coverage.xml
flags: GHA_Windows
name: "MSYS2 MinGW"
token: ${{ secrets.CODECOV_ORG_TOKEN }}

View File

@ -8,12 +8,13 @@ on:
# branches:
# - "**"
# paths:
# - ".github/workflows/test-valgrind.yml"
# - ".github/workflows/test-valgrind-memory.yml"
# - "**.c"
# - "**.h"
# - "depends/docker-test-valgrind-memory.sh"
pull_request:
paths:
- ".github/workflows/test-valgrind.yml"
- ".github/workflows/test-valgrind-memory.yml"
- "**.c"
- "**.h"
- "depends/docker-test-valgrind-memory.sh"
@ -44,7 +45,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@ -6,15 +6,12 @@ on:
push:
branches:
- "**"
paths:
paths: &paths
- ".github/workflows/test-valgrind.yml"
- "**.c"
- "**.h"
pull_request:
paths:
- ".github/workflows/test-valgrind.yml"
- "**.c"
- "**.h"
paths: *paths
workflow_dispatch:
permissions:
@ -42,7 +39,7 @@ jobs:
name: ${{ matrix.docker }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@ -4,19 +4,14 @@ on:
push:
branches:
- "**"
paths-ignore:
paths-ignore: &paths-ignore
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
paths-ignore: *paths-ignore
workflow_dispatch:
permissions:
@ -49,19 +44,19 @@ jobs:
steps:
- name: Checkout Pillow
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Checkout cached dependencies
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
repository: python-pillow/pillow-depends
path: winbuild\depends
- name: Checkout extra test images
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
repository: python-pillow/test-images
@ -69,7 +64,7 @@ jobs:
# sets env: pythonLocation
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
@ -113,7 +108,7 @@ jobs:
- name: Cache build
id: build-cache
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: winbuild\build
key:
@ -217,7 +212,7 @@ jobs:
shell: bash
- name: Upload errors
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: errors
@ -229,12 +224,11 @@ jobs:
shell: pwsh
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
files: ./coverage.xml
flags: GHA_Windows
name: ${{ runner.os }} Python ${{ matrix.python-version }}
token: ${{ secrets.CODECOV_ORG_TOKEN }}
success:
permissions:

View File

@ -4,19 +4,14 @@ on:
push:
branches:
- "**"
paths-ignore:
paths-ignore: &paths-ignore
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
pull_request:
paths-ignore:
- ".github/workflows/docs.yml"
- ".github/workflows/wheels*"
- ".gitmodules"
- "docs/**"
- "wheels/**"
paths-ignore: *paths-ignore
workflow_dispatch:
permissions:
@ -69,12 +64,12 @@ jobs:
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
@ -93,7 +88,7 @@ jobs:
- name: Cache libavif
if: startsWith(matrix.os, 'ubuntu')
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-libavif
with:
path: ~/cache-libavif
@ -101,7 +96,7 @@ jobs:
- name: Cache libimagequant
if: startsWith(matrix.os, 'ubuntu')
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-libimagequant
with:
path: ~/cache-libimagequant
@ -109,7 +104,7 @@ jobs:
- name: Cache libwebp
if: startsWith(matrix.os, 'ubuntu')
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-libwebp
with:
path: ~/cache-libwebp
@ -162,7 +157,7 @@ jobs:
mkdir -p Tests/errors
- name: Upload errors
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: errors
@ -173,11 +168,10 @@ jobs:
.ci/after_success.sh
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
token: ${{ secrets.CODECOV_ORG_TOKEN }}
success:
permissions:

View File

@ -89,22 +89,23 @@ fi
ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds.
FREETYPE_VERSION=2.14.3
HARFBUZZ_VERSION=13.2.1
LIBPNG_VERSION=1.6.55
JPEGTURBO_VERSION=3.1.3
OPENJPEG_VERSION=2.5.4
XZ_VERSION=5.8.2
ZSTD_VERSION=1.5.7
TIFF_VERSION=4.7.1
LCMS2_VERSION=2.18
ZLIB_NG_VERSION=2.3.3
LIBWEBP_VERSION=1.6.0
BZIP2_VERSION=1.0.8
LIBXCB_VERSION=1.17.0
BROTLI_VERSION=1.2.0
LIBAVIF_VERSION=1.4.1
VERSIONS_FILE="$PROJECTDIR/.github/dependencies.json"
_get_ver() { python3 -c "import json; print(json.load(open('$VERSIONS_FILE'))['$1'])"; }
FREETYPE_VERSION=$(_get_ver freetype)
HARFBUZZ_VERSION=$(_get_ver harfbuzz)
LIBPNG_VERSION=$(_get_ver libpng)
JPEGTURBO_VERSION=$(_get_ver jpegturbo)
OPENJPEG_VERSION=$(_get_ver openjpeg)
XZ_VERSION=$(_get_ver xz)
ZSTD_VERSION=$(_get_ver zstd)
TIFF_VERSION=$(_get_ver tiff)
LCMS2_VERSION=$(_get_ver lcms2)
ZLIB_NG_VERSION=$(_get_ver zlib-ng)
LIBWEBP_VERSION=$(_get_ver libwebp)
BZIP2_VERSION=$(_get_ver bzip2)
LIBXCB_VERSION=$(_get_ver libxcb)
BROTLI_VERSION=$(_get_ver brotli)
LIBAVIF_VERSION=$(_get_ver libavif)
function build_pkg_config {
if [ -e pkg-config-stamp ]; then return; fi
@ -178,7 +179,6 @@ function build_libavif {
build_simple nasm 2.16.03 https://www.nasm.us/pub/nasm/releasebuilds/2.16.03
fi
local build_type=MinSizeRel
local build_shared=ON
local lto=ON
@ -195,9 +195,6 @@ function build_libavif {
build_shared=OFF
fi
else
if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "x86_64" ]]; then
build_type=Release
fi
libavif_cmake_flags=(-DCMAKE_SHARED_LINKER_FLAGS_INIT="-Wl,--strip-all,-z,relro,-z,now")
fi
if [[ -n "$IOS_SDK" ]] && [[ "$PLAT" == "x86_64" ]]; then
@ -226,7 +223,7 @@ function build_libavif {
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=$lto \
-DCMAKE_C_VISIBILITY_PRESET=hidden \
-DCMAKE_CXX_VISIBILITY_PRESET=hidden \
-DCMAKE_BUILD_TYPE=$build_type \
-DCMAKE_BUILD_TYPE=MinSizeRel \
"${libavif_cmake_flags[@]}" \
$HOST_CMAKE_FLAGS . )

View File

@ -10,9 +10,13 @@ on:
# │ │ │ │ │
- cron: "42 1 * * 0,3"
push:
paths:
paths: &paths
- ".ci/requirements-cibw.txt"
- ".github/workflows/wheel*"
- ".ci/requirements-sbom.txt"
- ".github/compare-dist-sizes.py"
- ".github/dependencies.json"
- ".github/generate-sbom.py"
- ".github/workflows/wheels*"
- "pyproject.toml"
- "setup.py"
- "wheels/*"
@ -21,14 +25,7 @@ on:
tags:
- "*"
pull_request:
paths:
- ".ci/requirements-cibw.txt"
- ".github/workflows/wheel*"
- "pyproject.toml"
- "setup.py"
- "wheels/*"
- "winbuild/build_prepare.py"
- "winbuild/fribidi.cmake"
paths: *paths
workflow_dispatch:
permissions:
@ -39,12 +36,12 @@ concurrency:
cancel-in-progress: true
env:
EXPECTED_DISTS: 91
EXPECTED_DISTS: 66
FORCE_COLOR: 1
jobs:
build-native-wheels:
if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
if: github.event_name != 'schedule' || github.event.repository.fork == false
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
@ -74,26 +71,26 @@ jobs:
os: macos-latest
cibw_arch: arm64
macosx_deployment_target: "11.0"
- name: "manylinux2014 and musllinux x86_64"
platform: linux
os: ubuntu-latest
cibw_arch: x86_64
manylinux: "manylinux2014"
- name: "manylinux_2_28 x86_64"
platform: linux
os: ubuntu-latest
cibw_arch: x86_64
build: "*manylinux*"
- name: "manylinux2014 and musllinux aarch64"
- name: "musllinux x86_64"
platform: linux
os: ubuntu-24.04-arm
cibw_arch: aarch64
manylinux: "manylinux2014"
os: ubuntu-latest
cibw_arch: x86_64
build: "*musllinux*"
- name: "manylinux_2_28 aarch64"
platform: linux
os: ubuntu-24.04-arm
cibw_arch: aarch64
build: "*manylinux*"
- name: "musllinux aarch64"
platform: linux
os: ubuntu-24.04-arm
cibw_arch: aarch64
build: "*musllinux*"
- name: "iOS arm64 device"
platform: ios
os: macos-latest
@ -104,15 +101,15 @@ jobs:
cibw_arch: arm64_iphonesimulator
- name: "iOS x86_64 simulator"
platform: ios
os: macos-26-intel
os: macos-15-intel
cibw_arch: x86_64_iphonesimulator
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: true
- uses: actions/setup-python@v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
@ -127,20 +124,16 @@ jobs:
CIBW_PLATFORM: ${{ matrix.platform }}
CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BUILD: ${{ matrix.build }}
CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy
CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.manylinux }}
CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.manylinux }}
CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }}
CIBW_ENABLE: cpython-prerelease pypy
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist-${{ matrix.name }}
path: ./wheelhouse/*.whl
windows:
if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
if: github.event_name != 'schedule' || github.event.repository.fork == false
name: Windows ${{ matrix.cibw_arch }}
runs-on: ${{ matrix.os }}
strategy:
@ -154,18 +147,18 @@ jobs:
- cibw_arch: ARM64
os: windows-11-arm
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Checkout extra test images
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
repository: python-pillow/test-images
path: Tests\test-images
- uses: actions/setup-python@v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
@ -202,7 +195,7 @@ jobs:
CIBW_ARCHS: ${{ matrix.cibw_arch }}
CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd"
CIBW_CACHE_PATH: "C:\\cibw"
CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy
CIBW_ENABLE: cpython-prerelease pypy
CIBW_TEST_SKIP: "*-win_arm64"
CIBW_TEST_COMMAND: 'docker run --rm
-v {project}:C:\pillow
@ -214,33 +207,33 @@ jobs:
shell: bash
- name: Upload wheels
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist-windows-${{ matrix.cibw_arch }}
path: ./wheelhouse/*.whl
- name: Upload fribidi.dll
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fribidi-windows-${{ matrix.cibw_arch }}
path: winbuild\build\bin\fribidi*
sdist:
if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow'
if: github.event_name != 'schedule' || github.event.repository.fork == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
- run: make sdist
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist-sdist
path: dist/*.tar.gz
@ -250,7 +243,7 @@ jobs:
runs-on: ubuntu-latest
name: Count dists
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: dist-*
path: dist
@ -263,25 +256,98 @@ jobs:
echo $files
[ "$files" -eq $EXPECTED_DISTS ] || exit 1
compare-dist-sizes:
needs: [build-native-wheels, windows, sdist]
runs-on: ubuntu-latest
name: Compare dist sizes vs PyPI
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: dist-*
path: dist
merge-multiple: true
- name: Compare dist sizes vs latest PyPI release
run: uv run .github/compare-dist-sizes.py dist
scientific-python-nightly-wheels-publish:
if: github.repository_owner == 'python-pillow' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
if: github.event.repository.fork == false && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
needs: count-dists
runs-on: ubuntu-latest
name: Upload wheels to scientific-python-nightly-wheels
environment:
name: release-anaconda
url: https://anaconda.org/channels/scientific-python-nightly-wheels/packages/pillow/overview
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: dist-!(sdist)*
path: dist
merge-multiple: true
- name: Upload wheels to scientific-python-nightly-wheels
uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3
uses: scientific-python/upload-nightly-action@e76cfec8a4611fd02808a801b0ff5a7d7c1b2d99 # 0.6.4
with:
artifacts_path: dist
anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }}
sbom:
if: github.event_name != 'schedule' || github.event.repository.fork == false
runs-on: ubuntu-latest
name: Generate SBOM
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
- name: Generate CycloneDX SBOM
run: python3 .github/generate-sbom.py
- name: Upload SBOM as workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: "pillow-*.cdx.json"
- name: Validate SBOM
run: |
python3 -m pip install -r .ci/requirements-sbom.txt
check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json
sbom-publish:
if: |
github.event.repository.fork == false
&& github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags')
needs: [count-dists, sbom]
runs-on: ubuntu-latest
name: Publish SBOM to GitHub release
permissions:
contents: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sbom
path: .
- name: Attach SBOM to GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$GITHUB_REF_NAME" pillow-*.cdx.json
pypi-publish:
if: github.repository_owner == 'python-pillow' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
if: github.event.repository.fork == false && github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
needs: count-dists
runs-on: ubuntu-latest
name: Upload release to PyPI
@ -291,12 +357,12 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: dist-*
path: dist
merge-multiple: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
attestations: true

6
.github/zizmor.yml vendored
View File

@ -1,6 +0,0 @@
# https://docs.zizmor.sh/configuration/
rules:
unpinned-uses:
config:
policies:
"*": ref-pin

3
.gitignore vendored
View File

@ -97,3 +97,6 @@ pillow-test-images.zip
# pyinstaller
*.spec
# Generated SBOM
pillow-*.cdx.json

View File

@ -1,12 +1,12 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.4
rev: v0.15.12
hooks:
- id: ruff-check
args: [--exit-non-zero-on-fix]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 26.1.0
rev: 26.3.1
hooks:
- id: black
@ -24,7 +24,7 @@ repos:
exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$)
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v22.1.0
rev: v22.1.4
hooks:
- id: clang-format
types: [c]
@ -48,18 +48,20 @@ repos:
args: [--allow-multiple-documents]
- id: end-of-file-fixer
exclude: ^Tests/images/
- id: file-contents-sorter
files: .github/workflows/Brewfile
- id: trailing-whitespace
exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.37.0
rev: 0.37.2
hooks:
- id: check-github-workflows
- id: check-readthedocs
- id: check-renovate
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
rev: v1.24.1
hooks:
- id: zizmor
@ -69,7 +71,7 @@ repos:
- id: sphinx-lint
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.16.2
rev: v2.21.1
hooks:
- id: pyproject-fmt

View File

@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is
Pillow is the friendly PIL fork. It is
Copyright © 2010 by Jeffrey A. Clark and contributors
Copyright © 2010 by Jeffrey 'Alex' Clark and contributors
Like PIL, Pillow is licensed under the open source MIT-CMU License:

View File

@ -6,11 +6,13 @@
## Python Imaging Library (Fork)
Pillow is the friendly PIL fork by [Jeffrey A. Clark and
Pillow is the friendly PIL fork by [Jeffrey 'Alex' Clark and
contributors](https://github.com/python-pillow/Pillow/graphs/contributors).
PIL is the Python Imaging Library by Fredrik Lundh and contributors.
As of 2019, Pillow development is
[supported by Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise).
Development is supported by:
- [Tidelift](https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=readme&utm_campaign=enterprise) (since 2018)
- [Thanks.dev](https://thanks.dev) (since 2023)
- [GitHub Sponsors](https://github.com/sponsors/python-pillow) (since 2026)
<table>
<tr>
@ -106,4 +108,8 @@ The core image library is designed for fast access to data stored in a few basic
## Report a vulnerability
To report a security vulnerability, please follow the procedure described in the [Tidelift security policy](https://tidelift.com/docs/security).
To report sensitive vulnerability information, report it [privately on GitHub](https://github.com/python-pillow/Pillow/security/advisories/new).
If you cannot use GitHub, use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure.
DO NOT report sensitive vulnerability information in public.

View File

@ -19,6 +19,7 @@ Released as needed for security, installation or critical bug fixes.
git checkout -t remotes/origin/5.2.x
```
* [ ] Cherry pick individual commits from `main` branch to release branch e.g. `5.2.x`, then `git push`.
* [ ] If this is a security fix: amend commits to include the CVE identifier in the commit message.
* [ ] Check [GitHub Actions](https://github.com/python-pillow/Pillow/actions) to confirm passing tests in release branch e.g. `5.2.x`.
* [ ] In compliance with [PEP 440](https://peps.python.org/pep-0440/), update version identifier in `src/PIL/_version.py`
* [ ] Run pre-release check via `make release-test`.
@ -38,6 +39,7 @@ Released as needed for security, installation or critical bug fixes.
```bash
git push
```
* [ ] If this is a security fix: publish the [GitHub Security Advisory or Advisories](https://github.com/python-pillow/Pillow/security/advisories).
## Embargoed release

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -56,7 +56,7 @@ def test_questionable() -> None:
im.load()
if os.path.basename(f) not in supported:
print(f"Please add {f} to the partially supported bmp specs.")
except Exception: # as msg:
except Exception: # noqa: PERF203
if os.path.basename(f) in supported:
raise
@ -106,7 +106,7 @@ def test_good() -> None:
assert_image_similar(im_converted, compare_converted, 5)
except Exception as msg:
except Exception as msg: # noqa: PERF203
# there are three here that are unsupported:
unsupported = (
os.path.join(base, "g", "rgb32bf.bmp"),

View File

@ -145,14 +145,14 @@ class TestFileAvif:
# avifdec hopper.avif avif/hopper_avif_write.png
assert_image_similar_tofile(
reloaded, "Tests/images/avif/hopper_avif_write.png", 6.88
reloaded, "Tests/images/avif/hopper_avif_write.png", 6.93
)
# This test asserts that the images are similar. If the average pixel
# difference between the two images is less than the epsilon value,
# then we're going to accept that it's a reasonable lossy version of
# the image.
assert_image_similar(reloaded, im, 9.28)
assert_image_similar(reloaded, im, 9.39)
def test_AvifEncoder_with_invalid_args(self) -> None:
"""

View File

@ -42,7 +42,7 @@ def test_fallback_if_mmap_errors() -> None:
# This image has been truncated,
# so that the buffer is not large enough when using mmap
with Image.open("Tests/images/mmap_error.bmp") as im:
assert_image_equal_tofile(im, "Tests/images/pal8_offset.bmp")
assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp")
def test_save_to_bytes() -> None:
@ -238,11 +238,21 @@ def test_unsupported_bmp_bitfields_layout() -> None:
Image.open(fp)
def test_offset() -> None:
# This image has been hexedited
# to exclude the palette size from the pixel data offset
with Image.open("Tests/images/pal8_offset.bmp") as im:
assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp")
@pytest.mark.parametrize(
"offset, path",
(
(26, "pal8os2.bmp"),
(54, "pal8.bmp"),
),
)
def test_offset(offset: int, path: str) -> None:
image_path = "Tests/images/bmp/g/" + path
# Exclude the palette size from the pixel data offset
with open(image_path, "rb") as fp:
data = fp.read()
data = data[:10] + o32(offset) + data[14:]
with Image.open(io.BytesIO(data)) as im:
assert_image_equal_tofile(im, image_path)
def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -179,9 +179,7 @@ def test_iter(bytesmode: bool) -> None:
container = ContainerIO.ContainerIO(fh, 0, 120)
# Act
data = []
for line in container:
data.append(line)
data = list(container)
# Assert
if bytesmode:

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import io
import subprocess
from pathlib import Path
import pytest
@ -281,6 +282,11 @@ def test_bytesio_object() -> None:
),
)
def test_1(filename: str) -> None:
gs_binary = EpsImagePlugin.gs_binary
assert isinstance(gs_binary, str)
if subprocess.check_output([gs_binary, "--version"]) == b"10.06.0\n":
pytest.skip("Fails with Ghostscript 10.06.0")
with Image.open(filename) as im:
assert_image_equal_tofile(im, "Tests/images/eps/1.bmp")

View File

@ -178,9 +178,9 @@ def test_default_num_resolutions(
def test_reduce() -> None:
with Image.open("Tests/images/test-card-lossless.jp2") as im:
assert callable(im.reduce)
assert isinstance(im, Jpeg2KImagePlugin.Jpeg2KImageFile)
im.reduce = 2 # type: ignore[assignment, method-assign]
im.reduce = 2
assert im.reduce == 2
im.load()
@ -456,6 +456,13 @@ def test_pclr() -> None:
assert len(im.palette.colors) == 256
assert im.palette.colors[(255, 255, 255)] == 0
for enumcs in (0, 15, 17):
with open(f"{EXTRA_DIR}/issue104_jpxstream.jp2", "rb") as fp:
data = bytearray(fp.read())
data[114:115] = bytes([enumcs])
with Image.open(BytesIO(data)) as im:
assert im.mode == "L"
with Image.open(
f"{EXTRA_DIR}/147af3f1083de4393666b7d99b01b58b_signal_sigsegv_130c531_6155_5136.jp2"
) as im:

View File

@ -224,10 +224,7 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open("Tests/images/hopper_g4.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
for tag in im.tag_v2:
try:
del core_items[tag]
except KeyError:
pass
core_items.pop(tag, None)
del core_items[320] # colormap is special, tested below
# Type codes:
@ -1058,6 +1055,15 @@ class TestFileLibTiff(LibTiffTestCase):
with Image.open("Tests/images/tiff_strip_planar_16bit_RGBa.tiff") as im:
assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png")
def test_separate_planar_extra_samples(self, tmp_path: Path) -> None:
out = tmp_path / "temp.tif"
with Image.open("Tests/images/separate_planar_extra_samples.tiff") as im:
assert im.mode == "L"
im.save(out)
with Image.open(out) as reloaded:
assert reloaded.mode == "L"
@pytest.mark.parametrize("compression", (None, "jpeg"))
def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None:
im = hopper()

View File

@ -6,7 +6,14 @@ from typing import Any
import pytest
from PIL import Image, ImageFile, JpegImagePlugin, MpoImagePlugin
from PIL import (
Image,
ImageFile,
JpegImagePlugin,
MpoImagePlugin,
TiffImagePlugin,
_binary,
)
from .helper import (
assert_image_equal,
@ -145,6 +152,32 @@ def test_parallax() -> None:
assert exif.get_ifd(0x927C)[0xB211] == -3.125
def test_truncated_makernote() -> None:
def check(ifd: TiffImagePlugin.ImageFileDirectory_v2) -> None:
fp = BytesIO()
ifd.save(fp)
e = Image.Exif()
e.load(fp.getvalue())
assert e.get_ifd(37500) == {}
# Nintendo
ifd = TiffImagePlugin.ImageFileDirectory_v2()
ifd[271] = "Nintendo"
ifd[34665] = {37500: b" "}
check(ifd)
# Fujifilm
for data in (
b"FUJIFILM",
b"FUJIFILM" + _binary.o32le(50),
b"FUJIFILM" + _binary.o32le(0),
):
ifd = TiffImagePlugin.ImageFileDirectory_v2()
ifd[34665] = {37500: data}
check(ifd)
def test_reload_exif_after_seek() -> None:
with Image.open("Tests/images/sugarshack.mpo") as im:
exif = im.getexif()

View File

@ -4,7 +4,7 @@ import re
import sys
import warnings
import zlib
from io import BytesIO
from io import BytesIO, TextIOWrapper
from pathlib import Path
from types import ModuleType
from typing import Any, cast
@ -502,8 +502,9 @@ class TestFilePng:
im = roundtrip(im)
assert im.info["transparency"] == (248, 248, 248)
im = roundtrip(im, transparency=(0, 1, 2))
assert im.info["transparency"] == (0, 1, 2)
for transparency in ((0, 1, 2), [0, 1, 2]):
im = roundtrip(im, transparency=transparency)
assert im.info["transparency"] == (0, 1, 2)
def test_trns_p(self, tmp_path: Path) -> None:
# Check writing a transparency of 0, issue #528
@ -518,6 +519,36 @@ class TestFilePng:
assert_image_equal(im2.convert("RGBA"), im.convert("RGBA"))
def test_trns_invalid(self, tmp_path: Path) -> None:
out = tmp_path / "temp.png"
for mode in ("1", "L", "I;16"):
im = Image.new(mode, (1, 1))
with pytest.raises(
ValueError, match=f"transparency for {mode} must be an integer"
):
im.save(out, transparency="invalid")
im = Image.new("I", (1, 1))
with pytest.warns(DeprecationWarning, match="Saving I mode images as PNG"):
with pytest.raises(ValueError):
im.save(out, transparency="invalid")
im = Image.new("P", (1, 1))
with pytest.raises(
ValueError, match="transparency for P must be an integer or bytes"
):
im.save(out, transparency="invalid")
im = Image.new("RGB", (1, 1))
with pytest.raises(
ValueError, match="transparency for RGB must be list or tuple"
):
im.save(out, transparency="invalid")
with pytest.raises(ValueError, match="transparency for RGB must have length 3"):
im.save(out, transparency=(1, 2))
def test_trns_null(self) -> None:
# Check reading images with null tRNS value, issue #1239
test_file = "Tests/images/tRNS_null_1x1.png"
@ -821,19 +852,15 @@ class TestFilePng:
@pytest.mark.parametrize("buffer", (True, False))
def test_save_stdout(self, buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None:
class MyStdOut:
buffer = BytesIO()
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
fp = BytesIO()
mystdout = TextIOWrapper(fp) if buffer else fp
monkeypatch.setattr(sys, "stdout", mystdout)
with Image.open(TEST_PNG_FILE) as im:
im.save(sys.stdout, "PNG") # type: ignore[arg-type]
if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer
with Image.open(mystdout) as reloaded:
with Image.open(fp) as reloaded:
assert_image_equal_tofile(reloaded, TEST_PNG_FILE)
def test_truncated_end_chunk(self, monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -1,7 +1,7 @@
from __future__ import annotations
import sys
from io import BytesIO
from io import BytesIO, TextIOWrapper
from pathlib import Path
import pytest
@ -381,17 +381,13 @@ def test_mimetypes(tmp_path: Path) -> None:
@pytest.mark.parametrize("buffer", (True, False))
def test_save_stdout(buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None:
class MyStdOut:
buffer = BytesIO()
mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO()
fp = BytesIO()
mystdout = TextIOWrapper(fp) if buffer else fp
monkeypatch.setattr(sys, "stdout", mystdout)
with Image.open(TEST_FILE) as im:
im.save(sys.stdout, "PPM") # type: ignore[arg-type]
if isinstance(mystdout, MyStdOut):
mystdout = mystdout.buffer
with Image.open(mystdout) as reloaded:
with Image.open(fp) as reloaded:
assert_image_equal_tofile(reloaded, TEST_FILE)

View File

@ -1,12 +1,18 @@
from __future__ import annotations
import sys
import warnings
import pytest
from PIL import Image, PsdImagePlugin
from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_pypy
from .helper import (
assert_image_equal_tofile,
assert_image_similar,
hopper,
is_pypy,
)
test_file = "Tests/images/hopper.psd"
@ -85,6 +91,11 @@ def test_eoferror() -> None:
# Test that seeking to the last frame does not raise an error
im.seek(n_frames - 1)
# Test seeking past the last frame without calling n_frames first
with Image.open(test_file) as im:
with pytest.raises(EOFError):
im.seek(3)
def test_seek_tell() -> None:
with Image.open(test_file) as im:
@ -199,3 +210,17 @@ def test_bounds_crash(test_file: str) -> None:
with pytest.raises(ValueError):
im.load()
def test_bounds_crash_overflow() -> None:
with Image.open("Tests/images/psd-oob-write-overflow.psd") as im:
assert isinstance(im, PsdImagePlugin.PsdImageFile)
im.load()
if sys.maxsize <= 2**32:
with pytest.raises(OverflowError):
im.seek(im.n_frames)
else:
im.seek(im.n_frames)
with pytest.raises(ValueError):
im.load()

View File

@ -14,10 +14,6 @@ from .helper import assert_image_equal, hopper, is_pypy
TEST_FILE = "Tests/images/hopper.spider"
def teardown_module() -> None:
del Image.EXTENSION[".spider"]
def test_sanity() -> None:
with Image.open(TEST_FILE) as im:
im.load()
@ -67,6 +63,8 @@ def test_save(tmp_path: Path) -> None:
assert im2.size == (128, 128)
assert im2.format == "SPIDER"
del Image.EXTENSION[".spider"]
@pytest.mark.parametrize("size", ((0, 1), (1, 0), (0, 0)))
def test_save_zero(size: tuple[int, int]) -> None:

View File

@ -1,11 +1,12 @@
from __future__ import annotations
import os
from io import BytesIO
from pathlib import Path
import pytest
from PIL import Image, UnidentifiedImageError
from PIL import Image, UnidentifiedImageError, _binary
from .helper import assert_image_equal, assert_image_equal_tofile, hopper
@ -92,6 +93,25 @@ def test_rgba_16() -> None:
assert im.getpixel((1, 0)) == (0, 255, 82, 0)
def test_v2_no_alpha() -> None:
test_file = "Tests/images/tga/common/200x32_rgba_tl_rle.tga"
with open(test_file, "rb") as fp:
data = fp.read()
data += (
b"\x00" * 495
+ _binary.o32le(len(data))
+ _binary.o32le(0)
+ b"TRUEVISION-XFILE.\x00"
)
with Image.open(BytesIO(data)) as im:
with Image.open(test_file) as im2:
r, g, b = im2.split()[:3]
a = Image.new("L", im2.size, 255)
expected = Image.merge("RGBA", (r, g, b, a))
assert_image_equal(im, expected)
def test_id_field() -> None:
# tga file with id field
test_file = "Tests/images/tga_id_field.tga"

View File

@ -16,6 +16,7 @@ from PIL import (
TiffImagePlugin,
TiffTags,
UnidentifiedImageError,
_binary,
)
from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION
@ -941,6 +942,15 @@ class TestFileTiff:
4001,
]
def test_truncated_photoshop_blocks(self) -> None:
with Image.open("Tests/images/hopper.tif") as im:
assert isinstance(im, TiffImagePlugin.TiffImageFile)
im.tag_v2[34377] = b"8BIM"
assert im.get_photoshop_blocks() == {}
im.tag_v2[34377] = b"8BIM" + _binary.o16be(0) + _binary.o8(2) + b" " * 5
assert im.get_photoshop_blocks() == {}
def test_tiff_chunks(self, tmp_path: Path) -> None:
tmpfile = tmp_path / "temp.tif"

View File

@ -49,6 +49,12 @@ class TestFileWebp:
assert version is not None
assert re.search(r"\d+\.\d+\.\d+$", version)
def test_invalid_file(self) -> None:
invalid_file = "Tests/images/flower.jpg"
with pytest.raises(SyntaxError):
WebPImagePlugin.WebPImageFile(invalid_file)
def test_read_rgb(self) -> None:
"""
Can we read a RGB mode WebP file without error?

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from .helper import skip_unless_feature
class TestFontCrash:
def _fuzz_font(self, font: ImageFont.FreeTypeFont) -> None:
@ -14,6 +16,7 @@ class TestFontCrash:
draw.multiline_textbbox((10, 10), "ABC\nAaaa", font, stroke_width=2)
draw.text((10, 10), "Test Text", font=font, fill="#000")
@skip_unless_feature("freetype2")
def test_segfault(self) -> None:
font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784")
self._fuzz_font(font)

View File

@ -862,7 +862,7 @@ class TestImage:
def test_exif_webp(self, tmp_path: Path) -> None:
with Image.open("Tests/images/hopper.webp") as im:
exif = im.getexif()
assert exif == {}
assert dict(exif) == {}
out = tmp_path / "temp.webp"
exif[258] = 8
@ -884,7 +884,7 @@ class TestImage:
def test_exif_png(self, tmp_path: Path) -> None:
with Image.open("Tests/images/exif.png") as im:
exif = im.getexif()
assert exif == {274: 1}
assert dict(exif) == {274: 1}
out = tmp_path / "temp.png"
exif[258] = 8

View File

@ -627,3 +627,37 @@ class TestCoreResampleBox:
0.4,
f">>> {size} {box} {flt}",
)
class TestCoreResample16bpc:
# Lanczos weighting during downsampling can push accumulated float sums
@pytest.mark.parametrize(
"offset",
(
# below 0. These must be clamped to 0, not corrupted byte-by-byte.
0, # Left half = 65535, right half = 0
# above 65535. These must be clamped to 65535, not corrupted byte-by-byte.
50, # # Left half = 0, right half = 65535
),
)
def test_resampling_clamp_overflow(self, offset: int) -> None:
ims = {}
width, height = 100, 10
for mode in ("I;16", "F"):
im = Image.new(mode, (width, height))
im.paste(65535, (offset, 0, offset + width // 2, height))
# 5x downsampling with Lanczos
# creates ~8.7% overshoot or undershoot at the step edge
ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS)
for y in range(height):
for x in range(20):
v = ims["F"].getpixel((x, y))
assert isinstance(v, float)
expected = max(0, min(65535, round(v)))
value = ims["I;16"].getpixel((x, y))
assert (
value == expected
), f"Pixel ({x}, {y}): expected {expected}, got {value}"

View File

@ -170,6 +170,27 @@ class TestImageFile:
with pytest.raises(SystemError, match="tile cannot extend outside image"):
ImageFile._save(im, fp, [ImageFile._Tile("raw", xy + (1, 1), 0, "1")])
def test_extents_none(self) -> None:
with Image.open("Tests/images/hopper.jpg") as im:
im.tile = [im.tile[0]._replace(extents=None)]
im.load()
for extents in ("invalid", (0,), ("0", "0", "0", "0")):
with Image.open("Tests/images/hopper.jpg") as im:
im.tile = [im.tile[0]._replace(extents=extents)] # type: ignore[arg-type]
with pytest.raises(ValueError, match="invalid extents"):
im.load()
im2 = Image.new("L", (1, 1))
fp = BytesIO()
tile = ImageFile._Tile("jpeg", None, 0, "L")
ImageFile._save(im2, fp, [tile])
for extents in ("invalid", (0,), ("0", "0", "0", "0")):
tile = tile._replace(extents=extents) # type: ignore[arg-type]
with pytest.raises(ValueError, match="invalid extents"):
ImageFile._save(im2, fp, [tile])
def test_no_format(self) -> None:
buf = BytesIO(b"\x00" * 255)
@ -295,6 +316,26 @@ class TestPyDecoder(CodecsTest):
with pytest.raises(ValueError):
MockPyDecoder.last.set_as_raw(b"\x00")
@pytest.mark.parametrize(
"extents",
(
(-10, yoff, xoff + xsize, yoff + ysize),
(xoff, -10, xoff + xsize, yoff + ysize),
(xoff, yoff, -10, yoff + ysize),
(xoff, yoff, xoff + xsize, -10),
(xoff, yoff, xoff + xsize + 100, yoff + ysize),
(xoff, yoff, xoff + xsize, yoff + ysize + 100),
),
)
def test_extents(self, extents: tuple[int, int, int, int]) -> None:
buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf)
im.tile = [ImageFile._Tile("MOCK", extents, 32, None)]
with pytest.raises(ValueError):
im.load()
def test_extents_none(self) -> None:
buf = BytesIO(b"\x00" * 255)
@ -308,40 +349,6 @@ class TestPyDecoder(CodecsTest):
assert MockPyDecoder.last.state.xsize == 200
assert MockPyDecoder.last.state.ysize == 200
def test_negsize(self) -> None:
buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf)
im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 32, None)]
with pytest.raises(ValueError):
im.load()
im.tile = [ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 32, None)]
with pytest.raises(ValueError):
im.load()
def test_oversize(self) -> None:
buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf)
im.tile = [
ImageFile._Tile(
"MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 32, None
)
]
with pytest.raises(ValueError):
im.load()
im.tile = [
ImageFile._Tile(
"MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 32, None
)
]
with pytest.raises(ValueError):
im.load()
def test_decode(self) -> None:
decoder = ImageFile.PyDecoder("")
with pytest.raises(NotImplementedError):
@ -371,6 +378,33 @@ class TestPyEncoder(CodecsTest):
assert MockPyEncoder.last.state.xsize == xsize
assert MockPyEncoder.last.state.ysize == ysize
@pytest.mark.parametrize(
"extents",
(
(-10, yoff, xoff + xsize, yoff + ysize),
(xoff, -10, xoff + xsize, yoff + ysize),
(xoff, yoff, -10, yoff + ysize),
(xoff, yoff, xoff + xsize, -10),
(xoff, yoff, xoff + xsize + 100, yoff + ysize),
(xoff, yoff, xoff + xsize, yoff + ysize + 100),
),
)
def test_extents(self, extents: tuple[int, int, int, int]) -> None:
buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf)
fp = BytesIO()
MockPyEncoder.last = None
with pytest.raises(ValueError):
ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")])
last: MockPyEncoder | None = MockPyEncoder.last
assert last
assert last.cleanup_called
with pytest.raises(ValueError):
ImageFile._save(im, fp, [ImageFile._Tile("MOCK", extents, 0, "RGB")])
def test_extents_none(self) -> None:
buf = BytesIO(b"\x00" * 255)
@ -386,58 +420,6 @@ class TestPyEncoder(CodecsTest):
assert MockPyEncoder.last.state.xsize == 200
assert MockPyEncoder.last.state.ysize == 200
def test_negsize(self) -> None:
buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf)
fp = BytesIO()
MockPyEncoder.last = None
with pytest.raises(ValueError):
ImageFile._save(
im,
fp,
[ImageFile._Tile("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")],
)
last: MockPyEncoder | None = MockPyEncoder.last
assert last
assert last.cleanup_called
with pytest.raises(ValueError):
ImageFile._save(
im,
fp,
[ImageFile._Tile("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")],
)
def test_oversize(self) -> None:
buf = BytesIO(b"\x00" * 255)
im = MockImageFile(buf)
fp = BytesIO()
with pytest.raises(ValueError):
ImageFile._save(
im,
fp,
[
ImageFile._Tile(
"MOCK", (xoff, yoff, xoff + xsize + 100, yoff + ysize), 0, "RGB"
)
],
)
with pytest.raises(ValueError):
ImageFile._save(
im,
fp,
[
ImageFile._Tile(
"MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB"
)
],
)
def test_encode(self) -> None:
encoder = ImageFile.PyEncoder("")
with pytest.raises(NotImplementedError):

View File

@ -9,7 +9,7 @@ import pytest
from PIL import Image, ImageGrab
from .helper import assert_image_equal_tofile, skip_unless_feature
from .helper import assert_image_equal_tofile, on_ci, skip_unless_feature
class TestImageGrab:
@ -35,7 +35,7 @@ class TestImageGrab:
ImageGrab.grab()
ImageGrab.grab(xdisplay="")
except OSError as e:
except (OSError, subprocess.CalledProcessError) as e:
pytest.skip(str(e))
@pytest.mark.skipif(Image.core.HAVE_XCB, reason="tests missing XCB")
@ -60,12 +60,44 @@ class TestImageGrab:
ImageGrab.grab(xdisplay="error.test:0.0")
assert str(e.value).startswith("X connection failed")
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
@pytest.mark.skipif(
sys.platform != "darwin" or not on_ci(), reason="Only runs on macOS CI"
)
def test_grab_handle(self) -> None:
p = subprocess.Popen(
[
"osascript",
"-e",
'tell application "Finder"\n'
'open ("/" as POSIX file)\n'
"get id of front window\n"
"end tell",
],
stdout=subprocess.PIPE,
)
stdout = p.stdout
assert stdout is not None
window = int(stdout.read())
ImageGrab.grab(window=window)
im = ImageGrab.grab((0, 0, 10, 10), window=window)
assert im.size == (10, 10)
@pytest.mark.skipif(
sys.platform not in ("darwin", "win32"), reason="macOS and Windows only"
)
def test_grab_invalid_handle(self) -> None:
with pytest.raises(OSError, match="unable to get device context for handle"):
ImageGrab.grab(window=-1)
with pytest.raises(OSError, match="screen grab failed"):
ImageGrab.grab(window=0)
if sys.platform == "darwin":
with pytest.raises(subprocess.CalledProcessError):
ImageGrab.grab(window=-1)
else:
with pytest.raises(
OSError, match="unable to get device context for handle"
):
ImageGrab.grab(window=-1)
with pytest.raises(OSError, match="screen grab failed"):
ImageGrab.grab(window=0)
def test_grabclipboard(self) -> None:
if sys.platform == "darwin":

View File

@ -256,6 +256,13 @@ def test_expand_palette(border: int | tuple[int, int, int, int]) -> None:
assert_image_equal(im_cropped, im)
@pytest.mark.parametrize("border", ((1,), (1, 2, 3), (1, 2, 3, 4, 5)))
def test_expand_invalid_border(border: tuple[int, ...]) -> None:
im = Image.new("1", (1, 1))
with pytest.raises(ValueError):
ImageOps.expand(im, border)
def test_colorize_2color() -> None:
# Test the colorizing function with 2-color functionality

View File

@ -51,6 +51,7 @@ def test_path() -> None:
[0.0, 1.0],
((0, 1),),
[(0, 1)],
[[0, 1]],
((0.0, 1.0),),
[(0.0, 1.0)],
array.array("f", [0, 1]),
@ -68,6 +69,34 @@ def test_path_constructors(
assert list(p) == [(0.0, 1.0)]
@pytest.mark.parametrize(
"coords, expected",
(
([[0, 1], [2, 3]], [(0.0, 1.0), (2.0, 3.0)]),
([[0.0, 1.0], [2.0, 3.0]], [(0.0, 1.0), (2.0, 3.0)]),
),
)
def test_path_list_of_lists(
coords: list[list[float]], expected: list[tuple[float, float]]
) -> None:
p = ImagePath.Path(coords)
assert list(p) == expected
@pytest.mark.parametrize(
"coords, message",
(
([[1, 2, 3]], "coordinate list must contain exactly 2 coordinates"),
([[1]], "coordinate list must contain exactly 2 coordinates"),
([[[1, 2], [3, 4]]], "coordinate list must contain numbers"),
([["a", "b"]], "coordinate list must contain numbers"),
),
)
def test_invalid_list_coords(coords: list[list[object]], message: str) -> None:
with pytest.raises(ValueError, match=message):
ImagePath.Path(coords)
def test_invalid_path_constructors() -> None:
# Arrange / Act
with pytest.raises(ValueError, match="incorrect coordinate type"):

View File

@ -108,3 +108,123 @@ def test_stroke() -> None:
assert_image_similar_tofile(
im, "Tests/images/imagedraw_stroke_" + suffix + ".png", 3.1
)
@pytest.mark.parametrize(
"data, width, expected",
(
("Hello World!", 100, "Hello World!"), # No wrap required
("Hello World!", 50, "Hello\nWorld!"), # Wrap word to a new line
# Keep multiple spaces within a line
("Keep multiple spaces", 90, "Keep multiple\nspaces"),
(" Keep\n leading space", 100, " Keep\n leading space"),
),
)
@pytest.mark.parametrize("string", (True, False))
def test_wrap(data: str, width: int, expected: str, string: bool) -> None:
if string:
text = ImageText.Text(data)
assert text.wrap(width) is None
assert text.text == expected
else:
text_bytes = ImageText.Text(data.encode())
assert text_bytes.wrap(width) is None
assert text_bytes.text == expected.encode()
def test_wrap_long_word() -> None:
text = ImageText.Text("Hello World!")
with pytest.raises(ValueError, match="Word does not fit within line"):
text.wrap(25)
def test_wrap_unsupported(font: ImageFont.FreeTypeFont) -> None:
transposed_font = ImageFont.TransposedFont(font)
text = ImageText.Text("Hello World!", transposed_font)
with pytest.raises(ValueError, match="TransposedFont not supported"):
text.wrap(50)
text = ImageText.Text("Hello World!", direction="ttb")
with pytest.raises(ValueError, match="Only ltr direction supported"):
text.wrap(50)
def test_wrap_height() -> None:
width = 50 if features.check_module("freetype2") else 60
text = ImageText.Text("Text does not fit within height")
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
assert wrapped is not None
assert wrapped.text == " within height"
assert text.text == "Text does\nnot fit"
text = ImageText.Text("Text does not fit\nwithin height")
wrapped = text.wrap(width, 20)
assert wrapped is not None
assert wrapped.text == " not fit\nwithin height"
assert text.text == "Text does"
text = ImageText.Text("Text does not fit\n\nwithin height")
wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40)
assert wrapped is not None
assert wrapped.text == "\nwithin height"
assert text.text == "Text does\nnot fit"
def test_wrap_scaling_unsupported() -> None:
font = ImageFont.load_default_imagefont()
text = ImageText.Text("Hello World!", font)
with pytest.raises(ValueError, match="'scaling' only supports FreeTypeFont"):
text.wrap(50, scaling="shrink")
if features.check_module("freetype2"):
text = ImageText.Text("Hello World!")
with pytest.raises(ValueError, match="'scaling' requires 'height'"):
text.wrap(50, scaling="shrink")
@skip_unless_feature("freetype2")
def test_wrap_shrink() -> None:
# No scaling required
text = ImageText.Text("Hello World!")
assert isinstance(text.font, ImageFont.FreeTypeFont)
assert text.font.size == 10
assert text.wrap(50, 50, "shrink") is None
assert isinstance(text.font, ImageFont.FreeTypeFont)
assert text.font.size == 10
with pytest.raises(ValueError, match="Text could not be scaled"):
text.wrap(50, 15, ("shrink", 9))
assert text.wrap(50, 15, "shrink") is None
assert text.font.size == 8
text = ImageText.Text("Hello World!")
assert text.wrap(50, 15, ("shrink", 7)) is None
assert isinstance(text.font, ImageFont.FreeTypeFont)
assert text.font.size == 8
@skip_unless_feature("freetype2")
def test_wrap_grow() -> None:
# No scaling required
text = ImageText.Text("Hello World!")
assert isinstance(text.font, ImageFont.FreeTypeFont)
assert text.font.size == 10
assert text.wrap(58, 10, "grow") is None
assert isinstance(text.font, ImageFont.FreeTypeFont)
assert text.font.size == 10
with pytest.raises(ValueError, match="Text could not be scaled"):
text.wrap(50, 50, ("grow", 12))
assert text.wrap(50, 50, "grow") is None
assert text.font.size == 16
text = ImageText.Text("A\nB")
with pytest.raises(ValueError, match="Text could not be scaled"):
text.wrap(50, 10, "grow")
text = ImageText.Text("Hello World!")
assert text.wrap(50, 50, ("grow", 18)) is None
assert isinstance(text.font, ImageFont.FreeTypeFont)
assert text.font.size == 16

View File

@ -125,3 +125,8 @@ def test_duplicate_xref_entry() -> None:
pdf = PdfParser("Tests/images/duplicate_xref_entry.pdf")
assert pdf.xref_table.existing_entries[6][0] == 1197
pdf.close()
def test_trailer_loop() -> None:
with pytest.raises(PdfFormatError, match="trailer loop found"):
PdfParser("Tests/images/trailer_loop.pdf")

View File

@ -112,8 +112,6 @@ def test_to_array(mode: str, dtype: pyarrow.DataType, mask: list[int] | None) ->
reloaded = Image.fromarrow(arr, mode, img.size)
assert reloaded
assert_image_equal(img, reloaded)

View File

@ -5,7 +5,10 @@ archive=$1
url=$2
if [ ! -f $archive.tar.gz ]; then
wget --no-verbose -O $archive.tar.gz $url
wget -O $archive.tar.gz $url \
--no-verbose \
--retry-connrefused \
--retry-on-http-error=429,503,504
fi
rmdir $archive

View File

@ -2,7 +2,7 @@
# install raqm
archive=libraqm-0.10.3
archive=libraqm-0.10.5
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz

View File

@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is
Pillow is the friendly PIL fork. It is
Copyright © 2010 by Jeffrey A. Clark and contributors
Copyright © 2010 by Jeffrey 'Alex' Clark and contributors
Like PIL, Pillow is licensed under the open source PIL
Software License:

View File

@ -55,9 +55,9 @@ master_doc = "index"
project = "Pillow (PIL Fork)"
copyright = (
"1995-2011 Fredrik Lundh and contributors, "
"2010 Jeffrey A. Clark and contributors."
"2010 Jeffrey 'Alex' Clark and contributors."
)
author = "Fredrik Lundh (PIL), Jeffrey A. Clark (Pillow)"
author = "Fredrik Lundh (PIL), Jeffrey 'Alex' Clark (Pillow)"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the

View File

@ -8,3 +8,4 @@ Handbook
tutorial
concepts
appendices
security

259
docs/handbook/security.rst Normal file
View File

@ -0,0 +1,259 @@
Security
========
Pillow's primary attack surface is **parsing untrusted image data**. This page
documents the threat model for developers integrating Pillow into applications
that handle images from untrusted sources, along with recommended mitigations.
To report a vulnerability see :ref:`security-reporting`.
.. _security-threat-model:
Threat model (STRIDE)
---------------------
The analysis below follows the `STRIDE
<https://en.wikipedia.org/wiki/STRIDE_model>`_ framework and covers the
boundary between untrusted image input and the Pillow API.
.. code-block:: text
┌──────────────────────────────────────────┐
Untrusted zone │ Pillow API │
───────────── │ │
Image files ────►│ Image.open() ──► Format plugins │
Byte streams │ (40+ parsers) (Python + C FFI) │
User metadata │ │
│ ImageMath.unsafe_eval(expr) ───────────┼──► Python eval()
│ ImageShow.show(image) ─────────────────┼──► os.system / subprocess
│ EpsImagePlugin.open(eps) ──────────────┼──► Ghostscript (gs)
└──────────────┬───────────────────────────┘
│ C extensions:
│ _imaging · _imagingft · _imagingcms
│ _webp · _avif · _imagingtk
│ _imagingmath · _imagingmorph
┌──────────────────────────────────────────┐
│ C libraries (bundled or system) │
│ libjpeg · libpng · libtiff · libwebp │
│ openjpeg · freetype · littlecms2 │
└──────────────────────────────────────────┘
Spoofing
^^^^^^^^
**S-1 — Format sniffing bypass**
``Image.open()`` detects format by magic bytes, not file extension or MIME
type. An attacker can name a file ``safe.png`` while its content is TIFF, JPEG
2000, or EPS, causing a different — potentially more dangerous — parser to run.
*Mitigations:* validate MIME type and magic bytes independently before calling
``Image.open()``; pass the ``formats`` argument with an allowlist of accepted
formats.
**S-2 — Plugin registry spoofing**
Pillow's format registry is a global mutable dictionary. A malicious package
installed in the same environment could register a replacement parser for a
well-known format.
*Mitigations:* use isolated virtual environments with pinned, hash-verified
dependencies; audit ``Image.registered_extensions()`` at startup.
Tampering
^^^^^^^^^
**T-1 — Malicious metadata propagation**
Pillow preserves EXIF, XMP, IPTC, ICC profiles, and comments when
round-tripping images. Applications that store or render metadata without
sanitisation are vulnerable to second-order injection (SQLi, XSS, command
injection).
*Mitigations:* treat all values from ``image.info``, ``image._getexif()``,
``image.getexif()``, and ``image.text`` as untrusted; sanitise before storing
or rendering; strip metadata when it is not required.
**T-2 — Covert data channel (steganography)**
Pillow does not remove hidden data (JPEG comments, PNG text chunks) when
re-saving. An attacker can embed data that survives the
encode-decode cycle invisibly.
*Mitigations:* to guarantee a clean output when saving, create a new image instance via
``image.copy()`` and delete the ``image.info`` contents.
**T-3 — Supply chain tampering**
Pre-compiled wheels bundle libjpeg-turbo, libpng, libtiff, libwebp, openjpeg,
freetype, littlecms2, and other libraries. A compromised PyPI release or build pipeline
could ship malicious binaries.
*Mitigations:* pin with hash verification
(``python3 -m pip install --require-hashes``); monitor `Pillow security advisories
<https://github.com/python-pillow/Pillow/security/advisories>`_; use
Dependabot or OSV-Scanner for bundled C library CVEs.
Repudiation
^^^^^^^^^^^
**R-1 — No structured audit trail**
Without application-level logging there is no record of which images were
opened, what formats were detected, or what operations were performed, making
forensic investigation harder after an incident.
*Mitigations:* log the filename/hash, detected format, and dimensions of every
image processed; log and alert on ``Image.DecompressionBombWarning``,
``Image.DecompressionBombError``, and ``PIL.UnidentifiedImageError``.
Information disclosure
^^^^^^^^^^^^^^^^^^^^^^
**I-1 — Metadata in saved images**
GPS coordinates, author names, software version strings, and ICC profiles can
be inadvertently included in output images served publicly.
*Mitigations:* explicitly strip EXIF and XMP on save (set ``exif=b""``,
``icc_profile=None``, omit ``pnginfo``); verify output with ``exiftool`` in CI.
**I-2 — Temporary file exposure**
Several code paths write pixel data to temporary files via
``tempfile.mkstemp()``. Exception paths can leave these files behind on shared
filesystems.
*Mitigations:* files are created with mode ``0o600``; mount ``/tmp`` as a
per-container ``tmpfs``; ensure ``try/finally`` cleanup is in place.
Denial of service
^^^^^^^^^^^^^^^^^
**D-1 — Decompression bomb**
A small compressed image can expand to gigabytes in memory.
:py:data:`PIL.Image.MAX_IMAGE_PIXELS` raises
``Image.DecompressionBombError`` at 2× the limit and
``Image.DecompressionBombWarning`` at 1×. PNG text chunks are
separately capped by ``PngImagePlugin.MAX_TEXT_CHUNK`` and
``MAX_TEXT_MEMORY``. Check the values in your installed Pillow version at
runtime or in the reference/source for the current defaults.
*Mitigations:* **never** set ``Image.MAX_IMAGE_PIXELS = None`` in production;
treat ``Image.DecompressionBombWarning`` as an error; set OS/container memory limits
per worker.
**D-2 — CPU exhaustion**
Large-but-legal images (within ``MAX_IMAGE_PIXELS``) can still saturate CPU
through high-quality resampling, convolution filters, or complex draw
operations.
*Mitigations:* apply per-request CPU time limits; set a practical dimension
ceiling below ``MAX_IMAGE_PIXELS``; rate-limit processing requests.
**D-3 — Algorithmic complexity in parsers**
Formats such as TIFF (nested IFD chains), animated GIF/WebP (many frames), and
PNG (many text chunks) can exhaust CPU or memory before pixel data is decoded.
*Mitigations:* restrict accepted formats to the minimum required; enforce a
file-size limit before passing data to Pillow; use per-request timeouts.
Elevation of privilege
^^^^^^^^^^^^^^^^^^^^^^
**E-1 — C extension memory corruption (RCE)**
Pillow's ~87 C source files and its bundled C libraries process
attacker-controlled bytes. Historical CVEs include buffer overflows, integer
overflows, and use-after-free vulnerabilities that allow arbitrary code
execution.
*Mitigations:* keep Pillow and all C libraries up to date; compile with
hardening flags (ASLR, stack canaries, PIE, ``_FORTIFY_SOURCE=2``); run image
processing in a sandboxed subprocess (seccomp-bpf, AppArmor, or a restricted
container).
**E-2 — Ghostscript exploitation via EPS (RCE)**
Opening an EPS file invokes the system Ghostscript binary (``gs``) via
``subprocess``. Ghostscript has a long history of sandbox-escape CVEs
permitting arbitrary code execution from malicious PostScript.
*Mitigations:* **block EPS files** at the application input layer before
passing files to Pillow; if EPS must be supported, run Ghostscript in a fully
isolated sandbox with no network and no sensitive mounts. Pillow does not
provide a stable public API for unregistering individual format plugins, so do
not rely on mutating internal registries such as ``Image.OPEN`` as a security
control.
**E-3 — ImageMath.unsafe_eval() code injection**
:py:meth:`~PIL.ImageMath.unsafe_eval` calls Python's built-in ``eval()`` with
only a minimal ``__builtins__`` restriction, which can be bypassed via
introspection. Any user-controlled string passed to this function results in
arbitrary code execution.
*Mitigations:* **never** pass user-controlled strings to
``ImageMath.unsafe_eval()``; use :py:meth:`~PIL.ImageMath.lambda_eval` instead,
which accepts a Python callable and never calls ``eval``.
**E-4 — Font path traversal via ImageFont**
``ImageFont.truetype(font, size)`` passes the filename to the FreeType C
library. If font paths are constructed from user input without
canonicalisation, an attacker may supply a path like
``../../../../etc/passwd``.
*Mitigations:* never construct font paths from user input; if font selection
must be user-driven, resolve names against an explicit allowlist of
pre-validated absolute paths.
.. _security-recommendations:
Recommendations
---------------
The following mitigations are listed in priority order.
1. **Sandbox image processing** — run Pillow workers in a seccomp/AppArmor
restricted subprocess, isolated from the main application process.
2. **Block or sandbox EPS** — reject EPS at the application boundary, or run
Ghostscript in an isolated container.
3. **Never use** ``ImageMath.unsafe_eval()`` **with user input** — migrate all
callers to :py:meth:`~PIL.ImageMath.lambda_eval`.
4. **Keep all dependencies current** — Pillow and its C library dependencies
(including libjpeg, libpng, libtiff, libwebp, openjpeg, freetype,
littlecms2, Ghostscript, and others). Subscribe to `Pillow security
advisories <https://github.com/python-pillow/Pillow/security/advisories>`_.
5. **Enforce** ``MAX_IMAGE_PIXELS`` — never set it to ``None``; treat
``Image.DecompressionBombWarning`` as an error.
6. **Allowlist image formats** — restrict accepted formats when opening
images, for example with ``Image.open(..., formats=...)``, and isolate
installs/environments if you need to minimise supported formats.
7. **Strip metadata on output** — never pass through EXIF/XMP/ICC from user
uploads to publicly served images.
8. **Sanitise all metadata** returned by Pillow before using it downstream.
9. **Pin dependencies with hash verification** — use
``pip install --require-hashes`` and lockfiles.
10. **Log and alert** on ``Image.DecompressionBombWarning``,
``Image.DecompressionBombError``, ``PIL.UnidentifiedImageError``,
and all exceptions from ``Image.open()``.
.. _security-reporting:
Reporting a vulnerability
-------------------------
To report sensitive vulnerability information, report it `privately on GitHub
<https://github.com/python-pillow/Pillow/security/advisories/new>`_.
If you cannot use GitHub, use the `Tidelift security contact
<https://tidelift.com/docs/security>`_. Tidelift will coordinate the fix and
disclosure.
**Do not report sensitive vulnerability information in public.**

View File

@ -8,12 +8,15 @@ itself.
Here is a list of PyPI projects that offer additional plugins:
* :pypi:`amigainfo`: Adds support for Amiga Workbench .info icon files.
* :pypi:`amos-abk`: AMOS BASIC sprite and image banks.
* :pypi:`DjvuRleImagePlugin`: Plugin for the DjVu RLE image format as defined in the DjVuLibre docs.
* :pypi:`heif-image-plugin`: Simple HEIF/HEIC images plugin, based on the pyheif library.
* :pypi:`jxlpy`: Introduces reading and writing support for JPEG XL.
* :pypi:`pillow-degas`: Adds reading Atari ST Degas image files.
* :pypi:`pillow-heif`: Python bindings to libheif for working with HEIF images.
* :pypi:`pillow-jpls`: Plugin for the JPEG-LS codec, based on the Charls JPEG-LS implementation. Python bindings implemented using pybind11.
* :pypi:`pillow-jxl-plugin`: Plugin for JPEG-XL, using Rust for bindings.
* :pypi:`pillow-mbm`: Adds support for KSP's proprietary MBM texture format.
* :pypi:`pillow-netpbm`: Adds .pam support, and loads images using `Netpbm <https://en.wikipedia.org/wiki/Netpbm>`__'s converter collection.
* :pypi:`pillow-svg`: Implements basic SVG read support. Supports basic paths, shapes, and text.
* :pypi:`raw-pillow-opener`: Simple camera raw opener, based on the rawpy library.

View File

@ -1,7 +1,7 @@
Pillow
======
Pillow is the friendly PIL fork by `Jeffrey A. Clark and contributors <https://github.com/python-pillow/Pillow/graphs/contributors>`_. PIL is the Python Imaging Library by Fredrik Lundh and contributors.
Pillow is the friendly PIL fork by `Jeffrey 'Alex' Clark and contributors <https://github.com/python-pillow/Pillow/graphs/contributors>`_. PIL is the Python Imaging Library by Fredrik Lundh and contributors.
Pillow for enterprise is available via the Tidelift Subscription. `Learn more <https://tidelift.com/subscription/pkg/pypi-pillow?utm_source=pypi-pillow&utm_medium=docs&utm_campaign=enterprise>`_.

View File

@ -51,7 +51,7 @@ Many of Pillow's features require external libraries:
* **littlecms** provides color management
* Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and
above uses liblcms2. Tested with **1.19** and **2.7-2.18**.
above uses liblcms2. Tested with **1.19** and **2.7-2.19**.
* **libwebp** provides the WebP format.
@ -116,7 +116,7 @@ Many of Pillow's features require external libraries:
.. Note:: ``redhat-rpm-config`` is required on Fedora 23, but not earlier versions.
Prerequisites for **Ubuntu 16.04 LTS - 24.04 LTS** are installed with::
Prerequisites for **Ubuntu 16.04 LTS - 26.04 LTS** are installed with::
sudo apt-get install libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev \
libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \

View File

@ -19,25 +19,21 @@ These platforms are built and tested for every change.
+==================================+============================+=====================+
| Alpine | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Amazon Linux 2 | 3.10 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Amazon Linux 2023 | 3.11 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Arch | 3.13 | x86-64 |
| Arch | 3.14 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| CentOS Stream 9 | 3.10 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| CentOS Stream 10 | 3.12 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Debian 12 Bookworm | 3.11 | x86, x86-64 |
+----------------------------------+----------------------------+---------------------+
| Debian 13 Trixie | 3.13 | x86, x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 42 | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Fedora 43 | 3.14 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Gentoo | 3.12 | x86-64 |
| Fedora 44 | 3.14 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| Gentoo | 3.13 | x86-64 |
+----------------------------------+----------------------------+---------------------+
| macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14, | arm64 |
| | 3.15, PyPy3 | |
@ -48,16 +44,16 @@ These platforms are built and tested for every change.
+----------------------------------+----------------------------+---------------------+
| Ubuntu Linux 24.04 LTS (Noble) | 3.10, 3.11, 3.12, 3.13, | x86-64 |
| | 3.14, 3.15, PyPy3 | |
| +----------------------------+---------------------+
| | 3.12 | arm64v8, ppc64le, |
| | | s390x |
+----------------------------------+----------------------------+---------------------+
| Ubuntu Linux 26.04 LTS (Resolute)| 3.14 | x86-64, arm64v8, |
| | | ppc64le, s390x |
+----------------------------------+----------------------------+---------------------+
| Windows Server 2022 | 3.10 | x86 |
+----------------------------------+----------------------------+---------------------+
| Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 |
| | 3.15, PyPy3 | |
| +----------------------------+---------------------+
| | 3.13 (MinGW) | x86-64 |
| | 3.14 (MinGW) | x86-64 |
+----------------------------------+----------------------------+---------------------+
@ -75,7 +71,7 @@ These platforms have been reported to work at the versions mentioned.
| Operating system | | Tested Python | | Latest tested | | Tested |
| | | versions | | Pillow version | | processors |
+==================================+=============================+==================+==============+
| macOS 26 Tahoe | 3.10, 3.11, 3.12, 3.13, 3.14| 12.1.1 |arm |
| macOS 26 Tahoe | 3.10, 3.11, 3.12, 3.13, 3.14| 12.2.0 |arm |
| +-----------------------------+------------------+ |
| | 3.9 | 11.3.0 | |
+----------------------------------+-----------------------------+------------------+--------------+

View File

@ -27,6 +27,10 @@ The ImageColor module supports the following string formats:
as three percentages (0% to 100%). For example, ``rgb(255,0,0)`` and
``rgb(100%,0%,0%)`` both specify pure red.
* RGBA functions, given as ``rgba(red, green, blue, alpha)`` where the color
values and the alpha value are integers in the range 0 to 255. For example,
``rgba(255,0,0,128)`` specifies pure red with 50% opacity.
* Hue-Saturation-Lightness (HSL) functions, given as ``hsl(hue, saturation%,
lightness%)`` where hue is the color given as an angle between 0 and 360
(red=0, green=120, blue=240), saturation is a value between 0% and 100%

View File

@ -0,0 +1,124 @@
12.2.0
------
Security
========
:cve:`2026-40192`: Prevent FITS decompression bomb
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When decompressing GZIP data from a FITS image, Pillow did not limit the amount of data
being read, meaning that it was vulnerable to GZIP decompression bombs. This was
introduced in Pillow 10.3.0.
The data being read is now limited to only the necessary amount.
:cve:`2026-42311`: Fix OOB write with invalid tile extents
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Pillow 12.1.1 addressed :cve:`2026-25990` by improving checks for tile extents to
prevent an OOB write from specially crafted PSD images in Pillow >= 10.3.0. However,
these checks did not consider integer overflow. This has been corrected.
:cve:`2026-42310`: Prevent PDF parsing trailer infinite loop
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
When parsing a PDF, if a trailer refers to itself, or a more complex cyclic loop
exists, then an infinite loop occurs. Pillow now keeps a record of which trailers it
has already processed. PdfParser was added in Pillow 4.2.0.
:cve:`2026-42308`: Integer overflow when processing fonts
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
If a font advances for each glyph by an exceeding large amount, when Pillow keeps track
of the current position, it may lead to an integer overflow. This has been fixed.
:cve:`2026-42309`: Heap buffer overflow with nested list coordinates
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Passing nested lists as coordinates to APIs that accept coordinates such as
``ImagePath.Path``, :py:meth:`~PIL.ImageDraw.ImageDraw.polygon`
and :py:meth:`~PIL.ImageDraw.ImageDraw.line` could cause a heap buffer overflow,
as nested lists were recursively unpacked beyond the allocated buffer.
Coordinate lists are now validated to contain exactly two numeric coordinates.
This was introduced in Pillow 11.2.1.
API changes
===========
Error when encoding an empty image
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Attempting to encode an image with zero width or height would previously raise
a :py:exc:`SystemError`. That has now been changed to a :py:exc:`ValueError`.
This does not add any new errors. SGI, ICNS and ICO formats are still able to
save (0, 0) images.
API additions
=============
FontFile.to_imagefont()
^^^^^^^^^^^^^^^^^^^^^^^
:py:class:`~PIL.FontFile.FontFile` instances can now be directly converted to
:py:class:`~PIL.ImageFont.ImageFont` instances::
>>> from PIL import PcfFontFile
>>> with open("Tests/fonts/10x20-ISO8859-1.pcf", "rb") as fp:
... pcffont = PcfFontFile.PcfFontFile(fp)
... pcffont.to_imagefont()
...
<PIL.ImageFont.ImageFont object at 0x10457bb80>
ImageText.Text.wrap
^^^^^^^^^^^^^^^^^^^
:py:meth:`.ImageText.Text.wrap` has been added, to wrap text to fit within a given
width::
from PIL import ImageText
text = ImageText.Text("Hello World!")
text.wrap(50)
print(text.text) # "Hello\nWorld!"
or within a certain width and height, returning a new :py:class:`.ImageText.Text`
instance if the text does not fit::
text = ImageText.Text("Text does not fit within height")
print(text.wrap(50, 25).text == " within height")
print(text.text) # "Text does\nnot fit"
or scaling, optionally with a font size limit::
text.wrap(50, 15, "shrink")
text.wrap(50, 15, ("shrink", 7))
text.wrap(58, 10, "grow")
text.wrap(50, 50, ("grow", 12))
EXIF tag FrameRate
^^^^^^^^^^^^^^^^^^
The EXIF tag ``FrameRate`` has been added.
Other changes
=============
Support reading JPEG2000 images with CMYK palettes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
JPEG2000 images with CMYK palettes can now be read. This is the first integration of
CMYK palettes into Pillow.
Lazy plugin loading
^^^^^^^^^^^^^^^^^^^
When opening or saving an image, Pillow now lazily loads only the required plugin
based on the file extension, instead of importing all plugins upfront. This makes
``open`` 2.3-15.6x faster and ``save`` 2.2-9x faster for common formats.
Thread safety for free-threaded Python
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Critical sections are now used to protect FreeType font objects, improving thread
safety when using fonts in the free-threaded build of Python.

View File

@ -0,0 +1,57 @@
12.3.0
------
Security
========
TODO
^^^^
TODO
:cve:`YYYY-XXXXX`: TODO
^^^^^^^^^^^^^^^^^^^^^^^
TODO
Backwards incompatible changes
==============================
TODO
^^^^
TODO
Deprecations
============
TODO
^^^^
TODO
API changes
===========
TODO
^^^^
TODO
API additions
=============
TODO
^^^^
TODO
Other changes
=============
Removed Python 3.13 free-threaded wheels
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Python 3.13 added an experimental free-threaded mode, and Pillow 11.0.0 added
corresponding wheels. Now that Python 3.14 includes official support for it, Pillow has
removed wheels for Python 3.13 free-threaded mode.

View File

@ -15,6 +15,8 @@ expected to be backported to earlier versions.
:maxdepth: 2
versioning
12.3.0
12.2.0
12.1.1
12.1.0
12.0.0

View File

@ -18,7 +18,7 @@ keywords = [
license = "MIT-CMU"
license-files = [ "LICENSE" ]
authors = [
{ name = "Jeffrey A. Clark", email = "aclark@aclark.net" },
{ name = "Jeffrey 'Alex' Clark", email = "aclark@aclark.net" },
]
requires-python = ">=3.10"
classifiers = [
@ -146,6 +146,7 @@ lint.select = [
"I", # isort
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"PERF", # perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PT", # flake8-pytest-style
@ -185,12 +186,6 @@ lint.isort.required-imports = [
[tool.pyproject-fmt]
max_supported_python = "3.14"
[tool.pytest]
addopts = [ "-ra", "--color=auto" ]
testpaths = [
"Tests",
]
[tool.mypy]
python_version = "3.10"
pretty = true
@ -202,3 +197,9 @@ follow_imports = "silent"
warn_redundant_casts = true
warn_unreachable = true
warn_unused_ignores = true
[tool.pytest]
addopts = [ "-ra", "--color=auto" ]
testpaths = [
"Tests",
]

View File

@ -57,7 +57,7 @@ WEBP_ROOT = None
ZLIB_ROOT = None
FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ
if sys.platform == "win32" and sys.version_info >= (3, 15):
if sys.platform == "win32" and sys.version_info >= (3, 16):
import atexit
atexit.register(
@ -302,7 +302,7 @@ def _pkg_config(name: str) -> tuple[list[str], list[str]] | None:
subprocess.check_output(command_cflags).decode("utf8").strip(),
)[::2][1:]
return libs, cflags
except Exception:
except Exception: # noqa: PERF203
pass
return None
@ -1078,10 +1078,10 @@ libraries: list[tuple[str, _BuildInfo]] = [
]
files: list[str | os.PathLike[str]] = ["src/_imaging.c"]
for src_file in _IMAGING:
files.append("src/" + src_file + ".c")
for src_file in _LIB_IMAGING:
files.append(os.path.join("src/libImaging", src_file + ".c"))
files.extend("src/" + src_file + ".c" for src_file in _IMAGING)
files.extend(
os.path.join("src/libImaging", src_file + ".c") for src_file in _LIB_IMAGING
)
ext_modules = [
Extension("PIL._imaging", files),
Extension("PIL._imagingft", ["src/_imagingft.c"]),

View File

@ -179,14 +179,12 @@ class BmpImageFile(ImageFile.ImageFile):
# ------- 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"])
)
if not file_info.get("colors", 0):
file_info["colors"] = 1 << file_info["bits"]
assert isinstance(file_info["palette_padding"], int)
assert isinstance(file_info["colors"], int)
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
offset += 4 * file_info["colors"]
offset += file_info["palette_padding"] * file_info["colors"]
# ---------------------- Check bit depth for unusual unsupported values
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
@ -265,7 +263,6 @@ class BmpImageFile(ImageFile.ImageFile):
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

View File

@ -52,10 +52,6 @@ class BufrStubImageFile(ImageFile.StubImageFile):
self._mode = "F"
self._size = 1, 1
loader = self._load()
if loader:
loader.open(self)
def _load(self) -> ImageFile.StubHandler | None:
return _handler

View File

@ -128,17 +128,18 @@ class FitsGzipDecoder(ImageFile.PyDecoder):
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
value = gzip.decompress(self.fd.read())
with gzip.open(self.fd) as fp:
value = fp.read(self.state.xsize * self.state.ysize * 4)
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)
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

View File

@ -164,13 +164,13 @@ class GifImageFile(ImageFile.ImageFile):
self._seek(0)
last_frame = self.__frame
for f in range(self.__frame + 1, frame + 1):
try:
try:
for f in range(self.__frame + 1, frame + 1):
self._seek(f)
except EOFError as e:
self.seek(last_frame)
msg = "no more images in GIF file"
raise EOFError(msg) from e
except EOFError as e:
self.seek(last_frame)
msg = "no more images in GIF file"
raise EOFError(msg) from e
def _seek(self, frame: int, update_image: bool = True) -> None:
if isinstance(self._fp, DeferredError):

View File

@ -52,10 +52,6 @@ class GribStubImageFile(ImageFile.StubImageFile):
self._mode = "F"
self._size = 1, 1
loader = self._load()
if loader:
loader.open(self)
def _load(self) -> ImageFile.StubHandler | None:
return _handler

View File

@ -52,10 +52,6 @@ class HDF5StubImageFile(ImageFile.StubImageFile):
self._mode = "F"
self._size = 1, 1
loader = self._load()
if loader:
loader.open(self)
def _load(self) -> ImageFile.StubHandler | None:
return _handler

View File

@ -80,8 +80,7 @@ def read_32(
if byte_int & 0x80:
blocksize = byte_int - 125
byte = fobj.read(1)
for i in range(blocksize):
data.append(byte)
data.extend([byte] * blocksize)
else:
blocksize = byte_int + 1
data.append(fobj.read(blocksize))

View File

@ -488,7 +488,7 @@ def init() -> bool:
try:
logger.debug("Importing %s", plugin)
__import__(f"{__spec__.parent}.{plugin}", globals(), locals(), [])
except ImportError as e:
except ImportError as e: # noqa: PERF203
logger.debug("Image: failed to import %s: %s", plugin, e)
if OPEN or SAVE:
@ -2428,7 +2428,14 @@ class Image:
(box[3] - reduce_box[1]) / factor_y,
)
return self._new(self.im.resize(size, resample, box))
if self.size[1] > self.size[0] * 100 and size[1] < self.size[1]:
im = self.im.resize(
(self.size[0], size[1]), resample, (0, box[1], self.size[0], box[3])
)
im = im.resize(size, resample, (box[0], 0, box[2], size[1]))
else:
im = self.im.resize(size, resample, box)
return self._new(im)
def reduce(
self,
@ -2632,11 +2639,8 @@ class Image:
if is_path(fp):
filename = os.fspath(fp)
open_fp = True
elif fp == sys.stdout:
try:
fp = sys.stdout.buffer
except AttributeError:
pass
elif fp == sys.stdout and isinstance(sys.stdout, io.TextIOWrapper):
fp = sys.stdout.buffer
if not filename and hasattr(fp, "name") and is_path(fp.name):
# only set the name for metadata purposes
filename = os.fspath(fp.name)
@ -4234,80 +4238,83 @@ class Exif(_ExifBase):
if tag == ExifTags.IFD.MakerNote:
from .TiffImagePlugin import ImageFileDirectory_v2
if tag_data.startswith(b"FUJIFILM"):
ifd_offset = i32le(tag_data, 8)
ifd_data = tag_data[ifd_offset:]
try:
if tag_data.startswith(b"FUJIFILM"):
ifd_offset = i32le(tag_data, 8)
ifd_data = tag_data[ifd_offset:]
makernote = {}
for i in range(struct.unpack("<H", ifd_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
"<HHL4s", ifd_data[i * 12 + 2 : (i + 1) * 12 + 2]
)
try:
(
unit_size,
handler,
) = ImageFileDirectory_v2._load_dispatch[typ]
except KeyError:
continue
size = count * unit_size
if size > 4:
(offset,) = struct.unpack("<L", data)
data = ifd_data[offset - 12 : offset + size - 12]
else:
data = data[:size]
if len(data) != size:
warnings.warn(
"Possibly corrupt EXIF MakerNote data. "
f"Expecting to read {size} bytes but only got "
f"{len(data)}. Skipping tag {ifd_tag}"
makernote = {}
for i in range(struct.unpack("<H", ifd_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
"<HHL4s", ifd_data[i * 12 + 2 : (i + 1) * 12 + 2]
)
continue
try:
(
unit_size,
handler,
) = ImageFileDirectory_v2._load_dispatch[typ]
except KeyError:
continue
size = count * unit_size
if size > 4:
(offset,) = struct.unpack("<L", data)
data = ifd_data[offset - 12 : offset + size - 12]
else:
data = data[:size]
if not data:
continue
if len(data) != size:
warnings.warn(
"Possibly corrupt EXIF MakerNote data. "
f"Expecting to read {size} bytes but only got "
f"{len(data)}. Skipping tag {ifd_tag}"
)
continue
makernote[ifd_tag] = handler(
ImageFileDirectory_v2(), data, False
)
self._ifds[tag] = dict(self._fixup_dict(makernote))
elif self.get(0x010F) == "Nintendo":
makernote = {}
for i in range(struct.unpack(">H", tag_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2]
)
if ifd_tag == 0x1101:
# CameraInfo
(offset,) = struct.unpack(">L", data)
self.fp.seek(offset)
if not data:
continue
camerainfo: dict[str, int | bytes] = {
"ModelID": self.fp.read(4)
}
makernote[ifd_tag] = handler(
ImageFileDirectory_v2(), data, False
)
self._ifds[tag] = dict(self._fixup_dict(makernote))
elif self.get(0x010F) == "Nintendo":
makernote = {}
for i in range(struct.unpack(">H", tag_data[:2])[0]):
ifd_tag, typ, count, data = struct.unpack(
">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2]
)
if ifd_tag == 0x1101:
# CameraInfo
(offset,) = struct.unpack(">L", data)
self.fp.seek(offset)
self.fp.read(4)
# Seconds since 2000
camerainfo["TimeStamp"] = i32le(self.fp.read(12))
camerainfo: dict[str, int | bytes] = {
"ModelID": self.fp.read(4)
}
self.fp.read(4)
camerainfo["InternalSerialNumber"] = self.fp.read(4)
self.fp.read(4)
# Seconds since 2000
camerainfo["TimeStamp"] = i32le(self.fp.read(12))
self.fp.read(12)
parallax = self.fp.read(4)
handler = ImageFileDirectory_v2._load_dispatch[
TiffTags.FLOAT
][1]
camerainfo["Parallax"] = handler(
ImageFileDirectory_v2(), parallax, False
)[0]
self.fp.read(4)
camerainfo["InternalSerialNumber"] = self.fp.read(4)
self.fp.read(4)
camerainfo["Category"] = self.fp.read(2)
self.fp.read(12)
parallax = self.fp.read(4)
handler = ImageFileDirectory_v2._load_dispatch[
TiffTags.FLOAT
][1]
camerainfo["Parallax"] = handler(
ImageFileDirectory_v2(), parallax, False
)[0]
makernote = {0x1101: camerainfo}
self._ifds[tag] = makernote
self.fp.read(4)
camerainfo["Category"] = self.fp.read(2)
makernote = {0x1101: camerainfo}
self._ifds[tag] = makernote
except struct.error:
pass
else:
# Interop
ifd = self._get_ifd_dict(tag_data, tag)

View File

@ -175,7 +175,7 @@ class ImageDraw:
) -> None:
"""Draw an arc."""
ink, fill = self._getink(fill)
if ink is not None:
if ink is not None and width != 0:
self.draw.draw_arc(xy, start, end, ink, width)
def bitmap(
@ -302,7 +302,7 @@ class ImageDraw:
self,
xy: Coords,
fill: _Ink | None = None,
width: int = 0,
width: int = 1,
joint: str | None = None,
dash: tuple[int, ...] | None = None,
) -> None:
@ -322,7 +322,7 @@ class ImageDraw:
)
return
ink = self._getink(fill)[0]
if ink is not None:
if ink is not None and width != 0:
self.draw.draw_lines(xy, ink, width)
if joint == "curve" and width > 4:
joint_points = self._normalize_points(xy)
@ -651,7 +651,7 @@ class ImageDraw:
def text(
self,
xy: tuple[float, float],
text: AnyStr | ImageText.Text,
text: AnyStr | ImageText.Text[AnyStr],
fill: _Ink | None = None,
font: (
ImageFont.ImageFont
@ -704,49 +704,49 @@ class ImageDraw:
else ink
)
for xy, anchor, line in image_text._split(xy, anchor, align):
for line in image_text._split(xy, anchor, align):
def draw_text(ink: int, stroke_width: float = 0) -> None:
mode = self.fontmode
if stroke_width == 0 and embedded_color:
mode = "RGBA"
coord = []
for i in range(2):
coord.append(int(xy[i]))
start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
x = int(line.x)
y = int(line.y)
start = (math.modf(line.x)[0], math.modf(line.y)[0])
try:
mask, offset = image_text.font.getmask2( # type: ignore[union-attr,misc]
line,
line.text,
mode,
direction=direction,
features=features,
language=language,
stroke_width=stroke_width,
stroke_filled=True,
anchor=anchor,
anchor=line.anchor,
ink=ink,
start=start,
*args,
**kwargs,
)
coord = [coord[0] + offset[0], coord[1] + offset[1]]
x += offset[0]
y += offset[1]
except AttributeError:
try:
mask = image_text.font.getmask( # type: ignore[misc]
line,
line.text,
mode,
direction,
features,
language,
stroke_width,
anchor,
line.anchor,
ink,
start=start,
*args,
**kwargs,
)
except TypeError:
mask = image_text.font.getmask(line)
mask = image_text.font.getmask(line.text)
if mode == "RGBA":
# image_text.font.getmask2(mode="RGBA")
# returns color in RGB bands and mask in A
@ -754,13 +754,12 @@ class ImageDraw:
color, mask = mask, mask.getband(3)
ink_alpha = struct.pack("i", ink)[3]
color.fillband(3, ink_alpha)
x, y = coord
if self.im is not None:
self.im.paste(
color, (x, y, x + mask.size[0], y + mask.size[1]), mask
)
else:
self.draw.draw_bitmap(coord, mask, ink)
self.draw.draw_bitmap((x, y), mask, ink)
if stroke_ink is not None:
# Draw stroked text

View File

@ -148,6 +148,10 @@ class ImageFile(Image.Image):
try:
try:
self._open()
if isinstance(self, StubImageFile):
if loader := self._load():
loader.open(self)
except (
IndexError, # end of data
TypeError, # end of data (ord)
@ -215,8 +219,10 @@ class ImageFile(Image.Image):
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))
ifds = [
(exif._get_ifd_dict(subifd_offset), subifd_offset)
for subifd_offset in subifd_offsets
]
ifd1 = exif.get_ifd(ExifTags.IFD.IFD1)
if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset):
assert exif._info is not None
@ -799,28 +805,22 @@ class PyCodec:
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:
if x0 < 0 or y0 < 0 or x1 > self.im.size[0] or y1 > self.im.size[1]:
msg = "Tile cannot extend outside image"
raise ValueError(msg)
self.state.xoff = x0
self.state.yoff = y0
self.state.xsize = x1 - x0
self.state.ysize = y1 - y0
else:
self.state.xsize, self.state.ysize = self.im.size
if self.state.xsize <= 0 or self.state.ysize <= 0:
msg = "Size must be positive"
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):
"""

View File

@ -110,7 +110,7 @@ class ImageFont:
except Exception:
pass
else:
if image and image.mode in ("1", "L"):
if image.mode in ("1", "L"):
break
else:
if image:
@ -930,7 +930,7 @@ def load_path(filename: str | bytes) -> ImageFont:
for directory in sys.path:
try:
return load(os.path.join(directory, filename))
except OSError:
except OSError: # noqa: PERF203
pass
msg = f'cannot find font file "{filename}" in sys.path'
if os.path.exists(filename):

View File

@ -43,25 +43,29 @@ def grab(
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
args = ["screencapture"]
if window:
if window is not None:
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])
args += ["-x", filepath]
retcode = subprocess.call(args)
if retcode:
raise subprocess.CalledProcessError(retcode, args)
im = Image.open(filepath)
im.load()
os.unlink(filepath)
if bbox:
if window:
if window is not None:
# 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]
)
args = ["screencapture", "-l", str(window), "-o", "-x", filepath]
retcode = subprocess.call(args)
if retcode:
raise subprocess.CalledProcessError(retcode, args)
with Image.open(filepath) as im_no_shadow:
retina = im.width - im_no_shadow.width > 100
os.unlink(filepath)
@ -125,7 +129,10 @@ def grab(
raise
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
subprocess.call(args + [filepath])
args.append(filepath)
retcode = subprocess.call(args)
if retcode:
raise subprocess.CalledProcessError(retcode, args)
im = Image.open(filepath)
im.load()
os.unlink(filepath)

View File

@ -36,6 +36,9 @@ def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]:
left, top = right, bottom = border
elif len(border) == 4:
left, top, right, bottom = border
else:
msg = "border must be an integer, or a tuple of two or four elements"
raise ValueError(msg)
else:
left = top = right = bottom = border
return left, top, right, bottom

View File

@ -198,13 +198,11 @@ class ImagePalette:
try:
fp.write("# Palette\n")
fp.write(f"# Mode: {self.mode}\n")
palette_len = len(self.palette)
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(f" {self.palette[j] if j < palette_len else 0}")
fp.write("\n")
finally:
if open_fp:

View File

@ -1,19 +1,103 @@
from __future__ import annotations
import math
import re
from typing import AnyStr, Generic, NamedTuple
from . import ImageFont
from ._typing import _Ink
Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont
class _Line(NamedTuple):
x: float
y: float
anchor: str
text: str | bytes
class _Wrap(Generic[AnyStr]):
lines: list[AnyStr] = []
position = 0
offset = 0
class Text:
def __init__(
self,
text: str | bytes,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
text: Text[AnyStr],
width: int,
height: int | None = None,
font: Font | None = None,
) -> None:
self.text: Text[AnyStr] = text
self.width = width
self.height = height
self.font = font
input_text = self.text.text
emptystring = "" if isinstance(input_text, str) else b""
line = emptystring
for word in re.findall(
r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text
):
newlines = re.findall(
r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word
)
if newlines:
if not self.add_line(line):
break
for i, line in enumerate(newlines):
if i != 0 and not self.add_line(emptystring):
break
self.position += len(line)
word = word[len(line) :]
line = emptystring
new_line = line + word
if self.text._get_bbox(new_line, self.font)[2] <= width:
# This word fits on the line
line = new_line
continue
# This word does not fit on the line
if line and not self.add_line(line):
break
original_length = len(word)
word = word.lstrip()
self.offset = original_length - len(word)
if self.text._get_bbox(word, self.font)[2] > width:
if font is None:
msg = "Word does not fit within line"
raise ValueError(msg)
break
line = word
else:
if line:
self.add_line(line)
self.remaining_text: AnyStr = input_text[self.position :]
def add_line(self, line: AnyStr) -> bool:
lines = self.lines + [line]
if self.height is not None:
last_line_y = self.text._split(lines=lines)[-1].y
last_line_height = self.text._get_bbox(line, self.font)[3]
if last_line_y + last_line_height > self.height:
return False
self.lines = lines
self.position += len(line) + self.offset
self.offset = 0
return True
class Text(Generic[AnyStr]):
def __init__(
self,
text: AnyStr,
font: Font | None = None,
mode: str = "RGB",
spacing: float = 4,
direction: str | None = None,
@ -47,7 +131,7 @@ class Text:
It should be a `BCP 47 language code`_.
Requires libraqm.
"""
self.text = text
self.text: AnyStr = text
self.font = font or ImageFont.load_default()
self.mode = mode
@ -88,6 +172,101 @@ class Text:
else:
return "L"
def wrap(
self,
width: int,
height: int | None = None,
scaling: str | tuple[str, int] | None = None,
) -> Text[AnyStr] | None:
"""
Wrap text to fit within a given width.
:param width: The width to fit within.
:param height: An optional height limit. Any text that does not fit within this
will be returned as a new :py:class:`.Text` object.
:param scaling: An optional directive to scale the text, either "grow" as much
as possible within the given dimensions, or "shrink" until it
fits. It can also be a tuple of (direction, limit), with an
integer limit to stop scaling at.
:returns: An :py:class:`.Text` object, or None.
"""
if isinstance(self.font, ImageFont.TransposedFont):
msg = "TransposedFont not supported"
raise ValueError(msg)
if self.direction not in (None, "ltr"):
msg = "Only ltr direction supported"
raise ValueError(msg)
if scaling is None:
wrap = _Wrap(self, width, height)
else:
if not isinstance(self.font, ImageFont.FreeTypeFont):
msg = "'scaling' only supports FreeTypeFont"
raise ValueError(msg)
if height is None:
msg = "'scaling' requires 'height'"
raise ValueError(msg)
if isinstance(scaling, str):
limit = 1
else:
scaling, limit = scaling
font = self.font
wrap = _Wrap(self, width, height, font)
if scaling == "shrink":
if not wrap.remaining_text:
return None
size = math.ceil(font.size)
while wrap.remaining_text:
if size == max(limit, 1):
msg = "Text could not be scaled"
raise ValueError(msg)
size -= 1
font = self.font.font_variant(size=size)
wrap = _Wrap(self, width, height, font)
self.font = font
else:
if wrap.remaining_text:
msg = "Text could not be scaled"
raise ValueError(msg)
size = math.floor(font.size)
while not wrap.remaining_text:
if size == limit:
msg = "Text could not be scaled"
raise ValueError(msg)
size += 1
font = self.font.font_variant(size=size)
last_wrap = wrap
wrap = _Wrap(self, width, height, font)
size -= 1
if size != self.font.size:
self.font = self.font.font_variant(size=size)
wrap = last_wrap
if wrap.remaining_text:
text = Text(
text=wrap.remaining_text,
font=self.font,
mode=self.mode,
spacing=self.spacing,
direction=self.direction,
features=self.features,
language=self.language,
)
text.embedded_color = self.embedded_color
text.stroke_width = self.stroke_width
text.stroke_fill = self.stroke_fill
else:
text = None
newline = "\n" if isinstance(self.text, str) else b"\n"
self.text = newline.join(wrap.lines)
return text
def get_length(self) -> float:
"""
Returns length (in pixels with 1/64 precision) of text.
@ -146,21 +325,26 @@ class Text:
)
def _split(
self, xy: tuple[float, float], anchor: str | None, align: str
) -> list[tuple[tuple[float, float], str, str | bytes]]:
self,
xy: tuple[float, float] = (0, 0),
anchor: str | None = None,
align: str = "left",
lines: list[str] | list[bytes] | None = None,
) -> list[_Line]:
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 lines is None:
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)]
return [_Line(xy[0], xy[1], anchor, lines[0])]
if anchor[1] in "tb" and self.direction != "ttb":
msg = "anchor not supported for multiline text"
@ -185,7 +369,7 @@ class Text:
if self.direction == "ttb":
left = xy[0]
for line in lines:
parts.append(((left, top), anchor, line))
parts.append(_Line(left, top, anchor, line))
left += line_spacing
else:
widths = []
@ -248,7 +432,7 @@ class Text:
width_difference = max_width - sum(word_widths)
i = 0
for word in words:
parts.append(((left, top), word_anchor, word))
parts.append(_Line(left, top, word_anchor, word))
left += word_widths[i] + width_difference / (len(words) - 1)
i += 1
top += line_spacing
@ -259,11 +443,24 @@ class Text:
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
parts.append(((left, top), anchor, line))
parts.append(_Line(left, top, anchor, line))
top += line_spacing
return parts
def _get_bbox(
self, text: str | bytes, font: Font | None = None, anchor: str | None = None
) -> tuple[float, float, float, float]:
return (font or self.font).getbbox(
text,
self._get_fontmode(),
self.direction,
self.features,
self.language,
self.stroke_width,
anchor,
)
def get_bbox(
self,
xy: tuple[float, float] = (0, 0),
@ -289,22 +486,13 @@ class Text:
: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,
)
for x, y, anchor, text in self._split(xy, anchor, align):
bbox_line = self._get_bbox(text, anchor=anchor)
bbox_line = (
bbox_line[0] + xy[0],
bbox_line[1] + xy[1],
bbox_line[2] + xy[0],
bbox_line[3] + xy[1],
bbox_line[0] + x,
bbox_line[1] + y,
bbox_line[2] + x,
bbox_line[3] + y,
)
if bbox is None:
bbox = bbox_line

View File

@ -185,13 +185,9 @@ def getiptcinfo(
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
return {k: v for k, v in im.info.items() if isinstance(k, tuple)}
elif isinstance(im, JpegImagePlugin.JpegImageFile):
# extract the IPTC/NAA resource
@ -227,7 +223,4 @@ def getiptcinfo(
except (IndexError, KeyError):
pass # expected failure
for k, v in iptc_im.info.items():
if isinstance(k, tuple):
info[k] = v
return info
return {k: v for k, v in iptc_im.info.items() if isinstance(k, tuple)}

View File

@ -176,7 +176,7 @@ def _parse_jp2_header(
nc = None
dpi = None # 2-tuple of DPI info, or None
palette = None
cmyk = False
colr = None
while header.has_next_box():
tbox = header.next_box_type()
@ -199,10 +199,16 @@ def _parse_jp2_header(
mode = "RGBA"
elif tbox == b"colr":
meth, _, _, enumcs = header.read_fields(">BBBI")
if cmyk := (meth == 1 and enumcs == 12):
if nc == 4:
mode = "CMYK"
elif tbox == b"pclr" and mode in ("L", "LA"):
if meth == 1:
if enumcs in (0, 15):
colr = "1"
elif enumcs == 12:
colr = "CMYK"
if nc == 4:
mode = "CMYK"
elif enumcs == 17:
colr = "L"
elif tbox == b"pclr" and mode in ("L", "LA") and colr not in ("1", "L"):
ne, npc = header.read_fields(">HB")
assert isinstance(ne, int)
assert isinstance(npc, int)
@ -213,7 +219,7 @@ def _parse_jp2_header(
max_bitdepth = bitdepth
if max_bitdepth <= 8:
if npc == 4:
palette_mode = "CMYK" if cmyk else "RGBA"
palette_mode = "CMYK" if colr == "CMYK" else "RGBA"
else:
palette_mode = "RGB"
palette = ImagePalette.ImagePalette(palette_mode)

View File

@ -127,8 +127,8 @@ def APP(self: JpegImageFile, marker: int) -> None:
# parse the image resource block
offset = 14
photoshop = self.info.setdefault("photoshop", {})
while s[offset : offset + 4] == b"8BIM":
try:
try:
while s[offset : offset + 4] == b"8BIM":
offset += 4
# resource code
code = i16(s, offset)
@ -153,8 +153,8 @@ def APP(self: JpegImageFile, marker: int) -> None:
photoshop[code] = data
offset += size
offset += offset & 1 # align
except struct.error:
break # insufficient data
except struct.error:
pass # insufficient data
elif marker == 0xFFEE and s.startswith(b"Adobe"):
self.info["adobe"] = i16(s, 5)
@ -738,17 +738,15 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if not (0 < len(qtables) < 5):
msg = "None or too many quantization tables"
raise ValueError(msg)
for idx, table in enumerate(qtables):
try:
try:
for idx, table in enumerate(qtables):
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)
qtables[idx] = list(array.array("H", table))
except TypeError as e:
msg = "Invalid quantization table"
raise ValueError(msg) from e
return qtables
if qtables == "keep":

View File

@ -251,7 +251,7 @@ class PcfFontFile(FontFile.FontFile):
]
if encoding_offset != 0xFFFF:
encoding[i] = encoding_offset
except UnicodeDecodeError:
except UnicodeDecodeError: # noqa: PERF203
# character is not supported in selected encoding
pass

View File

@ -148,10 +148,14 @@ def _write_image(
strip_size=math.ceil(width / 8) * height,
)
elif decode_filter == "DCTDecode":
Image.SAVE["JPEG"](im, op, filename)
from . import JpegImagePlugin
JpegImagePlugin._save(im, op, filename)
elif decode_filter == "JPXDecode":
from . import Jpeg2KImagePlugin
del dict_obj["BitsPerComponent"]
Image.SAVE["JPEG2000"](im, op, filename)
Jpeg2KImagePlugin._save(im, op, filename)
else:
msg = f"unsupported PDF filter ({decode_filter})"
raise ValueError(msg)

View File

@ -383,7 +383,7 @@ class PdfParser:
msg = "specify buf or f or filename, but not both buf and f"
raise RuntimeError(msg)
self.filename = filename
self.buf: bytes | bytearray | mmap.mmap | None = buf
self.buf: bytes | bytearray | memoryview | mmap.mmap | None = buf
self.f = f
self.start_offset = start_offset
self.should_close_buf = False
@ -402,7 +402,11 @@ class PdfParser:
self.pages_ref: IndirectReference | None
self.last_xref_section_offset: int | None
if self.buf:
self.read_pdf_info()
try:
self.read_pdf_info()
except PdfFormatError:
self.close()
raise
else:
self.file_size_total = self.file_size_this = 0
self.root = PdfDict()
@ -431,7 +435,9 @@ class PdfParser:
self.seek_end()
def close_buf(self) -> None:
if isinstance(self.buf, mmap.mmap):
if isinstance(self.buf, memoryview):
self.buf.release()
elif isinstance(self.buf, mmap.mmap):
self.buf.close()
self.buf = None
@ -685,7 +691,9 @@ class PdfParser:
if b"Prev" in self.trailer_dict:
self.read_prev_trailer(self.trailer_dict[b"Prev"])
def read_prev_trailer(self, xref_section_offset: int) -> None:
def read_prev_trailer(
self, xref_section_offset: int, processed_offsets: list[int] = []
) -> None:
assert self.buf is not None
trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset)
m = self.re_trailer_prev.search(
@ -700,7 +708,11 @@ class PdfParser:
)
trailer_dict = self.interpret_trailer(trailer_data)
if b"Prev" in trailer_dict:
self.read_prev_trailer(trailer_dict[b"Prev"])
processed_offsets.append(xref_section_offset)
check_format_condition(
trailer_dict[b"Prev"] not in processed_offsets, "trailer loop found"
)
self.read_prev_trailer(trailer_dict[b"Prev"], processed_offsets)
re_whitespace_optional = re.compile(whitespace_optional)
re_name = re.compile(

View File

@ -866,13 +866,13 @@ class PngImageFile(ImageFile.ImageFile):
self._seek(0, True)
last_frame = self.__frame
for f in range(self.__frame + 1, frame + 1):
try:
try:
for f in range(self.__frame + 1, frame + 1):
self._seek(f)
except EOFError as e:
self.seek(last_frame)
msg = "no more images in APNG file"
raise EOFError(msg) from e
except EOFError as e:
self.seek(last_frame)
msg = "no more images in APNG file"
raise EOFError(msg) from e
def _seek(self, frame: int, rewind: bool = False) -> None:
assert self.png is not None
@ -1443,35 +1443,47 @@ def _save(
palette_bytes += b"\0"
chunk(fp, b"PLTE", palette_bytes)
transparency = im.encoderinfo.get("transparency", im.info.get("transparency", None))
transparency = im.encoderinfo.get("transparency", im.info.get("transparency"))
if transparency or transparency == 0:
if transparency is not None:
if im.mode == "P":
# limit to actual palette size
alpha_bytes = colors
if isinstance(transparency, bytes):
chunk(fp, b"tRNS", transparency[:alpha_bytes])
else:
elif isinstance(transparency, int):
transparency = max(0, min(255, transparency))
alpha = b"\xff" * transparency + b"\0"
chunk(fp, b"tRNS", alpha[:alpha_bytes])
else:
msg = "transparency for P must be an integer or bytes"
raise ValueError(msg)
elif im.mode in ("1", "L", "I", "I;16"):
transparency = max(0, min(65535, transparency))
chunk(fp, b"tRNS", o16(transparency))
if isinstance(transparency, int):
transparency = max(0, min(65535, transparency))
chunk(fp, b"tRNS", o16(transparency))
else:
msg = f"transparency for {im.mode} must be an integer"
raise ValueError(msg)
elif im.mode == "RGB":
red, green, blue = transparency
chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue))
else:
if "transparency" in im.encoderinfo:
# don't bother with transparency if it's an RGBA
# and it's in the info dict. It's probably just stale.
msg = "cannot use transparency for this mode"
raise OSError(msg)
else:
if im.mode == "P" and im.im.getpalettemode() == "RGBA":
alpha = im.im.getpalette("RGBA", "A")
alpha_bytes = colors
chunk(fp, b"tRNS", alpha[:alpha_bytes])
if not isinstance(transparency, (list, tuple)):
msg = "transparency for RGB must be list or tuple"
raise ValueError(msg)
elif len(transparency) != 3:
msg = "transparency for RGB must have length 3"
raise ValueError(msg)
else:
red, green, blue = transparency
chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue))
elif im.encoderinfo.get("transparency") is not None:
# don't bother with transparency if it's an RGBA
# and it's in the info dict. It's probably just stale.
msg = "cannot use transparency for this mode"
raise OSError(msg)
elif im.mode == "P" and im.im.getpalettemode() == "RGBA":
alpha = im.im.getpalette("RGBA", "A")
alpha_bytes = colors
chunk(fp, b"tRNS", alpha[:alpha_bytes])
if dpi := im.encoderinfo.get("dpi"):
chunk(

View File

@ -175,6 +175,9 @@ class PsdImageFile(ImageFile.ImageFile):
raise self._fp.ex
# seek to given layer (1..max)
if layer > len(self.layers):
msg = "no more images in PSD file"
raise EOFError(msg)
_, mode, _, tile = self.layers[layer - 1]
self._mode = mode
self.tile = tile

View File

@ -17,11 +17,13 @@
#
from __future__ import annotations
import os
import warnings
from typing import IO
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
@ -157,6 +159,20 @@ class TgaImageFile(ImageFile.ImageFile):
pass # cannot decode
def load_end(self) -> None:
if self.mode == "RGBA":
assert self.fp is not None
self.fp.seek(-26, os.SEEK_END)
footer = self.fp.read(26)
if footer.endswith(b"TRUEVISION-XFILE.\x00"):
# version 2
extension_offset = i32(footer)
if extension_offset:
self.fp.seek(extension_offset + 494)
attributes_type = self.fp.read(1)
if attributes_type == b"\x00":
# No alpha
self.im.fillband(3, 255)
if self._flip_horizontally:
self.im = self.im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)

Some files were not shown because too many files have changed in this diff Show More