diff --git a/.ci/install.sh b/.ci/install.sh index 9553eb8f4..cc104c45f 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -39,8 +39,8 @@ python3 -m pip install --only-binary=:all: pyarrow || true # PyQt6 doesn't support PyPy3 if [[ $GHA_PYTHON_VERSION == 3.* ]]; then sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 - # TODO Update condition when pyqt6 supports free-threading - if ! [[ "$PYTHON_GIL" == "0" ]]; then python3 -m pip install pyqt6 ; fi + # pyqt6 doesn't yet support free-threading; only install if a wheel is available + python3 -m pip install --only-binary=:all: pyqt6 || true fi # webp diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 6e869a5c2..c824c10bc 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.3.1 +cibuildwheel==3.4.1 diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index c64343a73..ad78bcb67 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.19.1 +mypy==1.20.2 arro3-compute arro3-core IceSpringPySideStubs-PyQt6 diff --git a/.ci/requirements-sbom.txt b/.ci/requirements-sbom.txt new file mode 100644 index 000000000..762812f80 --- /dev/null +++ b/.ci/requirements-sbom.txt @@ -0,0 +1 @@ +check-jsonschema==0.37.1 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 8fc6bd0ad..4378368a8 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ -tidelift: "pypi/pillow" +github: python-pillow +tidelift: pypi/pillow diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md new file mode 100644 index 000000000..1c2e395dd --- /dev/null +++ b/.github/INCIDENT_RESPONSE.md @@ -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** — +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 7–14 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] \ +> +> Hi \, +> +> 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 — \ — disclosure \ +> +> This is an embargoed notification of a vulnerability in Pillow. Please keep this +> information confidential until the disclosure date listed below. +> +> **CVE:** \ +> +> **Affected versions:** \ +> +> **Fixed version:** \ +> +> **Severity:** \ (CVSS \: \) +> +> **Reporter:** \ +> +> **Public disclosure date:** \ +> +> **Summary:** +> \ +> +> **Proof of concept:** +> \ +> +> **Remediation:** +> Upgrade to Pillow \. 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:** \ +> +> **CVE:** \ +> +> **Affected versions:** Pillow \< \ +> +> **Fixed version:** \ +> +> **Severity:** \ (CVSS \) +> +> **Reporter:** \ +> +> **Details:** +> \ +> +> **Remediation:** +> ``` +> python3 -m pip install --upgrade Pillow +> ``` +> +> **Timeline:** +> - Reported: \ +> - Fixed: \ +> - Disclosed: \ diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 1be2d9c9d..c9a396aa8 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -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 organization/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. diff --git a/.github/compare-dist-sizes.py b/.github/compare-dist-sizes.py new file mode 100644 index 000000000..ed7b9be0e --- /dev/null +++ b/.github/compare-dist-sizes.py @@ -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 ` +""" + +# /// 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\d[^-]*))?" + r"-(?P[^-]+)-(?P[^-]+)-(?P[^-]+)\.whl$", + re.IGNORECASE, +) +SDIST_RE = re.compile( + r"^(?P[^-]+)-(?P.+)\.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()) diff --git a/.github/dependencies.json b/.github/dependencies.json new file mode 100644 index 000000000..85db4ca0d --- /dev/null +++ b/.github/dependencies.json @@ -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" +} diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py new file mode 100755 index 000000000..3b15a7d91 --- /dev/null +++ b/.github/generate-sbom.py @@ -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 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 " + "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() diff --git a/.github/renovate.json b/.github/renovate.json index 8187fc15b..387630dd6 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -7,16 +7,169 @@ "Dependency" ], "minimumReleaseAge": "7 days", + "prCreation": "not-pending", + "schedule": [ + "* * 3 * *" + ], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"brotli\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "brotli", + "packageNameTemplate": "google/brotli", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"bzip2\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "bzip2", + "packageNameTemplate": "bzip2/bzip2", + "datasourceTemplate": "gitlab-tags", + "extractVersionTemplate": "^bzip2-(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"freetype\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "freetype", + "packageNameTemplate": "freetype/freetype", + "datasourceTemplate": "gitlab-tags", + "registryUrlTemplate": "https://gitlab.freedesktop.org", + "extractVersionTemplate": "^VER-(?[\\d-]+)$", + "versioningTemplate": "regex:^(?\\d+)[.-](?\\d+)[.-](?\\d+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"fribidi\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "fribidi", + "packageNameTemplate": "fribidi/fribidi", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"harfbuzz\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "harfbuzz", + "packageNameTemplate": "harfbuzz/harfbuzz", + "datasourceTemplate": "github-releases" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"jpegturbo\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "jpegturbo", + "packageNameTemplate": "libjpeg-turbo/libjpeg-turbo", + "datasourceTemplate": "github-releases" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"lcms2\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "lcms2", + "packageNameTemplate": "mm2/Little-CMS", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^lcms(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libavif\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "libavif", + "packageNameTemplate": "AOMediaCodec/libavif", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libimagequant\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "libimagequant", + "packageNameTemplate": "ImageOptim/libimagequant", + "datasourceTemplate": "github-tags" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libpng\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "libpng", + "packageNameTemplate": "pnggroup/libpng", + "datasourceTemplate": "github-tags", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libwebp\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "libwebp", + "packageNameTemplate": "webmproject/libwebp", + "datasourceTemplate": "github-tags", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"libxcb\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "libxcb", + "packageNameTemplate": "xorg/lib/libxcb", + "datasourceTemplate": "gitlab-tags", + "registryUrlTemplate": "https://gitlab.freedesktop.org", + "extractVersionTemplate": "^libxcb-(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"openjpeg\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "openjpeg", + "packageNameTemplate": "uclouvain/openjpeg", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"tiff\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "tiff", + "packageNameTemplate": "libtiff/libtiff", + "datasourceTemplate": "gitlab-tags", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"xz\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "xz", + "packageNameTemplate": "tukaani-project/xz", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"zlib-ng\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "zlib-ng", + "packageNameTemplate": "zlib-ng/zlib-ng", + "datasourceTemplate": "github-releases" + }, + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/dependencies\\.json$/"], + "matchStrings": ["\"zstd\":\\s*\"(?\\d+[^\"]*)\""], + "depNameTemplate": "zstd", + "packageNameTemplate": "facebook/zstd", + "datasourceTemplate": "github-releases", + "extractVersionTemplate": "^v(?.+)$" + } + ], "packageRules": [ { "groupName": "github-actions", - "matchManagers": [ - "github-actions" - ], + "matchManagers": ["github-actions"], "separateMajorMinor": false } - ], - "schedule": [ - "* * 3 * *" ] } diff --git a/.github/workflows/Brewfile b/.github/workflows/Brewfile new file mode 100644 index 000000000..414f04201 --- /dev/null +++ b/.github/workflows/Brewfile @@ -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" diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 3f78c98b6..99d04205f 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 857881c01..3734a3306 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e2f8bf47a..dacf40cc1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index 7c768af48..603ef5a23 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -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 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 12633284f..d62d2c3c2 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -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 }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 9d1902838..b2dca6dd2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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" diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 08226738e..e868b53a8 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -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: diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index 808373a65..1c36e06c0 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -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 }} diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml index 87eace643..5ceb544ea 100644 --- a/.github/workflows/test-valgrind-memory.yml +++ b/.github/workflows/test-valgrind-memory.yml @@ -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 diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index f14dab616..c47a0d060 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -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 diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 45392a689..fa1898df2 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -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: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 80bbfb45f..362412e94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: @@ -47,7 +42,6 @@ jobs: "3.15", "3.14t", "3.14", - "3.13t", "3.13", "3.12", "3.11", @@ -56,10 +50,6 @@ jobs: include: - { python-version: "3.12", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - { python-version: "3.11", PYTHONOPTIMIZE: 2 } - # Free-threaded - - { python-version: "3.15t", disable-gil: true } - - { python-version: "3.14t", disable-gil: true } - - { python-version: "3.13t", disable-gil: true } # Intel - { os: "macos-26-intel", python-version: "3.10" } exclude: @@ -69,12 +59,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 @@ -83,17 +73,12 @@ jobs: ".ci/*.sh" "pyproject.toml" - - name: Set PYTHON_GIL - if: "${{ matrix.disable-gil }}" - run: | - echo "PYTHON_GIL=0" >> $GITHUB_ENV - - name: Build system information run: python3 .github/workflows/system-info.py - 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 +86,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 +94,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 +147,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 +158,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: diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 107eeae9b..12593b3f5 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -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.56 -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 . ) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index af2f9b3e8..0180d1c1c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -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-15-intel + os: macos-26-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 diff --git a/.github/zizmor.yml b/.github/zizmor.yml deleted file mode 100644 index 100026562..000000000 --- a/.github/zizmor.yml +++ /dev/null @@ -1,6 +0,0 @@ -# https://docs.zizmor.sh/configuration/ -rules: - unpinned-uses: - config: - policies: - "*": ref-pin diff --git a/.gitignore b/.gitignore index 3033c2ea7..4e9803957 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ pillow-test-images.zip # pyinstaller *.spec + +# Generated SBOM +pillow-*.cdx.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53fd0a3ca..5ee040297 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/LICENSE b/LICENSE index 10dd42d9e..c011511a4 100644 --- a/LICENSE +++ b/LICENSE @@ -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: diff --git a/README.md b/README.md index 8585ef6cb..b4d83c2a9 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/RELEASING.md b/RELEASING.md index 3c6188c82..fcf108943 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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 diff --git a/Tests/conftest.py b/Tests/conftest.py index e00d1f019..1f32bbedf 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -1,9 +1,17 @@ from __future__ import annotations import io +import sys +import sysconfig import pytest +FREE_THREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + +gil_enabled_at_start = True +if FREE_THREADED_BUILD: + gil_enabled_at_start = sys._is_gil_enabled() # type: ignore[attr-defined] + def pytest_report_header(config: pytest.Config) -> str: try: @@ -16,6 +24,25 @@ def pytest_report_header(config: pytest.Config) -> str: return f"pytest_report_header failed: {e}" +def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter) -> None: + if ( + FREE_THREADED_BUILD + and not gil_enabled_at_start + and sys._is_gil_enabled() # type: ignore[attr-defined] + ): + tr = terminalreporter + tr.ensure_newline() + tr.section("GIL re-enabled", red=True, bold=True) + tr.line("The GIL was re-enabled at runtime during the tests.") + tr.line("This can happen with no test failures if the RuntimeWarning") + tr.line("raised by Python when this happens is filtered by a test.") + tr.line("") + tr.line("Please ensure all new C modules declare support for running") + tr.line("without the GIL. Any new tests that intentionally imports") + tr.line("code that re-enables the GIL should do so in a subprocess.") + pytest.exit("GIL re-enabled during tests", returncode=1) + + def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( "markers", diff --git a/Tests/images/pal8_offset.bmp b/Tests/images/pal8_offset.bmp deleted file mode 100644 index 24be65f22..000000000 Binary files a/Tests/images/pal8_offset.bmp and /dev/null differ diff --git a/Tests/images/psd-oob-write-overflow.psd b/Tests/images/psd-oob-write-overflow.psd new file mode 100644 index 000000000..c2bb10d61 Binary files /dev/null and b/Tests/images/psd-oob-write-overflow.psd differ diff --git a/Tests/images/separate_planar_extra_samples.tiff b/Tests/images/separate_planar_extra_samples.tiff new file mode 100644 index 000000000..be51a7570 Binary files /dev/null and b/Tests/images/separate_planar_extra_samples.tiff differ diff --git a/Tests/images/trailer_loop.pdf b/Tests/images/trailer_loop.pdf new file mode 100644 index 000000000..7bf27ca37 Binary files /dev/null and b/Tests/images/trailer_loop.pdf differ diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 8fbd73748..ea0853100 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -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"), diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index a25f77177..3ad38fd7e 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -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: """ diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 2e0394b3b..c8ac46524 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -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: diff --git a/Tests/test_file_container.py b/Tests/test_file_container.py index 597ab5083..c73f2a40c 100644 --- a/Tests/test_file_container.py +++ b/Tests/test_file_container.py @@ -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: diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index d4e8db4f4..d41bab307 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -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") diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 0e60b59f5..2a69f1f9a 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -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() diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index b453e3aa5..ca3c055f9 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -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() diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 3f08d1ad3..734bcde92 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -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: diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index fbca46be5..d0b1cbf8e 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -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) diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 8a2636dfe..538b1406b 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -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() diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 3b1953aac..71fb434cc 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -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: diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index f996cce67..e419c29f0 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -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? diff --git a/Tests/test_font_crash.py b/Tests/test_font_crash.py index 72a0f3534..9e92ea456 100644 --- a/Tests/test_font_crash.py +++ b/Tests/test_font_crash.py @@ -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) diff --git a/Tests/test_image.py b/Tests/test_image.py index 32c799195..81bd47299 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -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 diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 73b25ed51..f188be81e 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -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}" diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 6656ee506..517705905 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -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): diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index d0b458d6b..409474707 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -365,7 +365,7 @@ def test_rotated_transposed_font( bbox_b[2] - bbox_b[0], ) - # Check top left co-ordinates are correct + # Check top left coordinates are correct assert bbox_b[:2] == (20, 20) # text length is undefined for vertical text @@ -410,7 +410,7 @@ def test_unrotated_transposed_font( bbox_b[3] - bbox_b[1], ) - # Check top left co-ordinates are correct + # Check top left coordinates are correct assert bbox_b[:2] == (20, 20) assert length_a == length_b diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 01fa090dc..180682c64 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -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": diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 35fe3bb8a..31b7abecd 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -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 diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index ad8acde49..8d230eb56 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -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"): diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 2b424629d..507d82409 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -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 diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index d85fb1212..cba6cd053 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -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") diff --git a/Tests/test_pyarrow.py b/Tests/test_pyarrow.py index 7a161f2ac..f282f2c00 100644 --- a/Tests/test_pyarrow.py +++ b/Tests/test_pyarrow.py @@ -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) diff --git a/depends/install_raqm.sh b/depends/install_raqm.sh index 33bb2d0a7..8730b7d83 100755 --- a/depends/install_raqm.sh +++ b/depends/install_raqm.sh @@ -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 diff --git a/docs/COPYING b/docs/COPYING index 17fba5b87..1852f9e47 100644 --- a/docs/COPYING +++ b/docs/COPYING @@ -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: diff --git a/docs/conf.py b/docs/conf.py index 040301433..189758944 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 diff --git a/docs/handbook/index.rst b/docs/handbook/index.rst index acdeff7db..23255eb8a 100644 --- a/docs/handbook/index.rst +++ b/docs/handbook/index.rst @@ -8,3 +8,4 @@ Handbook tutorial concepts appendices + security diff --git a/docs/handbook/security.rst b/docs/handbook/security.rst new file mode 100644 index 000000000..c13389134 --- /dev/null +++ b/docs/handbook/security.rst @@ -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 +`_ 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 +`_; 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 `_. +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 +`_. + +If you cannot use GitHub, use the `Tidelift security contact +`_. Tidelift will coordinate the fix and +disclosure. + +**Do not report sensitive vulnerability information in public.** diff --git a/docs/handbook/third-party-plugins.rst b/docs/handbook/third-party-plugins.rst index 200866499..51181a596 100644 --- a/docs/handbook/third-party-plugins.rst +++ b/docs/handbook/third-party-plugins.rst @@ -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 `__'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. diff --git a/docs/index.rst b/docs/index.rst index ee51621ac..8612f77a5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ Pillow ====== -Pillow is the friendly PIL fork by `Jeffrey A. Clark and contributors `_. PIL is the Python Imaging Library by Fredrik Lundh and contributors. +Pillow is the friendly PIL fork by `Jeffrey 'Alex' Clark and contributors `_. PIL is the Python Imaging Library by Fredrik Lundh and contributors. Pillow for enterprise is available via the Tidelift Subscription. `Learn more `_. diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 1655b8f60..ecb892e1f 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -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 \ diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 74c63fb06..90321d054 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -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 | | +----------------------------------+-----------------------------+------------------+--------------+ diff --git a/docs/reference/ImageColor.rst b/docs/reference/ImageColor.rst index 781359f86..e73a63225 100644 --- a/docs/reference/ImageColor.rst +++ b/docs/reference/ImageColor.rst @@ -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% diff --git a/docs/releasenotes/12.2.0.rst b/docs/releasenotes/12.2.0.rst new file mode 100644 index 000000000..c03a28482 --- /dev/null +++ b/docs/releasenotes/12.2.0.rst @@ -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 exceedingly 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() + ... + + +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. diff --git a/docs/releasenotes/12.3.0.rst b/docs/releasenotes/12.3.0.rst new file mode 100644 index 000000000..58c8836d2 --- /dev/null +++ b/docs/releasenotes/12.3.0.rst @@ -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. diff --git a/docs/releasenotes/8.2.0.rst b/docs/releasenotes/8.2.0.rst index a59560695..abd3e0e04 100644 --- a/docs/releasenotes/8.2.0.rst +++ b/docs/releasenotes/8.2.0.rst @@ -81,7 +81,7 @@ Image.alpha_composite: dest ^^^^^^^^^^^^^^^^^^^^^^^^^^^ When calling :py:meth:`~PIL.Image.Image.alpha_composite`, the ``dest`` argument now -accepts negative co-ordinates, like the upper left corner of the ``box`` argument of +accepts negative coordinates, like the upper left corner of the ``box`` argument of :py:meth:`~PIL.Image.Image.paste` can be negative. Naturally, this has effect of cropping the overlaid image. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 690be2072..7cae29d18 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6d9910ca1..8861fe775 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", +] diff --git a/setup.py b/setup.py index 3d975950b..6a6bffab6 100644 --- a/setup.py +++ b/setup.py @@ -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"]), diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 5ee61b35b..a6724cab4 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -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 diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 264564d2b..d82c4c746 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -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 diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index a3fdc0efe..e91840778 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -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 diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 390b3b374..b8db5d832 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -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): diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index 146a6fa0d..3784ef2f1 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -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 diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index 1523e95d5..1a56660f7 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -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 diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 023835fb7..cb7a74c2e 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -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)) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index bde335504..81add2f7a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -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) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index eb108ac41..66511697a 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -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( @@ -235,12 +235,12 @@ class ImageDraw: self, xy: Coords, fill: _Ink | None = None, - width: int = 0, + width: int = 1, joint: str | None = None, ) -> None: """Draw a line, or a connected sequence of line segments.""" 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: points: Sequence[Sequence[float]] @@ -538,7 +538,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 @@ -591,49 +591,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 @@ -641,13 +641,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 diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 50e0075a2..c70d93f3c 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -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): """ diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index ea7f4dc54..06ea0359c 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -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): diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 4228078b1..66ee6dd33 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -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) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 42b10bd7b..f0ae142b9 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -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 diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 99ad2771b..2abbd46ea 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -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: diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index e6ccd8243..008d20d38 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -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 diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index 6fc824e4c..9c8be8b4e 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -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)} diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 2f11cbfe3..46320eb3b 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -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": diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py index a00e9b919..b923293b0 100644 --- a/src/PIL/PcfFontFile.py +++ b/src/PIL/PcfFontFile.py @@ -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 diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 5594c7e0f..cb26786b0 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -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) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 2c9031469..99ff26999 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -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 = None + ) -> 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,13 @@ class PdfParser: ) trailer_dict = self.interpret_trailer(trailer_data) if b"Prev" in trailer_dict: - self.read_prev_trailer(trailer_dict[b"Prev"]) + if processed_offsets is None: + processed_offsets = [] + 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( diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 4e082a293..3f21fa48e 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -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( diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 69a8703dd..dd3d5ab95 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -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 diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 3eec94dca..5094faa13 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1473,28 +1473,34 @@ class TiffImageFile(ImageFile.ImageFile): logger.debug("- size: %s", self.size) sample_format = self.tag_v2.get(SAMPLEFORMAT, (1,)) - if len(sample_format) > 1 and max(sample_format) == min(sample_format) == 1: + if len(sample_format) > 1 and max(sample_format) == min(sample_format): # SAMPLEFORMAT is properly per band, so an RGB image will # be (1,1,1). But, we don't support per band pixel types, # and anything more than one band is a uint8. So, just # take the first element. Revisit this if adding support # for more exotic images. - sample_format = (1,) + sample_format = (sample_format[0],) bps_tuple = self.tag_v2.get(BITSPERSAMPLE, (1,)) extra_tuple = self.tag_v2.get(EXTRASAMPLES, ()) + samples_per_pixel = self.tag_v2.get( + SAMPLESPERPIXEL, + 3 if self._compression == "tiff_jpeg" and photo in (2, 6) else 1, + ) if photo in (2, 6, 8): # RGB, YCbCr, LAB bps_count = 3 elif photo == 5: # CMYK bps_count = 4 else: bps_count = 1 + if self._planar_configuration == 2 and extra_tuple and max(extra_tuple) == 0: + # If components are stored separately, + # then unspecified extra components at the end can be ignored + bps_tuple = bps_tuple[: -len(extra_tuple)] + samples_per_pixel -= len(extra_tuple) + extra_tuple = () bps_count += len(extra_tuple) bps_actual_count = len(bps_tuple) - samples_per_pixel = self.tag_v2.get( - SAMPLESPERPIXEL, - 3 if self._compression == "tiff_jpeg" and photo in (2, 6) else 1, - ) if samples_per_pixel > MAX_SAMPLESPERPIXEL: # DOS check, samples_per_pixel can be a Long, and we extend the tuple below @@ -1762,6 +1768,12 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: legacy_ifd = im.tag.to_v2() supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} + if supplied_tags.get(PLANAR_CONFIGURATION) == 2 and EXTRASAMPLES in supplied_tags: + # If the image used separate component planes, + # then EXTRASAMPLES should be ignored when saving contiguously + if SAMPLESPERPIXEL in supplied_tags: + supplied_tags[SAMPLESPERPIXEL] -= len(supplied_tags[EXTRASAMPLES]) + del supplied_tags[EXTRASAMPLES] for tag in ( # IFD offset that may not be correct in the saved image EXIFIFD, diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index e20e40d91..63a481691 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -43,10 +43,15 @@ class WebPImageFile(ImageFile.ImageFile): __logical_frame = 0 def _open(self) -> None: + assert self.fp is not None + s = self.fp.read() + if not _accept(s): + msg = "not a WEBP file" + raise SyntaxError(msg) + # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. - assert self.fp is not None - self._decoder = _webp.WebPAnimDecoder(self.fp.read()) + self._decoder = _webp.WebPAnimDecoder(s) # Get info from decoder self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = ( diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index a85c62a93..f5e244782 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -147,10 +147,6 @@ class WmfStubImageFile(ImageFile.StubImageFile): self._mode = "RGB" self._size = size - loader = self._load() - if loader: - loader.open(self) - def _load(self) -> ImageFile.StubHandler | None: return _handler diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 6e4c23f89..faf3e76e0 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -1,6 +1,6 @@ """Pillow (Fork of the Python Imaging Library) -Pillow is the friendly PIL fork by Jeffrey A. Clark and contributors. +Pillow is the friendly PIL fork by Jeffrey 'Alex' Clark and contributors. https://github.com/python-pillow/Pillow/ Pillow is forked from PIL 1.1.7. diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 96363e9f1..8d005f33e 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "12.2.0.dev0" +__version__ = "12.3.0.dev0" diff --git a/src/_imaging.c b/src/_imaging.c index ac0317f78..7fee41114 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -267,6 +267,9 @@ PyObject * ExportArrowSchemaPyCapsule(ImagingObject *self) { struct ArrowSchema *schema = (struct ArrowSchema *)calloc(1, sizeof(struct ArrowSchema)); + if (!schema) { + return ArrowError(IMAGING_CODEC_MEMORY); + } int err = export_imaging_schema(self->image, schema); if (err == 0) { return PyCapsule_New(schema, "arrow_schema", ReleaseArrowSchemaPyCapsule); @@ -292,6 +295,9 @@ PyObject * ExportArrowArrayPyCapsule(ImagingObject *self) { struct ArrowArray *array = (struct ArrowArray *)calloc(1, sizeof(struct ArrowArray)); + if (!array) { + return ArrowError(IMAGING_CODEC_MEMORY); + } int err = export_imaging_array(self->image, array); if (err == 0) { return PyCapsule_New(array, "arrow_array", ReleaseArrowArrayPyCapsule); @@ -3166,8 +3172,8 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { PyObject *data; int ink; - int width = 0; - if (!PyArg_ParseTuple(args, "Oi|i", &data, &ink, &width)) { + int width; + if (!PyArg_ParseTuple(args, "Oii", &data, &ink, &width)) { return NULL; } @@ -3176,7 +3182,7 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { return NULL; } - if (width <= 1) { + if (width == 1) { double *p = NULL; for (i = 0; i < n - 1; i++) { p = &xy[i + i]; diff --git a/src/_imagingft.c b/src/_imagingft.c index 5d91eaad6..8330439f0 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -518,7 +518,7 @@ text_layout( } static PyObject * -font_getlength(FontObject *self, PyObject *args) { +font_getlength_impl(FontObject *self, PyObject *args) { int length; /* length along primary axis, in 26.6 precision */ GlyphInfo *glyph_info = NULL; /* computed text layout */ size_t i, count; /* glyph_info index and length */ @@ -567,6 +567,15 @@ font_getlength(FontObject *self, PyObject *args) { return PyLong_FromLong(length); } +static PyObject * +font_getlength(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getlength_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + static int bounding_box_and_anchors( FT_Face face, @@ -580,9 +589,9 @@ bounding_box_and_anchors( int *x_offset, int *y_offset ) { - int position; /* pen position along primary axis, in 26.6 precision */ - int advanced; /* pen position along primary axis, in pixels */ - int px, py; /* position of current glyph, in pixels */ + long position; /* pen position along primary axis, in 26.6 precision */ + long advanced; /* pen position along primary axis, in pixels */ + int px, py; /* position of current glyph, in pixels */ int x_min, x_max, y_min, y_max; /* text bounding box, in pixels */ int x_anchor, y_anchor; /* offset of point drawn at (0, 0), in pixels */ int error; @@ -746,7 +755,7 @@ bad_anchor: } static PyObject * -font_getsize(FontObject *self, PyObject *args) { +font_getsize_impl(FontObject *self, PyObject *args) { int width, height, x_offset, y_offset; int load_flags; /* FreeType load_flags parameter */ int error; @@ -820,7 +829,16 @@ font_getsize(FontObject *self, PyObject *args) { } static PyObject * -font_render(FontObject *self, PyObject *args) { +font_getsize(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getsize_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_render_impl(FontObject *self, PyObject *args) { int x, y; /* pen position, in 26.6 precision */ int px, py; /* position of current glyph, in pixels */ int x_min, y_max; /* text offset in 26.6 precision */ @@ -1243,6 +1261,15 @@ glyph_error: return NULL; } +static PyObject * +font_render(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_render_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + static PyObject * font_getvarnames(FontObject *self) { int error; @@ -1382,7 +1409,7 @@ font_getvaraxes(FontObject *self) { } static PyObject * -font_setvarname(FontObject *self, PyObject *args) { +font_setvarname_impl(FontObject *self, PyObject *args) { int error; int instance_index; @@ -1399,7 +1426,16 @@ font_setvarname(FontObject *self, PyObject *args) { } static PyObject * -font_setvaraxes(FontObject *self, PyObject *args) { +font_setvarname(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_setvarname_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_setvaraxes_impl(FontObject *self, PyObject *args) { int error; PyObject *axes, *item; @@ -1452,6 +1488,15 @@ font_setvaraxes(FontObject *self, PyObject *args) { Py_RETURN_NONE; } +static PyObject * +font_setvaraxes(FontObject *self, PyObject *args) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_setvaraxes_impl(self, args); + Py_END_CRITICAL_SECTION(); + return result; +} + static void font_dealloc(FontObject *self) { if (self->face) { @@ -1493,30 +1538,75 @@ font_getattr_style(FontObject *self, void *closure) { } static PyObject * -font_getattr_ascent(FontObject *self, void *closure) { +font_getattr_ascent_impl(FontObject *self, void *closure) { return PyLong_FromLong(PIXEL(self->face->size->metrics.ascender)); } static PyObject * -font_getattr_descent(FontObject *self, void *closure) { +font_getattr_ascent(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_ascent_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_descent_impl(FontObject *self, void *closure) { return PyLong_FromLong(-PIXEL(self->face->size->metrics.descender)); } static PyObject * -font_getattr_height(FontObject *self, void *closure) { +font_getattr_descent(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_descent_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_height_impl(FontObject *self, void *closure) { return PyLong_FromLong(PIXEL(self->face->size->metrics.height)); } static PyObject * -font_getattr_x_ppem(FontObject *self, void *closure) { +font_getattr_height(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_height_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_x_ppem_impl(FontObject *self, void *closure) { return PyLong_FromLong(self->face->size->metrics.x_ppem); } static PyObject * -font_getattr_y_ppem(FontObject *self, void *closure) { +font_getattr_x_ppem(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_x_ppem_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + +static PyObject * +font_getattr_y_ppem_impl(FontObject *self, void *closure) { return PyLong_FromLong(self->face->size->metrics.y_ppem); } +static PyObject * +font_getattr_y_ppem(FontObject *self, void *closure) { + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + result = font_getattr_y_ppem_impl(self, closure); + Py_END_CRITICAL_SECTION(); + return result; +} + static PyObject * font_getattr_glyphs(FontObject *self, void *closure) { return PyLong_FromLong(self->face->num_glyphs); diff --git a/src/_imagingtk.c b/src/_imagingtk.c index 68d7bf4cd..7b9607cb5 100644 --- a/src/_imagingtk.c +++ b/src/_imagingtk.c @@ -33,8 +33,10 @@ _tkinit(PyObject *self, PyObject *args) { } interp = (Tcl_Interp *)PyLong_AsVoidPtr(arg); + if (interp == NULL && PyErr_Occurred()) { + return NULL; + } - /* This will bomb if interp is invalid... */ TkImaging_Init(interp); Py_RETURN_NONE; diff --git a/src/decode.c b/src/decode.c index cda4ce702..5c6c25098 100644 --- a/src/decode.c +++ b/src/decode.c @@ -155,21 +155,54 @@ PyImaging_AsImaging(PyObject *op); static PyObject * _setimage(ImagingDecoderObject *decoder, PyObject *args) { - PyObject *op; + PyObject *op, *extents; Imaging im; ImagingCodecState state; int x0, y0, x1, y1; - x0 = y0 = x1 = y1 = 0; - /* FIXME: should publish the ImagingType descriptor */ - if (!PyArg_ParseTuple(args, "O(iiii)", &op, &x0, &y0, &x1, &y1)) { + if (!PyArg_ParseTuple(args, "OO", &op, &extents)) { return NULL; } im = PyImaging_AsImaging(op); if (!im) { return NULL; } + if (extents == Py_None) { + x0 = 0; + y0 = 0; + x1 = im->xsize; + y1 = im->ysize; + } else { + if (!PyTuple_Check(extents) || PyTuple_GET_SIZE(extents) != 4) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + for (int i = 0; i < 4; i++) { + PyObject *extent = PyTuple_GetItem(extents, i); + if (!PyLong_Check(extent)) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + int e = (int)PyLong_AsLong(extent); + + if (i == 0) { + x0 = e; + } else if (i == 1) { + y0 = e; + } else if (i == 2) { + x1 = e; + } else { + y1 = e; + } + } + } + + if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0 || x1 > (int)im->xsize || + y1 > (int)im->ysize) { + PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image"); + return NULL; + } decoder->im = im; @@ -181,13 +214,6 @@ _setimage(ImagingDecoderObject *decoder, PyObject *args) { state->xsize = x1 - x0; state->ysize = y1 - y0; - if (state->xoff < 0 || state->xsize <= 0 || - state->xsize + state->xoff > (int)im->xsize || state->yoff < 0 || - state->ysize <= 0 || state->ysize + state->yoff > (int)im->ysize) { - PyErr_SetString(PyExc_ValueError, "tile cannot extend outside image"); - return NULL; - } - /* Allocate memory buffer (if bits field is set) */ if (state->bits > 0) { if (!state->bytes) { diff --git a/src/encode.c b/src/encode.c index 1fc31404d..4b1496534 100644 --- a/src/encode.c +++ b/src/encode.c @@ -222,28 +222,58 @@ PyImaging_AsImaging(PyObject *op); static PyObject * _setimage(ImagingEncoderObject *encoder, PyObject *args) { - PyObject *op; + PyObject *op, *extents; Imaging im; ImagingCodecState state; Py_ssize_t x0, y0, x1, y1; - /* Define where image data should be stored */ - - x0 = y0 = x1 = y1 = 0; - /* FIXME: should publish the ImagingType descriptor */ - if (!PyArg_ParseTuple(args, "O(nnnn)", &op, &x0, &y0, &x1, &y1)) { + if (!PyArg_ParseTuple(args, "OO", &op, &extents)) { return NULL; } im = PyImaging_AsImaging(op); if (!im) { return NULL; } + if (extents == Py_None) { + x0 = 0; + y0 = 0; + x1 = im->xsize; + y1 = im->ysize; + } else { + if (!PyTuple_Check(extents) || PyTuple_GET_SIZE(extents) != 4) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + for (int i = 0; i < 4; i++) { + PyObject *extent = PyTuple_GetItem(extents, i); + if (!PyLong_Check(extent)) { + PyErr_SetString(PyExc_ValueError, "invalid extents"); + return NULL; + } + Py_ssize_t e = (Py_ssize_t)PyLong_AsLong(extent); + + if (i == 0) { + x0 = e; + } else if (i == 1) { + y0 = e; + } else if (i == 2) { + x1 = e; + } else { + y1 = e; + } + } + } if (im->xsize == 0 || im->ysize == 0) { PyErr_SetString(PyExc_ValueError, "cannot write empty image"); return NULL; } + if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0 || x1 > im->xsize || y1 > im->ysize) { + PyErr_SetString(PyExc_SystemError, "tile cannot extend outside image"); + return NULL; + } + encoder->im = im; state = &encoder->state; @@ -253,13 +283,6 @@ _setimage(ImagingEncoderObject *encoder, PyObject *args) { state->xsize = x1 - x0; state->ysize = y1 - y0; - if (state->xoff < 0 || state->xsize <= 0 || - state->xsize + state->xoff > im->xsize || state->yoff < 0 || - state->ysize <= 0 || state->ysize + state->yoff > im->ysize) { - PyErr_SetString(PyExc_SystemError, "tile cannot extend outside image"); - return NULL; - } - /* Allocate memory buffer (if bits field is set) */ if (state->bits > 0) { if (state->xsize > ((INT_MAX / state->bits) - 7)) { diff --git a/src/libImaging/Arrow.c b/src/libImaging/Arrow.c index de4d3568e..3ca227d4f 100644 --- a/src/libImaging/Arrow.c +++ b/src/libImaging/Arrow.c @@ -10,8 +10,8 @@ static void ReleaseExportedSchema(struct ArrowSchema *array) { - // This should not be called on already released array - // assert(array->release != NULL); + // TODO here: release and/or deallocate all data directly owned by + // the ArrowArray struct, such as the private_data. if (!array->release) { return; @@ -30,31 +30,36 @@ ReleaseExportedSchema(struct ArrowSchema *array) { } // Release children - for (int64_t i = 0; i < array->n_children; ++i) { - struct ArrowSchema *child = array->children[i]; - if (child->release != NULL) { - child->release(child); - child->release = NULL; - } - free(array->children[i]); - } if (array->children) { + for (int64_t i = 0; i < array->n_children; ++i) { + struct ArrowSchema *child = array->children[i]; + if (child != NULL) { + if (child->release != NULL) { + child->release(child); + child->release = NULL; + } + free(array->children[i]); + } + } free(array->children); + array->children = NULL; } // Release dictionary struct ArrowSchema *dict = array->dictionary; - if (dict != NULL && dict->release != NULL) { - dict->release(dict); - dict->release = NULL; + if (dict != NULL) { + if (dict->release != NULL) { + dict->release(dict); + dict->release = NULL; + } + free(dict); + array->dictionary = NULL; } - // TODO here: release and/or deallocate all data directly owned by - // the ArrowArray struct, such as the private_data. - // Mark array released array->release = NULL; } + char * image_band_json(Imaging im) { char *format = "{\"bands\": [\"%s\", \"%s\", \"%s\", \"%s\"]}"; @@ -220,13 +225,19 @@ export_imaging_schema(Imaging im, struct ArrowSchema *schema) { // if it's not 1 band, it's an int32 at the moment. 4 uint8 bands. schema->n_children = 1; schema->children = calloc(1, sizeof(struct ArrowSchema *)); + if (!schema->children) { + schema->release(schema); + return IMAGING_CODEC_MEMORY; + } schema->children[0] = (struct ArrowSchema *)calloc(1, sizeof(struct ArrowSchema)); + if (!schema->children[0]) { + schema->release(schema); + return IMAGING_CODEC_MEMORY; + } retval = export_named_type( schema->children[0], im->arrow_band_format, getModeData(im->mode)->name ); if (retval != 0) { - free(schema->children[0]); - free(schema->children); schema->release(schema); return retval; } @@ -256,11 +267,12 @@ release_const_array(struct ArrowArray *array) { array->buffers = NULL; } if (array->children) { - // undone -- does arrow release all the children recursively? for (int i = 0; i < array->n_children; i++) { - if (array->children[i]->release) { - array->children[i]->release(array->children[i]); - array->children[i]->release = NULL; + if (array->children[i]) { + if (array->children[i]->release) { + array->children[i]->release(array->children[i]); + array->children[i]->release = NULL; + } free(array->children[i]); } } @@ -303,8 +315,11 @@ export_single_channel_array(Imaging im, struct ArrowArray *array) { }; // Allocate list of buffers - array->buffers = (const void **)malloc(sizeof(void *) * array->n_buffers); - // assert(array->buffers != NULL); + array->buffers = (const void **)calloc(1, sizeof(void *) * array->n_buffers); + if (!array->buffers) { + array->release(array); + return IMAGING_CODEC_MEMORY; + } array->buffers[0] = NULL; // no nulls, null bitmap can be omitted if (im->block) { @@ -386,6 +401,9 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { array->children[0]->buffers = (const void **)calloc(2, sizeof(void *) * array->n_buffers); + if (!array->children[0]->buffers) { + goto err; + } if (im->block) { array->children[0]->buffers[1] = im->block; @@ -395,15 +413,7 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { return 0; err: - if (array->children[0]) { - free(array->children[0]); - } - if (array->children) { - free(array->children); - } - if (array->buffers) { - free(array->buffers); - } + array->release(array); return IMAGING_CODEC_MEMORY; } diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c index 973a7a2fa..c6989dc1c 100644 --- a/src/libImaging/BcnEncode.c +++ b/src/libImaging/BcnEncode.c @@ -257,9 +257,9 @@ ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { UINT8 *dst = buf; + int will_write = (n == 2 || n == 3 || n == 5) ? 16 : 8; for (;;) { - // Loop writes a max of 16 bytes per iteration - if (dst + 16 >= bytes + buf) { + if (dst + will_write >= bytes + buf) { break; } if (n == 5) { diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 002497c32..f156810ff 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -37,8 +37,6 @@ #define MAX(a, b) (a) > (b) ? (a) : (b) #define MIN(a, b) (a) < (b) ? (a) : (b) -#define CLIP16(v) ((v) <= 0 ? 0 : (v) >= 65535 ? 65535 : (v)) - /* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */ #define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114) #define L24(rgb) ((rgb)[0] * 19595 + (rgb)[1] * 38470 + (rgb)[2] * 7471 + 0x8000) diff --git a/src/libImaging/FliDecode.c b/src/libImaging/FliDecode.c index 9b494dfa2..d3a1bb954 100644 --- a/src/libImaging/FliDecode.c +++ b/src/libImaging/FliDecode.c @@ -49,7 +49,7 @@ ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt framesize = I32(ptr); // there can be one pad byte in the framesize - if (bytes + (bytes % 2) < framesize) { + if ((unsigned)(bytes + (bytes % 2)) < framesize) { return 0; } @@ -259,7 +259,7 @@ ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt state->errcode = IMAGING_CODEC_BROKEN; return -1; } - if (advance < 0 || advance > bytes) { + if (advance < 0 || advance > (unsigned)bytes) { state->errcode = IMAGING_CODEC_OVERRUN; return -1; } diff --git a/src/libImaging/ImagingUtils.h b/src/libImaging/ImagingUtils.h index 714458ad0..a362780d0 100644 --- a/src/libImaging/ImagingUtils.h +++ b/src/libImaging/ImagingUtils.h @@ -27,6 +27,8 @@ #define CLIP8(v) ((v) <= 0 ? 0 : (v) < 256 ? (v) : 255) +#define CLIP16(v) ((v) <= 0 ? 0 : (v) < 65536 ? (v) : 65535) + /* This is to work around a bug in GCC prior 4.9 in 64 bit mode. GCC generates code with partial dependency which is 3 times slower. See: https://stackoverflow.com/a/26588074/253146 */ diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 1123d7bc9..ccb6a199c 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -812,7 +812,7 @@ j2k_decode_entry(Imaging im, ImagingCodecState state) { break; } - /* Adjust the tile co-ordinates based on the reduction (OpenJPEG + /* Adjust the tile coordinates based on the reduction (OpenJPEG doesn't do this for us) */ tile_info.x0 = (tile_info.x0 + correction) >> context->reduce; tile_info.y0 = (tile_info.y0 + correction) >> context->reduce; diff --git a/src/libImaging/Paste.c b/src/libImaging/Paste.c index f01bce933..f4b72c5d8 100644 --- a/src/libImaging/Paste.c +++ b/src/libImaging/Paste.c @@ -356,7 +356,7 @@ fill( ) { /* fill opaque region */ - int x, y, i; + int x, y; UINT8 ink8 = 0; INT32 ink32 = 0L; @@ -372,6 +372,7 @@ fill( } else { #if defined _WIN32 && !defined _WIN64 + int i; dx *= pixelsize; for (y = 0; y < ysize; y++) { UINT8 *out = (UINT8 *)imOut->image[y + dy] + dx; diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index cbd18d0c1..1647dca14 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -492,9 +492,9 @@ ImagingResampleHorizontal_16bpc( << 8)) * k[x]; } - ss_int = ROUND_UP(ss); - imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); - imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + ss_int = CLIP16(ROUND_UP(ss)); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF; + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8; } } ImagingSectionLeave(&cookie); @@ -531,9 +531,9 @@ ImagingResampleVertical_16bpc( (imIn->image8[y + ymin][xx * 2 + (bigendian ? 0 : 1)] << 8)) * k[y]; } - ss_int = ROUND_UP(ss); - imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); - imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + ss_int = CLIP16(ROUND_UP(ss)); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF; + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8; } } ImagingSectionLeave(&cookie); @@ -726,19 +726,10 @@ ImagingResampleInner( need_horizontal = xsize != imIn->xsize || box[0] || box[2] != xsize; need_vertical = ysize != imIn->ysize || box[1] || box[3] != ysize; - ksize_horiz = precompute_coeffs( - imIn->xsize, box[0], box[2], xsize, filterp, &bounds_horiz, &kk_horiz - ); - if (!ksize_horiz) { - return NULL; - } - ksize_vert = precompute_coeffs( imIn->ysize, box[1], box[3], ysize, filterp, &bounds_vert, &kk_vert ); if (!ksize_vert) { - free(bounds_horiz); - free(kk_horiz); return NULL; } @@ -749,6 +740,15 @@ ImagingResampleInner( /* two-pass resize, horizontal pass */ if (need_horizontal) { + ksize_horiz = precompute_coeffs( + imIn->xsize, box[0], box[2], xsize, filterp, &bounds_horiz, &kk_horiz + ); + if (!ksize_horiz) { + free(bounds_vert); + free(kk_vert); + return NULL; + } + // Shift bounds for vertical pass for (i = 0; i < ysize; i++) { bounds_vert[i * 2] -= ybox_first; @@ -768,10 +768,6 @@ ImagingResampleInner( return NULL; } imOut = imIn = imTemp; - } else { - // Free in any case - free(bounds_horiz); - free(kk_horiz); } /* vertical pass */ diff --git a/src/path.c b/src/path.c index 38300547c..b88346d5f 100644 --- a/src/path.c +++ b/src/path.c @@ -118,14 +118,27 @@ assign_item_to_array(double *xy, Py_ssize_t j, PyObject *op) { } else if (PyNumber_Check(op)) { xy[j++] = PyFloat_AsDouble(op); } else if (PyList_Check(op)) { + if (PyList_GET_SIZE(op) != 2) { + PyErr_SetString( + PyExc_ValueError, "coordinate list must contain exactly 2 coordinates" + ); + return -1; + } for (int k = 0; k < 2; k++) { PyObject *op1 = PyList_GetItemRef(op, k); if (op1 == NULL) { return -1; } - j = assign_item_to_array(xy, j, op1); + if (PyFloat_Check(op1) || PyLong_Check(op1) || PyNumber_Check(op1)) { + j = assign_item_to_array(xy, j, op1); + } else { + j = -1; + } Py_DECREF(op1); if (j == -1) { + PyErr_SetString( + PyExc_ValueError, "coordinate list must contain numbers" + ); return -1; } } diff --git a/src/thirdparty/raqm/NEWS b/src/thirdparty/raqm/NEWS index fb432cffb..7d393eb14 100644 --- a/src/thirdparty/raqm/NEWS +++ b/src/thirdparty/raqm/NEWS @@ -1,3 +1,21 @@ +Overview of changes leading to 0.10.5 +Saturday, April 11, 2026 +==================================== + +Check for NULL return from malloc in couple of places. + +Overview of changes leading to 0.10.4 +Thursday, February 5, 2026 +==================================== + +Add build option to skip tests. + +Add dependency override for use as a subproject. + +Fix tests when b_ndebug=true. + +Build, CI and documentation updates. + Overview of changes leading to 0.10.3 Tuesday, August 5, 2025 ==================================== diff --git a/src/thirdparty/raqm/raqm-version.h b/src/thirdparty/raqm/raqm-version.h index f2dd61cf6..9908f06bd 100644 --- a/src/thirdparty/raqm/raqm-version.h +++ b/src/thirdparty/raqm/raqm-version.h @@ -33,9 +33,9 @@ #define RAQM_VERSION_MAJOR 0 #define RAQM_VERSION_MINOR 10 -#define RAQM_VERSION_MICRO 3 +#define RAQM_VERSION_MICRO 5 -#define RAQM_VERSION_STRING "0.10.3" +#define RAQM_VERSION_STRING "0.10.5" #define RAQM_VERSION_ATLEAST(major,minor,micro) \ ((major)*10000+(minor)*100+(micro) <= \ diff --git a/src/thirdparty/raqm/raqm.c b/src/thirdparty/raqm/raqm.c index 9ecc5cac8..88bbbfd5e 100644 --- a/src/thirdparty/raqm/raqm.c +++ b/src/thirdparty/raqm/raqm.c @@ -54,7 +54,7 @@ * @short_description: A library for complex text layout * @include: raqm.h * - * Raqm is a light weight text layout library with strong emphasis on + * Raqm is a lightweight text layout library with strong emphasis on * supporting languages and writing systems that require complex text layout. * * The main object in Raqm API is #raqm_t, it stores all the states of the @@ -338,6 +338,8 @@ _raqm_alloc_run (raqm_t *rq) else { run = malloc (sizeof (raqm_run_t)); + if (!run) + return NULL; run->font = NULL; run->buffer = NULL; } @@ -515,7 +517,7 @@ raqm_clear_contents (raqm_t *rq) * @len: the length of @text. * * Adds @text to @rq to be used for layout. It must be a valid UTF-32 text, any - * invalid character will be replaced with U+FFFD. The text should typically + * invalid characters will be replaced with U+FFFD. The text should typically * represent a full paragraph, since doing the layout of chunks of text * separately can give improper output. * @@ -765,13 +767,13 @@ raqm_set_par_direction (raqm_t *rq, * raqm_set_language: * @rq: a #raqm_t. * @lang: a BCP47 language code. - * @start: index of first character that should use @face. + * @start: index of the first character that should use @face. * @len: number of characters using @face. * * Sets a [BCP47 language * code](https://www.w3.org/International/articles/language-tags/) to be used - * for @len-number of characters staring at @start. The @start and @len are - * input string array indices (i.e. counting bytes in UTF-8 and scaler values + * for @len-number of characters starting at @start. The @start and @len are + * input string array indices (i.e. counting bytes in UTF-8 and scalar values * in UTF-32). * * This method can be used repeatedly to set different languages for different @@ -951,7 +953,7 @@ raqm_set_freetype_face (raqm_t *rq, * raqm_set_freetype_face_range: * @rq: a #raqm_t. * @face: an #FT_Face. - * @start: index of first character that should use @face from the input string. + * @start: index of the first character that should use @face from the input string. * @len: number of elements using @face. * * Sets an #FT_Face to be used for @len-number of characters staring at @start. @@ -962,7 +964,7 @@ raqm_set_freetype_face (raqm_t *rq, * * This method can be used repeatedly to set different faces for different * parts of the text. It is the responsibility of the client to make sure that - * face ranges cover the whole text, and is properly aligned. + * face ranges cover the whole text, and are properly aligned. * * See also raqm_set_freetype_face(). * @@ -1023,9 +1025,6 @@ _raqm_set_freetype_load_flags (raqm_t *rq, * Sets the load flags passed to FreeType when loading glyphs, should be the * same flags used by the client when rendering FreeType glyphs. * - * This requires version of HarfBuzz that has hb_ft_font_set_load_flags(), for - * older version the flags will be ignored. - * * Return value: * `true` if no errors happened, `false` otherwise. * @@ -1042,22 +1041,19 @@ raqm_set_freetype_load_flags (raqm_t *rq, * raqm_set_freetype_load_flags_range: * @rq: a #raqm_t. * @flags: FreeType load flags. - * @start: index of first character that should use @flags. + * @start: index of the first character that should use @flags. * @len: number of characters using @flags. * * Sets the load flags passed to FreeType when loading glyphs for @len-number * of characters staring at @start. Flags should be the same as used by the * client when rendering corresponding FreeType glyphs. The @start and @len - * are input string array indices (i.e. counting bytes in UTF-8 and scaler + * are input string array indices (i.e. counting bytes in UTF-8 and scalar * values in UTF-32). * * This method can be used repeatedly to set different flags for different * parts of the text. It is the responsibility of the client to make sure that * flag ranges cover the whole text. * - * This requires version of HarfBuzz that has hb_ft_font_set_load_flags(), for - * older version the flags will be ignored. - * * See also raqm_set_freetype_load_flags(). * * Return value: @@ -1143,7 +1139,7 @@ _raqm_set_spacing (raqm_t *rq, * raqm_set_letter_spacing_range: * @rq: a #raqm_t. * @spacing: amount of spacing in Freetype Font Units (26.6 format). - * @start: index of first character that should use @spacing. + * @start: index of the first character that should use @spacing. * @len: number of characters using @spacing. * * Set the letter spacing or tracking for a given range, the value @@ -1200,12 +1196,12 @@ raqm_set_letter_spacing_range(raqm_t *rq, * raqm_set_word_spacing_range: * @rq: a #raqm_t. * @spacing: amount of spacing in Freetype Font Units (26.6 format). - * @start: index of first character that should use @spacing. + * @start: index of the first character that should use @spacing. * @len: number of characters using @spacing. * * Set the word spacing for a given range. Word spacing will only be applied to * 'word separator' characters, such as 'space', 'no break space' and - * Ethiopic word separator'. + * 'Ethiopic word separator'. * The value will be added onto the advance and offset for RTL, and the advance * for other directions. * @@ -1239,7 +1235,7 @@ raqm_set_word_spacing_range(raqm_t *rq, * @rq: a #raqm_t. * @gid: glyph id to use for invisible glyphs. * - * Sets the glyph id to be used for invisible glyhphs. + * Sets the glyph id to be used for invisible glyphs. * * If @gid is negative, invisible glyphs will be suppressed from the output. * @@ -1629,6 +1625,11 @@ _raqm_reorder_runs (const FriBidiCharType *types, } runs = malloc (sizeof (_raqm_bidi_run) * count); + if (!runs) + { + *run_count = 0; + return NULL; + } while (run_start < len) { @@ -2747,10 +2748,10 @@ raqm_version_string (void) * @minor: Library minor version component. * @micro: Library micro version component. * - * Checks if library version is less than or equal the specified version. + * Checks if library version is less than or equal to the specified version. * * Return value: - * `true` if library version is less than or equal the specified version, + * `true` if library version is less than or equal to the specified version, * `false` otherwise. * * Since: 0.7 @@ -2769,10 +2770,10 @@ raqm_version_atleast (unsigned int major, * @minor: Library minor version component. * @micro: Library micro version component. * - * Checks if library version is less than or equal the specified version. + * Checks if library version is less than or equal to the specified version. * * Return value: - * `true` if library version is less than or equal the specified version, + * `true` if library version is less than or equal to the specified version, * `false` otherwise. * * Since: 0.7 diff --git a/src/thirdparty/raqm/raqm.h b/src/thirdparty/raqm/raqm.h index 6fd6089c7..4c75f9d46 100644 --- a/src/thirdparty/raqm/raqm.h +++ b/src/thirdparty/raqm/raqm.h @@ -48,7 +48,7 @@ extern "C" { /** * raqm_t: * - * This is the main object holding all state of the currently processed text as + * This is the main object holding all the states of the currently processed text as * well as its output. * * Since: 0.1 @@ -81,7 +81,7 @@ typedef enum * @y_advance: the glyph advance width in vertical text. * @x_offset: the horizontal movement of the glyph from the current point. * @y_offset: the vertical movement of the glyph from the current point. - * @cluster: the index of original character in input text. + * @cluster: the index of the original character in the input text. * @ftface: the @FT_Face of the glyph. * * The structure that holds information about output glyphs, returned from diff --git a/tox.ini b/tox.ini index de18946ef..aede5fcdc 100644 --- a/tox.ini +++ b/tox.ini @@ -9,11 +9,16 @@ env_list = [testenv] deps = numpy + pytest-sugar extras = tests commands = {envpython} selftest.py - {envpython} -m pytest -W always {posargs} + {envpython} -m pytest \ + --dist worksteal \ + --numprocesses auto \ + -W always \ + {posargs} [testenv:lint] skip_install = true diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index d958a4592..f55c82112 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json import os import platform import re @@ -8,6 +9,7 @@ import shutil import struct import subprocess import sys +from pathlib import Path from typing import Any @@ -112,30 +114,17 @@ ARCHITECTURES = { "ARM64": {"vcvars_arch": "x86_arm64", "msbuild_arch": "ARM64"}, } -V = { - "BROTLI": "1.2.0", - "FREETYPE": "2.14.3", - "FRIBIDI": "1.0.16", - "HARFBUZZ": "13.2.1", - "JPEGTURBO": "3.1.3", - "LCMS2": "2.18", - "LIBAVIF": "1.4.1", - "LIBIMAGEQUANT": "4.4.1", - "LIBPNG": "1.6.56", - "LIBWEBP": "1.6.0", - "OPENJPEG": "2.5.4", - "TIFF": "4.7.1", - "XZ": "5.8.2", - "ZLIBNG": "2.3.3", -} -V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) +V = json.loads( + (Path(__file__).parents[1] / ".github" / "dependencies.json").read_text() +) +V["libpng-xy"] = "".join(V["libpng"].split(".")[:2]) # dependencies, listed in order of compilation DEPS: dict[str, dict[str, Any]] = { "libjpeg": { - "url": f"https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/{V['JPEGTURBO']}/libjpeg-turbo-{V['JPEGTURBO']}.tar.gz", - "filename": f"libjpeg-turbo-{V['JPEGTURBO']}.tar.gz", + "url": f"https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/{V['jpegturbo']}/libjpeg-turbo-{V['jpegturbo']}.tar.gz", + "filename": f"libjpeg-turbo-{V['jpegturbo']}.tar.gz", "license": ["README.ijg", "LICENSE.md"], "license_pattern": ( "(LEGAL ISSUES\n============\n\n.+?)\n\nREFERENCES\n==========" @@ -162,8 +151,8 @@ DEPS: dict[str, dict[str, Any]] = { "bins": ["djpeg.exe"], }, "zlib": { - "url": f"https://github.com/zlib-ng/zlib-ng/archive/refs/tags/{V['ZLIBNG']}.tar.gz", - "filename": f"zlib-ng-{V['ZLIBNG']}.tar.gz", + "url": f"https://github.com/zlib-ng/zlib-ng/archive/refs/tags/{V['zlib-ng']}.tar.gz", + "filename": f"zlib-ng-{V['zlib-ng']}.tar.gz", "license": "LICENSE.md", "patch": { r"CMakeLists.txt": { @@ -179,8 +168,8 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"zlib.lib"], }, "xz": { - "url": f"https://github.com/tukaani-project/xz/releases/download/v{V['XZ']}/FILENAME", - "filename": f"xz-{V['XZ']}.tar.gz", + "url": f"https://github.com/tukaani-project/xz/releases/download/v{V['xz']}/FILENAME", + "filename": f"xz-{V['xz']}.tar.gz", "license": "COPYING", "build": [ *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -192,7 +181,7 @@ DEPS: dict[str, dict[str, Any]] = { }, "libwebp": { "url": "http://downloads.webmproject.org/releases/webp/FILENAME", - "filename": f"libwebp-{V['LIBWEBP']}.tar.gz", + "filename": f"libwebp-{V['libwebp']}.tar.gz", "license": "COPYING", "patch": { r"src\enc\picture_csp_enc.c": { @@ -213,7 +202,7 @@ DEPS: dict[str, dict[str, Any]] = { }, "libtiff": { "url": "https://download.osgeo.org/libtiff/FILENAME", - "filename": f"tiff-{V['TIFF']}.tar.gz", + "filename": f"tiff-{V['tiff']}.tar.gz", "license": "LICENSE.md", "patch": { r"libtiff\tif_lzma.c": { @@ -237,22 +226,22 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"libtiff\*.lib"], }, "libpng": { - "url": f"{SF_PROJECTS}/libpng/files/libpng{V['LIBPNG_XY']}/{V['LIBPNG']}/" + "url": f"{SF_PROJECTS}/libpng/files/libpng{V['libpng-xy']}/{V['libpng']}/" f"FILENAME/download", - "filename": f"libpng-{V['LIBPNG']}.tar.gz", + "filename": f"libpng-{V['libpng']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake("png_static", "-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF"), cmd_copy( - f"libpng{V['LIBPNG_XY']}_static.lib", f"libpng{V['LIBPNG_XY']}.lib" + f"libpng{V['libpng-xy']}_static.lib", f"libpng{V['libpng-xy']}.lib" ), ], "headers": [r"png*.h"], - "libs": [f"libpng{V['LIBPNG_XY']}.lib"], + "libs": [f"libpng{V['libpng-xy']}.lib"], }, "brotli": { - "url": f"https://github.com/google/brotli/archive/refs/tags/v{V['BROTLI']}.tar.gz", - "filename": f"brotli-{V['BROTLI']}.tar.gz", + "url": f"https://github.com/google/brotli/archive/refs/tags/v{V['brotli']}.tar.gz", + "filename": f"brotli-{V['brotli']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake(("brotlicommon", "brotlidec"), "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -262,7 +251,7 @@ DEPS: dict[str, dict[str, Any]] = { }, "freetype": { "url": "https://download.savannah.gnu.org/releases/freetype/FILENAME", - "filename": f"freetype-{V['FREETYPE']}.tar.gz", + "filename": f"freetype-{V['freetype']}.tar.gz", "license": ["LICENSE.TXT", r"docs\FTL.TXT", r"docs\GPLv2.TXT"], "patch": { r"builds\windows\vc2010\freetype.vcxproj": { @@ -275,7 +264,7 @@ DEPS: dict[str, dict[str, Any]] = { "": "FT_CONFIG_OPTION_SYSTEM_ZLIB;FT_CONFIG_OPTION_USE_PNG;FT_CONFIG_OPTION_USE_HARFBUZZ;FT_CONFIG_OPTION_USE_BROTLI", # noqa: E501 "": r"{dir_harfbuzz}\src;{inc_dir}", # noqa: E501 "": "{lib_dir}", # noqa: E501 - "": f"zlib.lib;libpng{V['LIBPNG_XY']}.lib;brotlicommon.lib;brotlidec.lib", # noqa: E501 + "": f"zlib.lib;libpng{V['libpng-xy']}.lib;brotlicommon.lib;brotlidec.lib", # noqa: E501 }, r"src/autofit/afshaper.c": { # link against harfbuzz.lib @@ -295,8 +284,8 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"objs\{msbuild_arch}\Release Static\freetype.lib"], }, "lcms2": { - "url": f"{SF_PROJECTS}/lcms/files/lcms/{V['LCMS2']}/FILENAME/download", - "filename": f"lcms2-{V['LCMS2']}.tar.gz", + "url": f"{SF_PROJECTS}/lcms/files/lcms/{V['lcms2']}/FILENAME/download", + "filename": f"lcms2-{V['lcms2']}.tar.gz", "license": "LICENSE", "patch": { r"Projects\VC2022\lcms2_static\lcms2_static.vcxproj": { @@ -320,21 +309,21 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"Lib\MS\*.lib"], }, "openjpeg": { - "url": f"https://github.com/uclouvain/openjpeg/archive/v{V['OPENJPEG']}.tar.gz", - "filename": f"openjpeg-{V['OPENJPEG']}.tar.gz", + "url": f"https://github.com/uclouvain/openjpeg/archive/v{V['openjpeg']}.tar.gz", + "filename": f"openjpeg-{V['openjpeg']}.tar.gz", "license": "LICENSE", "build": [ *cmds_cmake( "openjp2", "-DBUILD_CODEC:BOOL=OFF", "-DBUILD_SHARED_LIBS:BOOL=OFF" ), - cmd_mkdir(rf"{{inc_dir}}\openjpeg-{V['OPENJPEG']}"), - cmd_copy(r"src\lib\openjp2\*.h", rf"{{inc_dir}}\openjpeg-{V['OPENJPEG']}"), + cmd_mkdir(rf"{{inc_dir}}\openjpeg-{V['openjpeg']}"), + cmd_copy(r"src\lib\openjp2\*.h", rf"{{inc_dir}}\openjpeg-{V['openjpeg']}"), ], "libs": [r"bin\*.lib"], }, "libimagequant": { - "url": "https://github.com/ImageOptim/libimagequant/archive/{V['LIBIMAGEQUANT']}.tar.gz", - "filename": f"libimagequant-{V['LIBIMAGEQUANT']}.tar.gz", + "url": "https://github.com/ImageOptim/libimagequant/archive/{V['libimagequant']}.tar.gz", + "filename": f"libimagequant-{V['libimagequant']}.tar.gz", "license": "COPYRIGHT", "build": [ cmd_cd("imagequant-sys"), @@ -344,8 +333,8 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"..\target\release\imagequant_sys.lib"], }, "harfbuzz": { - "url": f"https://github.com/harfbuzz/harfbuzz/releases/download/{V['HARFBUZZ']}/FILENAME", - "filename": f"harfbuzz-{V['HARFBUZZ']}.tar.xz", + "url": f"https://github.com/harfbuzz/harfbuzz/releases/download/{V['harfbuzz']}/FILENAME", + "filename": f"harfbuzz-{V['harfbuzz']}.tar.xz", "license": "COPYING", "build": [ *cmds_cmake( @@ -358,11 +347,11 @@ DEPS: dict[str, dict[str, Any]] = { "libs": [r"*.lib"], }, "fribidi": { - "url": f"https://github.com/fribidi/fribidi/archive/v{V['FRIBIDI']}.zip", - "filename": f"fribidi-{V['FRIBIDI']}.zip", + "url": f"https://github.com/fribidi/fribidi/archive/v{V['fribidi']}.zip", + "filename": f"fribidi-{V['fribidi']}.zip", "license": "COPYING", "build": [ - cmd_copy(r"COPYING", rf"{{bin_dir}}\fribidi-{V['FRIBIDI']}-COPYING"), + cmd_copy(r"COPYING", rf"{{bin_dir}}\fribidi-{V['fribidi']}-COPYING"), cmd_copy(r"{winbuild_dir}\fribidi.cmake", r"CMakeLists.txt"), # generated tab.i files cannot be cross-compiled " ^&^& ".join( @@ -376,8 +365,8 @@ DEPS: dict[str, dict[str, Any]] = { "bins": [r"*.dll"], }, "libavif": { - "url": f"https://github.com/AOMediaCodec/libavif/archive/v{V['LIBAVIF']}.tar.gz", - "filename": f"libavif-{V['LIBAVIF']}.tar.gz", + "url": f"https://github.com/AOMediaCodec/libavif/archive/v{V['libavif']}.tar.gz", + "filename": f"libavif-{V['libavif']}.tar.gz", "license": "LICENSE", "build": [ "rustup update", @@ -542,14 +531,11 @@ def write_script( def get_footer(dep: dict[str, Any]) -> list[str]: - lines = [] - for out in dep.get("headers", []): - lines.append(cmd_copy(out, "{inc_dir}")) - for out in dep.get("libs", []): - lines.append(cmd_copy(out, "{lib_dir}")) - for out in dep.get("bins", []): - lines.append(cmd_copy(out, "{bin_dir}")) - return lines + return ( + [cmd_copy(out, "{inc_dir}") for out in dep.get("headers", [])] + + [cmd_copy(out, "{lib_dir}") for out in dep.get("libs", [])] + + [cmd_copy(out, "{bin_dir}") for out in dep.get("bins", [])] + ) def build_env(prefs: dict[str, str], verbose: bool) -> None: