diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f31d8f3..d41afcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,10 @@ jobs: matrix: include: # windows-2022, not windows-latest: the msvc preset's generator is "Visual Studio 17 2022". - # msvc also builds and runs examples/vips_composite.c on libvips' prebuilt Windows build - # (fetched, pinned; MSVC is what Platemaker builds with). - - { os: windows-2022, preset: msvc, vips_example: ON } + # msvc also builds and runs examples/vips_composite.c and the libvips plugin + # (libvips-plugin/) on libvips' prebuilt Windows build (fetched, pinned; MSVC is what + # Platemaker builds with). + - { os: windows-2022, preset: msvc, vips: ON } - { os: windows-latest, preset: mingw } # linux-gcc also reads psd-tools' 315 files (22 MB, pinned by hash): every comparison # with psd-tools, Photoshop's ZIP files through zlib among them. @@ -77,10 +78,30 @@ jobs: - name: Configure, build, test if: matrix.preset != 'mingw' run: | - cmake --preset ${{ matrix.preset }} -DMINIPSD_REQUIRE_PSD_TOOLS=ON -DMINIPSD_PSD_TOOLS_FILES=${{ matrix.psd_tools_files || 'OFF' }} -DMINIPSD_VIPS_EXAMPLE=${{ matrix.vips_example || 'OFF' }} "-DPython3_EXECUTABLE=${{ steps.python.outputs.python-path }}" + cmake --preset ${{ matrix.preset }} -DMINIPSD_REQUIRE_PSD_TOOLS=ON -DMINIPSD_PSD_TOOLS_FILES=${{ matrix.psd_tools_files || 'OFF' }} -DMINIPSD_VIPS_EXAMPLE=${{ matrix.vips || 'OFF' }} -DMINIPSD_VIPS_PLUGIN=${{ matrix.vips || 'OFF' }} "-DPython3_EXECUTABLE=${{ steps.python.outputs.python-path }}" cmake --build --preset ${{ matrix.preset }} ctest --preset ${{ matrix.preset }} + # The plugin as released (minipsd-vips-X.Y.Z.zip, tools/package_vips.cmake): built from the + # zip alone, outside the repository, its example run, its SBOM fragment checked against its + # files; libvips as fetched above. The release builds it again on Linux before publishing. + - name: Check the released plugin builds and runs + if: matrix.vips == 'ON' + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $true + cmake -DOUTPUT_DIR="$env:RUNNER_TEMP/dist" -P tools/package_vips.cmake + $zip = Get-Item "$env:RUNNER_TEMP/dist/minipsd-vips-*.zip" + cmake -E chdir "$env:RUNNER_TEMP" cmake -E tar xf $zip.FullName + $unpacked = Join-Path $env:RUNNER_TEMP $zip.BaseName + cmake "-DSPDX=$unpacked/minipsd-vips.spdx.json" "-DBASE=$unpacked" -DCOMPLETE=ON -P tools/check_spdx.cmake + $deps = "$env:GITHUB_WORKSPACE/build/${{ matrix.preset }}/_deps" + cmake -S (Join-Path $env:RUNNER_TEMP $zip.BaseName) -B "$env:RUNNER_TEMP/plugin" ` + "-DFETCHCONTENT_SOURCE_DIR_MINIPSD_LIBVIPS=$deps/minipsd_libvips-src" ` + "-DFETCHCONTENT_SOURCE_DIR_MINIPSD_ZLIB_HEADERS=$deps/minipsd_zlib_headers-src" + cmake --build "$env:RUNNER_TEMP/plugin" + ctest --test-dir "$env:RUNNER_TEMP/plugin" -C Debug --output-on-failure + # The same tests, instrumented, on Linux: # linux-asan AddressSanitizer + UBSan: out-of-bounds access, overflow, undefined shifts. # No round trip: Python would need the sanitizer runtime preloaded. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2a8475..6ff7865 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,8 @@ # Release: a pushed tag X.Y.Z (bare semver) checks that CI already passed on its commit on master, # checks the tag against the header version and the changelog, and publishes minipsd.h (include/ joined into one file by -# tools/amalgamate.cmake) + minipsd.h.sha256 as a GitHub Release with a build-provenance attestation. "Run workflow" (workflow_dispatch) does everything except publish, +# tools/amalgamate.cmake) + minipsd.h.sha256, and the libvips plugin as a CMake project that builds as it is +# (minipsd-vips-X.Y.Z.zip, tools/package_vips.cmake, built and run here first) + its .sha256, as a GitHub Release +# with build-provenance attestations. "Run workflow" (workflow_dispatch) does everything except publish, # so it is a dry run. See docs/GITHUB-RELEASE.md. name: Release @@ -63,10 +65,30 @@ jobs: cmake -DOUTPUT=dist/minipsd.h -P tools/amalgamate.cmake cd dist && sha256sum minipsd.h > minipsd.h.sha256 && cat minipsd.h.sha256 - # Verifiable with: gh attestation verify minipsd.h --repo ShadobaDev/minipsd + # The plugin's sources, the single-file header, how libvips is found and an example, one zip. + - name: Stage the libvips plugin and its checksum + run: | + cmake -DOUTPUT_DIR=dist -P tools/package_vips.cmake + cd dist && for zip in minipsd-vips-*.zip; do sha256sum "$zip" > "$zip.sha256" && cat "$zip.sha256"; done + + # Built from the zip alone, outside the repository, against the system's libvips; its example + # saves a PSD through the plugin and reads it back. Its SBOM fragment checked against its files. + - name: Check the plugin builds and runs as released + run: | + sudo apt-get update && sudo apt-get install -y --no-install-recommends libvips-dev ninja-build + mkdir "$RUNNER_TEMP/plugin" && cd "$RUNNER_TEMP/plugin" + cmake -E tar xf "$GITHUB_WORKSPACE"/dist/minipsd-vips-*.zip + cmake -DSPDX="$(echo minipsd-vips-*)/minipsd-vips.spdx.json" -DBASE="$(echo minipsd-vips-*)" -DCOMPLETE=ON -P "$GITHUB_WORKSPACE"/tools/check_spdx.cmake + cmake -S minipsd-vips-* -B build -G Ninja + cmake --build build + ctest --test-dir build --output-on-failure + + # Verifiable with: gh attestation verify minipsd.h --repo ShadobaDev/minipsd (the zip alike) - uses: actions/attest-build-provenance@v4 with: - subject-path: dist/minipsd.h + subject-path: | + dist/minipsd.h + dist/minipsd-vips-*.zip - name: Publish the GitHub Release if: github.ref_type == 'tag' @@ -78,3 +100,5 @@ jobs: files: | dist/minipsd.h dist/minipsd.h.sha256 + dist/minipsd-vips-*.zip + dist/minipsd-vips-*.zip.sha256 diff --git a/CMakeLists.txt b/CMakeLists.txt index 23f0eff..f9c2b46 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,8 +28,10 @@ project(minipsd # Tests and install rules are for working on minipsd itself, not for consumers. option(MINIPSD_BUILD_TESTS "Build the minipsd tests" ${PROJECT_IS_TOP_LEVEL}) option(MINIPSD_INSTALL "Generate the minipsd install rules" ${PROJECT_IS_TOP_LEVEL}) +# The libvips plugin (libvips-plugin/): psdload and psdsave for a program that links libvips. +option(MINIPSD_VIPS_PLUGIN "Build the libvips plugin, minipsd::vips (libvips fetched on Windows)" OFF) -if(PROJECT_IS_TOP_LEVEL OR MINIPSD_BUILD_TESTS) +if(PROJECT_IS_TOP_LEVEL OR MINIPSD_BUILD_TESTS OR MINIPSD_VIPS_PLUGIN) enable_language(C) # also gives GNUInstallDirs a known architecture endif() include(GNUInstallDirs) @@ -50,8 +52,6 @@ target_include_directories(minipsd INTERFACE # add_subdirectory, FetchContent and find_package (a variable would not survive # FetchContent_MakeAvailable's function scope). Its timestamp honours SOURCE_DATE_EPOCH. # --------------------------------------------------------------------------- -# Every header is listed with its checksums; SPDX requires SHA-1 per file and derives the package -# verification code from those (sorted, concatenated, hashed again). file(GLOB_RECURSE _minipsd_headers RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}/include" CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/include/*.h") list(SORT _minipsd_headers) @@ -59,46 +59,11 @@ list(SORT _minipsd_headers) # edited header would refresh neither the checksums here nor tests/'s single-file header. list(TRANSFORM _minipsd_headers PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/include/" OUTPUT_VARIABLE _minipsd_header_paths) set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_minipsd_header_paths}") -set(_minipsd_sbom_file [=[ - - { - "SPDXID": "SPDXRef-File-@_minipsd_id@", - "fileName": "./include/@_minipsd_rel@", - "checksums": [ - { "algorithm": "SHA1", "checksumValue": "@_minipsd_sha1@" }, - { "algorithm": "SHA256", "checksumValue": "@_minipsd_sha256@" } - ], - "licenseConcluded": "MIT", - "licenseInfoInFiles": [ "MIT" ], - "copyrightText": "Copyright (c) 2026 Bartlomiej Mucha " - }]=]) -set(_minipsd_sbom_contains [=[ - - { - "spdxElementId": "SPDXRef-Package-minipsd", - "relationshipType": "CONTAINS", - "relatedSpdxElement": "SPDXRef-File-@_minipsd_id@" - }]=]) -set(_minipsd_files "") -set(_minipsd_contains "") -set(_minipsd_sha1s "") -foreach(_minipsd_rel IN LISTS _minipsd_headers) - string(REGEX REPLACE "[^A-Za-z0-9.-]" "-" _minipsd_id "${_minipsd_rel}") - file(SHA1 "${CMAKE_CURRENT_SOURCE_DIR}/include/${_minipsd_rel}" _minipsd_sha1) - file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/include/${_minipsd_rel}" _minipsd_sha256) - list(APPEND _minipsd_sha1s "${_minipsd_sha1}") - string(CONFIGURE "${_minipsd_sbom_file}" _minipsd_entry @ONLY) - list(APPEND _minipsd_files "${_minipsd_entry}") - string(CONFIGURE "${_minipsd_sbom_contains}" _minipsd_entry @ONLY) - list(APPEND _minipsd_contains "${_minipsd_entry}") -endforeach() -list(JOIN _minipsd_files "," MINIPSD_SBOM_FILES) -list(JOIN _minipsd_contains "," MINIPSD_SBOM_CONTAINS) -list(SORT _minipsd_sha1s) -list(JOIN _minipsd_sha1s "" _minipsd_sha1s) -string(SHA1 MINIPSD_SBOM_VERIFICATION "${_minipsd_sha1s}") -string(TIMESTAMP MINIPSD_SBOM_CREATED "%Y-%m-%dT%H:%M:%SZ" UTC) -configure_file(cmake/minipsd.spdx.json.in "${CMAKE_CURRENT_BINARY_DIR}/minipsd.spdx.json" @ONLY) +include(cmake/minipsd_spdx.cmake) +minipsd_spdx(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/minipsd.spdx.json" PACKAGE minipsd VERSION "${PROJECT_VERSION}" + HOMEPAGE "${PROJECT_HOMEPAGE_URL}" DOWNLOAD "git+${PROJECT_HOMEPAGE_URL}.git@${PROJECT_VERSION}" + SUMMARY "${PROJECT_DESCRIPTION}" COMMENT "Header-only: the files below, under include/, are the whole library." + BASE "${CMAKE_CURRENT_SOURCE_DIR}/include" PREFIX "include/" FILES ${_minipsd_headers}) set_target_properties(minipsd PROPERTIES MINIPSD_SBOM_FILE "${CMAKE_CURRENT_BINARY_DIR}/minipsd.spdx.json") # --------------------------------------------------------------------------- @@ -170,5 +135,8 @@ endif() if(MINIPSD_BUILD_TESTS) enable_language(CXX) enable_testing() - add_subdirectory(tests) + add_subdirectory(tests) # the plugin too, with the tests' warnings and its own test +elseif(MINIPSD_VIPS_PLUGIN) + include(cmake/minipsd_libvips.cmake) + add_subdirectory(libvips-plugin) endif() diff --git a/README.md b/README.md index 1b1fd71..5cb0335 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Procreate open as layered documents. # Status -**release: 0.3.1** +**release: 0.4.0** The API is [include/minipsd.h](include/minipsd.h); the implementation is in [include/minipsd/](include/minipsd/). @@ -85,6 +85,10 @@ no deflate), and are copied as they are when such a file is read and written bac files are bounded by caller limits. How it does on 315 files from other programs: [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md). +In a program built on libvips, [libvips-plugin/](libvips-plugin/) makes minipsd its PSD and PSB +loader and saver: a file's composite or any layer in, one image or several as layers out; see +the manual's [PSD in libvips](docs/MANUAL.md#psd-in-libvips-the-plugin). + ## Adding it to a project Copy the `include/` directory, or use CMake (FetchContent, git submodule, `find_package`), diff --git a/cmake/minipsd.spdx.json.in b/cmake/minipsd.spdx.json.in index 72ed0fa..35d6529 100644 --- a/cmake/minipsd.spdx.json.in +++ b/cmake/minipsd.spdx.json.in @@ -2,8 +2,8 @@ "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", - "name": "minipsd-@PROJECT_VERSION@", - "documentNamespace": "@PROJECT_HOMEPAGE_URL@/spdx/minipsd-@PROJECT_VERSION@-@MINIPSD_SBOM_VERIFICATION@", + "name": "@S_PACKAGE@-@S_VERSION@", + "documentNamespace": "@S_HOMEPAGE@/spdx/@S_PACKAGE@-@S_VERSION@-@MINIPSD_SBOM_VERIFICATION@", "creationInfo": { "created": "@MINIPSD_SBOM_CREATED@", "creators": [ @@ -13,12 +13,12 @@ }, "packages": [ { - "SPDXID": "SPDXRef-Package-minipsd", - "name": "minipsd", - "versionInfo": "@PROJECT_VERSION@", + "SPDXID": "SPDXRef-Package-@S_PACKAGE@", + "name": "@S_PACKAGE@", + "versionInfo": "@S_VERSION@", "supplier": "Person: Bartlomiej Mucha (shadobadev@gmail.com)", - "downloadLocation": "git+@PROJECT_HOMEPAGE_URL@.git@@PROJECT_VERSION@", - "homepage": "@PROJECT_HOMEPAGE_URL@", + "downloadLocation": "@S_DOWNLOAD@", + "homepage": "@S_HOMEPAGE@", "filesAnalyzed": true, "packageVerificationCode": { "packageVerificationCodeValue": "@MINIPSD_SBOM_VERIFICATION@" @@ -27,13 +27,13 @@ "licenseDeclared": "MIT", "licenseInfoFromFiles": [ "MIT" ], "copyrightText": "Copyright (c) 2026 Bartlomiej Mucha ", - "summary": "@PROJECT_DESCRIPTION@", - "comment": "Header-only: the files below, under include/, are the whole library.", + "summary": "@S_SUMMARY@", + "comment": "@S_COMMENT@", "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", - "referenceLocator": "pkg:github/ShadobaDev/minipsd@@PROJECT_VERSION@" + "referenceLocator": "pkg:github/ShadobaDev/minipsd@@S_VERSION@" } ] } @@ -44,7 +44,7 @@ { "spdxElementId": "SPDXRef-DOCUMENT", "relationshipType": "DESCRIBES", - "relatedSpdxElement": "SPDXRef-Package-minipsd" + "relatedSpdxElement": "SPDXRef-Package-@S_PACKAGE@" },@MINIPSD_SBOM_CONTAINS@ ] } diff --git a/cmake/minipsd_libvips.cmake b/cmake/minipsd_libvips.cmake new file mode 100644 index 0000000..edb3129 --- /dev/null +++ b/cmake/minipsd_libvips.cmake @@ -0,0 +1,76 @@ +# --------------------------------------------------------------------------- +# libvips for what uses it: minipsd's libvips plugin (libvips-plugin/, MINIPSD_VIPS_PLUGIN; or the +# plugin as released, minipsd-vips-.zip) and examples/vips_composite.c +# (MINIPSD_VIPS_EXAMPLE). minipsd itself never depends on libvips. +# +# Windows fetches libvips' official prebuilt "web" build, pinned (the newest release when set); +# elsewhere pkg-config finds the installed one. Gives: +# +# minipsd_libvips an imported target: libvips' C API, glib and gobject +# minipsd_libvips_zlib the zlib libvips links: Windows, the zip's libz1 (zlib-ng, built +# zlib-compatible; versions.json) with zlib's own headers, fetched and +# pinned, since the zip has none; elsewhere pkg-config's zlib +# MINIPSD_LIBVIPS_ENV the environment its tests run in (Windows: the DLLs on PATH), or "" +# +# A program with libvips of its own defines both targets before adding minipsd, visible to it (an +# INTERFACE library, or IMPORTED GLOBAL); nothing is then fetched or found here. A program that +# fetched libvips' Windows build itself sets FETCHCONTENT_SOURCE_DIR_MINIPSD_LIBVIPS to where it is +# unpacked: that one is used, and only zlib's headers are fetched. +# --------------------------------------------------------------------------- + +if(TARGET minipsd_libvips AND TARGET minipsd_libvips_zlib) + set(MINIPSD_LIBVIPS_ENV "") + return() +endif() + +if(WIN32) + set(MINIPSD_VIPS_VERSION 8.18.7) + include(FetchContent) + FetchContent_Declare(minipsd_libvips # named as its target: a program may call the plugin minipsd_vips + URL https://github.com/libvips/build-win64-mxe/releases/download/v${MINIPSD_VIPS_VERSION}/vips-dev-x64-web-${MINIPSD_VIPS_VERSION}.zip + URL_HASH SHA256=3a122eb3d588690008216f786338e2f9329dba3c3c2768b5933cf7299d549241 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + SOURCE_SUBDIR no-cmake-project) # files only: nothing to add as a subproject + FetchContent_MakeAvailable(minipsd_libvips) + set(_vips "${minipsd_libvips_SOURCE_DIR}") + add_library(minipsd_libvips INTERFACE IMPORTED) + if(MSVC) # the zip's import libraries + target_link_libraries(minipsd_libvips INTERFACE + "${_vips}/lib/libvips.lib" "${_vips}/lib/libglib-2.0.lib" "${_vips}/lib/libgobject-2.0.lib") + else() # MinGW links the DLLs themselves + target_link_libraries(minipsd_libvips INTERFACE + "${_vips}/bin/libvips-42.dll" "${_vips}/bin/libglib-2.0-0.dll" "${_vips}/bin/libgobject-2.0-0.dll") + endif() + target_include_directories(minipsd_libvips SYSTEM INTERFACE + "${_vips}/include" "${_vips}/include/glib-2.0" "${_vips}/lib/glib-2.0/include") + # zlib's headers for the zlib-ng the zip links: built zlib-compatible, it is zlib 1.3.1's ABI + # (libz1.dll of 8.18.7, zlib-ng 2.3.3, reports zlibVersion() "1.3.1.zlib-ng"; deflateInit_ + # checks the version's first digit and sizeof(z_stream)). zlib-ng's own headers are made when + # it is built (zlib.h.in, zconf.h.in), so no release has them to fetch; madler's 1.3.1 are + # the same API and layout. Bump them with libvips, to the zlib version its libz1 reports. + FetchContent_Declare(minipsd_zlib_headers + URL https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz + URL_HASH SHA256=9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + SOURCE_SUBDIR no-cmake-project) # zlib.h and zconf.h only + FetchContent_MakeAvailable(minipsd_zlib_headers) + add_library(minipsd_libvips_zlib INTERFACE IMPORTED) + target_include_directories(minipsd_libvips_zlib SYSTEM INTERFACE "${minipsd_zlib_headers_SOURCE_DIR}") + if(MSVC) + target_link_libraries(minipsd_libvips_zlib INTERFACE "${_vips}/lib/libz1.lib") + else() + target_link_libraries(minipsd_libvips_zlib INTERFACE "${_vips}/bin/libz1.dll") + endif() + set(_vips_path "${_vips}/bin;$ENV{PATH}") # the DLLs, for the tests + string(REPLACE ";" "\\;" _vips_path "${_vips_path}") + set(MINIPSD_LIBVIPS_ENV "PATH=${_vips_path}") +else() + find_package(PkgConfig REQUIRED) + pkg_check_modules(VIPS REQUIRED IMPORTED_TARGET vips) + add_library(minipsd_libvips INTERFACE IMPORTED) + target_link_libraries(minipsd_libvips INTERFACE PkgConfig::VIPS) + pkg_check_modules(VIPS_ZLIB REQUIRED IMPORTED_TARGET zlib) + add_library(minipsd_libvips_zlib INTERFACE IMPORTED) + target_link_libraries(minipsd_libvips_zlib INTERFACE PkgConfig::VIPS_ZLIB) + set(MINIPSD_LIBVIPS_ENV "") +endif() diff --git a/cmake/minipsd_spdx.cmake b/cmake/minipsd_spdx.cmake new file mode 100644 index 0000000..8aa7ad7 --- /dev/null +++ b/cmake/minipsd_spdx.cmake @@ -0,0 +1,57 @@ +# --------------------------------------------------------------------------- +# An SPDX 2.3 fragment describing one package and its files, for a consumer to merge into its own +# SBOM: minipsd's (CMakeLists.txt) and the released plugin zip's (tools/package_vips.cmake). +# Every file is listed with its checksums; SPDX requires SHA-1 per file and derives the package +# verification code from those (sorted, concatenated, hashed again). The timestamp honours +# SOURCE_DATE_EPOCH. Works in a project and in script mode (cmake -P). +# +# minipsd_spdx(OUTPUT PACKAGE VERSION HOMEPAGE DOWNLOAD +# SUMMARY COMMENT BASE [PREFIX ] FILES ) +# +# Each file is read from BASE/ and named ./PREFIX in the document. +# --------------------------------------------------------------------------- +set(_minipsd_spdx_template "${CMAKE_CURRENT_LIST_DIR}/minipsd.spdx.json.in") + +function(minipsd_spdx) + cmake_parse_arguments(PARSE_ARGV 0 S "" "OUTPUT;PACKAGE;VERSION;HOMEPAGE;DOWNLOAD;SUMMARY;COMMENT;BASE;PREFIX" "FILES") + set(_file [=[ + + { + "SPDXID": "SPDXRef-File-@_id@", + "fileName": "./@S_PREFIX@@_rel@", + "checksums": [ + { "algorithm": "SHA1", "checksumValue": "@_sha1@" }, + { "algorithm": "SHA256", "checksumValue": "@_sha256@" } + ], + "licenseConcluded": "MIT", + "licenseInfoInFiles": [ "MIT" ], + "copyrightText": "Copyright (c) 2026 Bartlomiej Mucha " + }]=]) + set(_contains [=[ + + { + "spdxElementId": "SPDXRef-Package-@S_PACKAGE@", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-File-@_id@" + }]=]) + set(_files "") + set(_relations "") + set(_sha1s "") + foreach(_rel IN LISTS S_FILES) + string(REGEX REPLACE "[^A-Za-z0-9.-]" "-" _id "${_rel}") + file(SHA1 "${S_BASE}/${_rel}" _sha1) + file(SHA256 "${S_BASE}/${_rel}" _sha256) + list(APPEND _sha1s "${_sha1}") + string(CONFIGURE "${_file}" _entry @ONLY) + list(APPEND _files "${_entry}") + string(CONFIGURE "${_contains}" _entry @ONLY) + list(APPEND _relations "${_entry}") + endforeach() + list(JOIN _files "," MINIPSD_SBOM_FILES) + list(JOIN _relations "," MINIPSD_SBOM_CONTAINS) + list(SORT _sha1s) + list(JOIN _sha1s "" _sha1s) + string(SHA1 MINIPSD_SBOM_VERIFICATION "${_sha1s}") + string(TIMESTAMP MINIPSD_SBOM_CREATED "%Y-%m-%dT%H:%M:%SZ" UTC) + configure_file("${_minipsd_spdx_template}" "${S_OUTPUT}" @ONLY) +endfunction() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5e2cd87..f83b3f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,77 @@ Changes between releases, for users of minipsd. Versioning follows the pre-1.0 rules in [TODO.md](TODO.md): breaking changes bump the MINOR, additions and fixes the PATCH. +## [0.4.0] — 2026-10-26 + +**Breaking:** `minipsd_doc.composite_alpha` now says, for every composite (given, composed from +the layers, or stored), whether it has transparency; before, a given or composed composite always +had it. 0, the default, is a composite of the colour channels only, a composed one matted against +white. To migrate, a document whose composite has transparency (most layered ones, and every +`composite` given as RGBA) sets `composite_alpha = 1`; nothing else changes. + +**Breaking:** the compression constants move up one, so that zero means the default everywhere: +`MINIPSD_COMPRESSION_DEFAULT` 0 (a document's RLE, a layer's the document's), `MINIPSD_RLE` 1, +`MINIPSD_RAW` 2, `MINIPSD_ZIP` 3, `MINIPSD_ZIP_PREDICTED` 4. Code that names them compiles as it +was; numbers written out (a binding, a stored setting) move. They are not the markers a file +stores (0 raw, 1 RLE, 2 ZIP, 3 ZIP with prediction). + +**Breaking:** `minipsd_read` gives transparency as the file has it: a layer without a +transparency channel has `no_alpha` set and its pixels are the colour channels only, and the +composite has the channels the file stores, `composite_alpha` saying whether one is transparency +(before, both came as RGBA, transparency made opaque). A file without layers gets its +"Background" layer the same way. To migrate, read a layer's pixels by `no_alpha`, the +composite's by `composite_alpha`. + +### Added +- Images without transparency: `minipsd_layer.no_alpha`, a layer whose pixels are its colour + channels only, written without a transparency channel and composed as opaque; with + `composite_alpha` 0, a document without layers is a flat file, as Photoshop saves an image that + has neither layers nor transparency. +- `minipsd_layer.compression`: a layer's channels, its mask's too, in a compression of their own + (0: the document's); the deflate is asked for whenever any layer deflates. +- Channels encoded by the program, row by row, for `stored` and `composite_stored`: + `minipsd_encode_row` (PackBits or raw, as the writer encodes a row), `minipsd_predict_row` (the + row `MINIPSD_ZIP_PREDICTED` deflates), `minipsd_matte_row` (a composite's row matted as the + writer mattes it), and `MINIPSD_ROW_BYTES`, `MINIPSD_ROW_BOUND` for their buffers. +- The libvips plugin (`libvips-plugin/`, `MINIPSD_VIPS_PLUGIN`, the static library + `minipsd::vips`): minipsd as libvips' loader and saver of PSD and PSB, registered with + `minipsd_vips_init()` after `VIPS_INIT`. `psdload` gives the composite or, with `layer`, one + layer placed by its offset, decoded in bands of rows from libvips' threads; grey, RGB, CMYK, + bitmap, indexed (through its palette), Lab and multichannel at every depth, with the + resolution, ICC profile, EXIF, XMP, IPTC and `psd-*` fields: a layer's name, opacity, + visibility, blend mode and clipping, its mask (an image in `psd-layer-mask`), a group's + records (`psd-layer-section`), and a record without pixels as one transparent pixel with + `psd-layer-empty`, so every record loads. `psdsave` writes an image with + alpha as one layer and its composite, one without as a flat file (with `layered`, a layer + without transparency too), reading the image once, in bands, each channel encoded as its rows + come: memory about the file's size. Options: `compression` (`rle`, `zip`, `zip-predicted`, + `none`), `psb`, `layer_name`, `layered`; a PSB by itself when a PSD cannot hold the file. +- `psdsave_layers` (`_buffer`, `_target`; `minipsd_vips_psdsave_layers` in C): several images as + the layers of one document, bottom first, each record's look from the same `psd-layer-*` + fields, masks and groups included, so a file's records loaded with `psdload` and saved come + back. The composite is given, or composed by `vips_composite` (blend modes, opacity, masks, + groups' visibility and opacity); the canvas by `width`, `height`, `left`, `top`. +- The plugin released as `minipsd-vips-X.Y.Z.zip`: a CMake project that builds as it is, with + minipsd's single header private to it, an example that is its test, and its SBOM fragment + (`minipsd-vips.spdx.json`, `MINIPSD_SBOM_FILE` on `minipsd::vips`). + +### Fixed +- A crafted file could make `minipsd_read` and the decode calls write past their buffers: a + mask's rectangle was not bounded, so the size of its plane could wrap around 64 bits. A + channel with a side above 300 000 is now `MINIPSD_ERR_UNSUPPORTED`, and `minipsd_read` bounds a + mask's sides as a layer's. +- An RLE row that packs to more than 65 535 bytes (32-bit samples that do not pack, wider than + about 16 380 px) went into a PSD with a wrong row count, and no error: now + `MINIPSD_ERR_SIZE`, the layer's or the composite's. A PSB takes it. +- `minipsd_write` encoded a long row again for each 4 KiB it was cut into: its buffer now holds + the widest row, so each row is cut at most once (30 000 x 400 RGBA noise: 1406 ms to 796 ms, + the same bytes). +- A composite's transparency is now named where the layer count cannot say it (a 32-bit + document, whose layers go in `Lr32` with a positive count, and a document with no layers): 1006, + 1045 and 1053 "Transparency", as Photoshop writes them, unless the document keeps names of its + own. GIMP showed it as an extra channel. A document with no layers also gets an empty `Mtrn` + (`Mt16`, `Mt32`): its transparency was lost before. + ## [0.3.1] — 2026-10-24 ### Added diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 9cdfefe..22c7c74 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -114,9 +114,9 @@ cpack --config build/package/CPackConfig.cmake -G DEB Check and install the `.deb`: ```bash -dpkg-deb -I dist/libminipsd-dev_0.2.0_all.deb # metadata -dpkg-deb -c dist/libminipsd-dev_0.2.0_all.deb # files -sudo apt install ./dist/libminipsd-dev_0.2.0_all.deb +dpkg-deb -I dist/libminipsd-dev_0.4.0_all.deb # metadata +dpkg-deb -c dist/libminipsd-dev_0.4.0_all.deb # files +sudo apt install ./dist/libminipsd-dev_0.4.0_all.deb sudo apt remove libminipsd-dev ``` @@ -136,6 +136,7 @@ sudo tar -xzf dist/minipsd-0.1.0.tar.gz --strip-components=1 -C /usr/local | `minipsd-cxx17` (build target) | the header compiles warning-free as C++17 | | `minipsd-include-check` (build target) | `minipsd.h` compiles alone, then with its implementation, leaving no internal function unused | | `psd-tools-roundtrip` | documents written through minipsd, read back by `psd-tools` | +| `vips.plugin` | with `MINIPSD_VIPS_PLUGIN`: documents and every file at hand loaded through libvips (composite and layers) against minipsd's decode, saved and read back, a tall image read in bands, layered documents saved with `psdsave_layers` (masks, groups, the composite composed) | | `install` | `cmake --install` into the build tree (`tests/prefix/`) | | `consumer-copy` | a project with `include/` copied in | | `consumer-subdirectory` | `add_subdirectory` (git submodule) | @@ -154,6 +155,7 @@ paths also check the SBOM fragment's version and that it lists the headers. | `MINIPSD_INSTALL` | ON only as the top-level project | install rules | | `MINIPSD_REQUIRE_PSD_TOOLS` | OFF (CI: ON) | fail the configure instead of skipping the round trip | | `MINIPSD_VIPS_EXAMPLE` | OFF (CI: ON in `msvc`) | build and run `examples/vips_composite.c`: on Windows fetch libvips' prebuilt "web" build (8.18.7, 11 MB, pinned by hash), elsewhere find libvips with pkg-config; with `tests/fixtures/Test-g-example.psd` present, also on that file | +| `MINIPSD_VIPS_PLUGIN` | OFF (CI: ON in `msvc`) | build `minipsd::vips`, the libvips plugin (`libvips-plugin/`), and with the tests `vips.plugin`; libvips as for `MINIPSD_VIPS_EXAMPLE`, plus its zlib (on Windows zlib 1.3.1's headers, pinned by hash) | | `MINIPSD_PSD_TOOLS_FILES` | OFF (CI: ON in `linux-gcc`) | fetch psd-tools' 315 test files, 278 PSD and 37 PSB (v1.25.0, about 22 MB, pinned by hash) and read every one | | `MINIPSD_TEST_FIXTURES` | ON (`linux-coverage`: OFF) | read the local PSDs in `tests/fixtures/` too; off, a local coverage run measures what CI's does | | `MINIPSD_CLANG_TIDY` | empty (`linux-tidy`: `clang-tidy`) | run clang-tidy on the test targets | diff --git a/docs/CODE-ARCH.md b/docs/CODE-ARCH.md index 5b86972..7366fac 100644 --- a/docs/CODE-ARCH.md +++ b/docs/CODE-ARCH.md @@ -115,12 +115,26 @@ examples/ the I/O implementations; built and run in CI, nev write_nonblocking.c O_NONBLOCK pipe + poll (POSIX) read_file.c whole file into memory, described, every layer and the composite decoded resave.c minipsd_read with stored, one layer changed, written back: the round trip +libvips-plugin/ minipsd as libvips' loader and saver (psdload, psdsave, psdsave_layers), registered by the program + minipsd_vips.h the public header: minipsd_vips_init, MinipsdVipsCompression, minipsd_vips_psdsave_layers + minipsd_vips.c minipsd_vips_init: registers both sides with libvips + foreign/ the formats' code, as libvips lays out its own; names the plugin's (MinipsdVips*) + psdload.c psdload, _buffer, _source; minipsd's reader compiled in, private + psdsave.c psdsave and psdsave_layers, each _buffer, _target; minipsd's writer compiled in, private + psd.c, psd.h internal: constants, helpers and the types both sides share + test_vips.c documents and files loaded and saved through libvips, against minipsd's decode +cmake/ + minipsd_libvips.cmake libvips and its zlib for the example and the plugin: fetched on Windows, else pkg-config + minipsd_spdx.cmake the SPDX fragment generator: minipsd's, and the plugin zip's tests/ test_.cpp one file per module; detail modules are tested directly fuzz/ the reader's libFuzzer target, run locally (linux-fuzz preset); replay.c runs it on regressions/, the inputs that once failed tools/ amalgamate.cmake joins include/ into one file, a release artifact + package_vips.cmake packs the plugin as released: minipsd-vips-X.Y.Z.zip, its SBOM fragment inside + check_spdx.cmake checks an SPDX fragment against its files (checksums, every file listed) + compat_report.py regenerates COMPATIBILITY.md ``` | Module (section of `minipsd.h`) | Struct | Functions (sketch) | Implementation | @@ -214,9 +228,11 @@ int minipsd_writer_fill (minipsd_writer *w, void *buf, size_t cap, size_t * - **No staging buffer.** `fill` produces the current piece again from its start, through a sink that copies only the bytes not yet handed out and only as many as fit. A piece is one row at most (the ICC profile is copied, not re-encoded), so a call that stops inside a row costs one - more encoding of that row: nothing with a buffer of a few rows, quadratic in the row only for - tiny caps. The composite row being produced is blended once and kept. A staging buffer would - remove that cost at the price of workspace; it is left out until a caller needs tiny caps. + more encoding of that row: nothing with a buffer of at least the widest row's + `MINIPSD_ROW_BOUND`, which `minipsd_write` allocates; quadratic in the row for smaller caps + (4 KiB cut a 32-bit PSB row of 300 000 px about 290 times). The composite row being produced + is blended once and kept. A staging buffer would remove that cost at the price of workspace; + it is left out until a caller needs tiny caps. - Any `cap >= 1` works, so a caller with a 1-byte buffer still gets the whole file. - A non-blocking caller writes as much of `buf` as the target accepts and keeps the rest for the next round. The library never needs to know about partial writes. @@ -539,6 +555,12 @@ int composite_matted; /* non-zero: composite is already matted, wri layers, and the writer mattes it. Only `composite_matted` skips it: `minipsd_read` sets it, because un-matting and matting again drift where alpha is low (8-bit matting merges nearby colours), and a file's own composite must come back pixel for pixel. +- **Transparency is the document's to say** (`composite_alpha`, 0.4.0): every composite, + supplied, composed or stored, has transparency only when it is set; without it the composite + is the colour channels alone (composed: matted against white), as Photoshop writes an image + without layers or transparency. A layer's own is `no_alpha`. Before 0.4.0 a supplied or + composed composite always had transparency, which a program saving an opaque image could not + avoid. - **Workspace.** A supplied composite is read row by row like a layer; the composed one keeps its one row in the workspace, as now. - **Kept (`composite_stored`, when `composite == NULL`): a file's own, copied.** The composite as @@ -643,7 +665,8 @@ typedef struct minipsd_read_opts { - **ZIP written** (`compression` `MINIPSD_ZIP`, `_PREDICTED`, with `minipsd_write_opts.deflate`): a channel's size is in its record before its data, and the writer never seeks back, so `mpsd__measure_layers` deflates each channel once and the `MPSD__ZIP` piece once more, as RLE - rows are packed twice. The workspace holds the largest channel's raw rows (gathered big-endian, + rows are packed for their size before they are written (three times a layer row: its size + while measuring, then its count and the row; the composite's sizes are kept, so twice). The workspace holds the largest channel's raw rows (gathered big-endian, predicted in place) and its stream; the stream is kept while a piece of it is handed out in parts, and a second stream of another size is `MINIPSD_ERR_WRITE`. The composite stays RLE. - **Limits:** `minipsd_limits` as in the reader; `max_decoded_bytes` bounds the sum of the call's @@ -730,7 +753,7 @@ commit message. [ROADMAP.md](ROADMAP.md) turns these into numbered steps. | **4. Reader** | `reader.h` for the own subset, then real-application files; fuzzing | 0.2.1 | | **5. Round trip** | layer rectangles in the writer, a supplied composite, full import, kept unknown content, pass-through | 0.2.x | | **6. Features** | one row of the table above per stage, writer and reader together | 0.2.x | -| **7. Interoperability** | libvips: an example and a MANUAL chapter | 0.2.x | +| **7. Interoperability** | libvips: an example, a MANUAL chapter, and the plugin (`psdload`, `psdsave`, `psdsave_layers`) | 0.3.x, 0.4.0 | Every stage follows these rules: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 86011a7..be88ca0 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -8,7 +8,7 @@ output is covered by the golden test and the `psd-tools` round trip (see the spe [Verification](SPECIFICATION.md#verification)); this page is about everyone else's files. This page is generated by [tools/compat_report.py](../tools/compat_report.py) with minipsd -0.3.1, over 315 files of psd-tools v1.25.0. +0.4.0, over 315 files of psd-tools v1.25.0. ## Credits diff --git a/docs/GITHUB-RELEASE.md b/docs/GITHUB-RELEASE.md index 222b3eb..ebada9b 100644 --- a/docs/GITHUB-RELEASE.md +++ b/docs/GITHUB-RELEASE.md @@ -12,12 +12,17 @@ Pushing the tag starts the [`Release`](../.github/workflows/release.yml) workflo 2. a check that the tag equals the `MINIPSD_VERSION_*` macros in `include/minipsd.h` and that `docs/CHANGELOG.md` has a section for that version; 3. `minipsd.h` as a single file: `include/` joined by `tools/amalgamate.cmake`; -4. a build-provenance attestation for that file; -5. a GitHub Release with `minipsd.h` and `minipsd.h.sha256`, its notes taken from the changelog - section. - -There is nothing to build for consumers: the source tree at the tag *is* the release. The two -assets are for users who prefer one file to copying `include/`. +4. the libvips plugin as `minipsd-vips-X.Y.Z.zip`, a CMake project that builds as it is + (`tools/package_vips.cmake`), with its SBOM fragment, then checked against that fragment + (`tools/check_spdx.cmake`) and built from the zip alone against Ubuntu's libvips, its + example run: a zip that does not build is not published; +5. a build-provenance attestation for `minipsd.h` and the zip; +6. a GitHub Release with `minipsd.h`, the zip and a `.sha256` for each, its notes taken from the + changelog section. + +There is nothing to build for consumers: the source tree at the tag *is* the release. `minipsd.h` +is for users who prefer one file to copying `include/`; the zip, for those who want the plugin +without the repository. CI packs and builds the zip on every push too, so it fails there first. ## 0. Pre-flight @@ -47,8 +52,10 @@ notes come from `docs/CHANGELOG.md`, not from the tag. ## 3. Verify - **Actions** → the Release run is green. -- **Releases** → `0.1.0` has `minipsd.h`, `minipsd.h.sha256` and the changelog notes. -- Provenance: `gh attestation verify minipsd.h --repo ShadobaDev/minipsd`. +- **Releases** → `0.1.0` has `minipsd.h`, `minipsd-vips-0.1.0.zip`, a `.sha256` for each, and + the changelog notes. +- Provenance: `gh attestation verify minipsd.h --repo ShadobaDev/minipsd`, and the same for the + zip. - A FetchContent consumer pinned to `GIT_TAG 0.1.0` configures and builds. ## 4. Then the consumers diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e06c81f..f37de5d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -13,7 +13,7 @@ attaches the same headers joined into one `minipsd.h`, for those who prefer a si include(FetchContent) FetchContent_Declare(minipsd GIT_REPOSITORY https://github.com/ShadobaDev/minipsd.git - GIT_TAG 0.2.0) + GIT_TAG 0.4.0) FetchContent_MakeAvailable(minipsd) target_link_libraries(my-app PRIVATE minipsd::minipsd) ``` @@ -21,11 +21,11 @@ target_link_libraries(my-app PRIVATE minipsd::minipsd) **[CPM.cmake](https://github.com/cpm-cmake/CPM.cmake)** ```cmake -CPMAddPackage("gh:ShadobaDev/minipsd#0.2.0") +CPMAddPackage("gh:ShadobaDev/minipsd#0.4.0") target_link_libraries(my-app PRIVATE minipsd::minipsd) ``` -Use `#0.2.0`, not `@0.2.0`: the `@` form looks for a tag `v0.2.0`, and minipsd tags have no `v`. +Use `#0.4.0`, not `@0.4.0`: the `@` form looks for a tag `v0.4.0`, and minipsd tags have no `v`. **git submodule** @@ -39,6 +39,9 @@ target_link_libraries(my-app PRIVATE minipsd::minipsd) As a subproject, minipsd builds no tests, fetches nothing and enables no language. +The libvips plugin, minipsd as libvips' loader and saver, comes its own way: +[The libvips plugin](#the-libvips-plugin) below. + **Installed package.** minipsd installs as a tree with nothing platform-specific in it: ``` @@ -59,11 +62,11 @@ You can get that tree in any of these ways: - **As an archive**, `minipsd-X.Y.Z.tar.gz` or `.zip`: use it unpacked where it is, or extract it into a prefix. The tree is relocatable, so nothing needs regenerating: ```bash - sudo tar -xzf minipsd-0.2.0.tar.gz --strip-components=1 -C /usr/local + sudo tar -xzf minipsd-0.4.0.tar.gz --strip-components=1 -C /usr/local ``` - **As a Debian/Ubuntu package**, `libminipsd-dev_X.Y.Z_all.deb`: ```bash - sudo apt install ./libminipsd-dev_0.2.0_all.deb + sudo apt install ./libminipsd-dev_0.4.0_all.deb ``` The archive and the `.deb` are built with CPack, see @@ -84,3 +87,71 @@ own SBOM: ```cmake get_target_property(minipsd_sbom minipsd::minipsd MINIPSD_SBOM_FILE) ``` + +## The libvips plugin + +The plugin makes minipsd libvips' loader and saver of PSD and PSB: it reads a file's composite or +any layer, and writes one image or several as the layers of one document (the manual's +[PSD in libvips](MANUAL.md#psd-in-libvips-the-plugin)). It is built from source, as part of your +program, against your libvips' headers: its classes extend libvips' `VipsForeignLoad` and +`VipsForeignSave`, so a compiled copy holds only while those keep the layout it was built +against (they are the same in libvips 8.15.1 and 8.18.7; a field added to either breaks it). +Link `minipsd::vips`, then call `minipsd_vips_init()` after `VIPS_INIT`. + +**From the release.** Each GitHub Release has `minipsd-vips-X.Y.Z.zip` (and its `.sha256`): the +plugin as a CMake project of its own, with minipsd's single header inside it, private to the +plugin, so a program's own minipsd, of any version, is left alone: + +```cmake +include(FetchContent) +FetchContent_Declare(minipsd_vips + URL https://github.com/ShadobaDev/minipsd/releases/download/0.4.0/minipsd-vips-0.4.0.zip + URL_HASH SHA256=) +FetchContent_MakeAvailable(minipsd_vips) +target_link_libraries(my-app PRIVATE minipsd::vips) +``` + +`add_subdirectory` of the unpacked zip does the same. On its own, the zip builds and tests its +example (`cmake -S`, `cmake --build`, `ctest`); without CMake, its README says what to compile. +The zip carries its own SBOM fragment, `minipsd-vips.spdx.json` (SPDX 2.3, every other file in the +zip with its checksums, minipsd's single header among them), found as minipsd's is: +`get_target_property(sbom minipsd::vips MINIPSD_SBOM_FILE)`. + +**From minipsd's source.** In any of the ways at the top, turn on `MINIPSD_VIPS_PLUGIN` before +adding minipsd: + +```cmake +set(MINIPSD_VIPS_PLUGIN ON) +FetchContent_MakeAvailable(minipsd) +target_link_libraries(my-app PRIVATE minipsd::vips) +``` + +**Where libvips comes from.** Either way the plugin enables C and, unless told otherwise, finds +libvips itself ([cmake/minipsd_libvips.cmake](../cmake/minipsd_libvips.cmake)): + +- **Linux, macOS:** pkg-config, the system's libvips and zlib. Nothing is fetched. +- **Windows:** libvips' official prebuilt build, `vips-dev-x64-web`, pinned, and zlib's headers + (zlib 1.3.1, pinned, about 1.5 MB), which that build lacks; the plugin links the zlib the + build carries, `libz1`, for ZIP channels. That one is zlib-ng built zlib-compatible, and + reports itself as zlib 1.3.1 (`1.3.1.zlib-ng`), so zlib 1.3.1's headers describe it; zlib-ng's + own are made when it is built, and no release carries them. + +A program that fetches libvips' Windows build itself points the plugin at it, before adding the +plugin. Then one libvips is fetched, and the plugin is compiled against the headers of the DLLs +the program ships: + +```cmake +# after the program's own FetchContent_MakeAvailable(my_vips) of vips-dev-x64-*.zip +FetchContent_GetProperties(my_vips SOURCE_DIR my_vips_dir) +set(FETCHCONTENT_SOURCE_DIR_MINIPSD_LIBVIPS "${my_vips_dir}") # the plugin takes this libvips +FetchContent_MakeAvailable(minipsd_vips) # or minipsd, with MINIPSD_VIPS_PLUGIN +``` + +The directory is libvips' Windows build unpacked as libvips releases it: the plugin takes +`include/`, `lib/` (the import libraries MSVC links) and `bin/` (the DLLs MinGW links) from it. +zlib's headers are still fetched: `zlib.h` is always there, whichever libvips is used. + +A program whose libvips comes from anywhere else defines two targets before adding the plugin, +and nothing is fetched or found: `minipsd_libvips` (libvips, glib, gobject) and +`minipsd_libvips_zlib` (zlib's headers and the zlib that libvips links), each an `INTERFACE` +library or `IMPORTED GLOBAL`. diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 21253a1..8e0559d 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -8,7 +8,7 @@ complete programs here are in [examples/](../examples/), which CI builds and run | Chapter | Covers | |---|---| | [Getting started](#getting-started) | adding minipsd, a PSD written and read in a page of code | -| [Writing](#writing) | the order of calls; to memory, to a blocking stream, to a non-blocking one | +| [Writing](#writing) | the order of calls; to memory, to a blocking stream, to a non-blocking one; channels encoded by you | | [Reading](#reading) | the order of calls; a file read in parts; pixels; the composite; untrusted files | | [Round trip](#round-trip) | a file read into a document, changed, written back; what is kept, what is lost | | [Memory and ownership](#memory-and-ownership) | what you allocate, how long it must live | @@ -49,7 +49,8 @@ int main(void) static uint8_t rgba[64 * 64 * 4]; /* straight alpha, rows top down */ /* Fields left out are zero, the defaults: visible, the full canvas, RLE. */ minipsd_layer layer = {.name = "Hello", .pixels = rgba, .opacity = 255}; - minipsd_doc doc = {.width = 64, .height = 64, .dpi = 72.0, .layers = &layer, .layer_count = 1}; + minipsd_doc doc = {.width = 64, .height = 64, .dpi = 72.0, .layers = &layer, .layer_count = 1, + .composite_alpha = 1}; /* composed from the layer, with its transparency */ FILE *f; int i, rc; @@ -140,8 +141,8 @@ In the diagrams, solid is what you write or call; dashed is what minipsd does. **One call, `minipsd_write`.** You write a callback and call `minipsd_write`; minipsd calls the callback with each piece of the file, in order, and returns when the whole file has gone -through it. A piece is at most 4 KiB, the buffer `minipsd_write` keeps on its stack; it has -nothing to do with the target. A callback that returns 0 stops the write with +through it. A piece is at most the buffer `minipsd_write` allocates with its workspace: 4 KiB, +or the widest row's `MINIPSD_ROW_BOUND` when that is more; it has nothing to do with the target. A callback that returns 0 stops the write with `MINIPSD_ERR_WRITE`. ```mermaid @@ -180,11 +181,21 @@ minipsd_layer layers[2] = { { .name = "Template", .pixels = rgba, .opacity = 255 }, /* width * height * 4, straight alpha */ { .name = "Layer", .opacity = 255 }, /* an empty layer above it */ }; -minipsd_doc doc = { .width = width, .height = height, .dpi = 300.0, .layers = layers, .layer_count = 2 }; +minipsd_doc doc = { .width = width, .height = height, .dpi = 300.0, .layers = layers, .layer_count = 2, + .composite_alpha = 1 }; /* the composite with the layers' transparency */ ``` Name the fields. The structs only grow at the end, and every field left out is zero, which is -the default: visible, RLE, and a layer that covers the full canvas. A layer of another size sets +the default: visible, RLE, and a layer that covers the full canvas. + +`composite_alpha` says whether the composite has transparency, whichever composite it is: given, +composed from the layers, or stored. With it, as most layered documents want, the composite keeps +the layers' transparency. Without it (0, the default), the composite holds the mode's colour +channels only and a composed one is matted against white, as an image without transparency is. A +layer whose pixels have no transparency sets `no_alpha`: its `pixels` are then the colour channels +alone, and it is written without a transparency channel. A document without layers, its +`composite` given and `composite_alpha` 0, is a flat file, the way Photoshop saves an image that +has neither layers nor transparency. A layer of another size sets its rectangle on the canvas, which may reach past the edges; `pixels` then holds that rectangle: ```c @@ -215,7 +226,7 @@ a `uint16_t` in host order, 0 to 65535; the pointers keep their type, so cast: uint16_t *deep = malloc((size_t)width * height * 4 * sizeof *deep); /* straight alpha */ minipsd_layer layer = { .name = "Deep", .pixels = (const uint8_t *)deep, .opacity = 255 }; minipsd_doc doc = { .width = width, .height = height, .dpi = 300.0, .layers = &layer, .layer_count = 1, - .depth = 16 }; + .depth = 16, .composite_alpha = 1 }; ``` Another colour mode sets `color_mode` (`MINIPSD_MODE_*`). A pixel is then the mode's colour @@ -227,7 +238,7 @@ uint8_t *cmyka = malloc((size_t)width * height * 5); /* C, M, Y, K, trans uint8_t *flat = malloc((size_t)width * height * 5); /* the same, rendered by you */ minipsd_layer ink = { .name = "Ink", .pixels = cmyka, .opacity = 255 }; minipsd_doc doc = { .width = width, .height = height, .dpi = 300.0, .layers = &ink, .layer_count = 1, - .composite = flat, .color_mode = MINIPSD_MODE_CMYK }; + .composite = flat, .composite_alpha = 1, .color_mode = MINIPSD_MODE_CMYK }; ``` RGB and grey also come at 32 bits (`depth` 32), every sample a `float` from 0.0 to 1.0, again with @@ -267,8 +278,9 @@ rc = minipsd_write(doc, &as_psb, to_file, f); /* a PSD read from disk, written Going the other way, a PSB that a PSD cannot hold is `MINIPSD_ERR_SIZE`, and the few blocks whose translation is not verified yet are `MINIPSD_ERR_UNSUPPORTED`, both before a byte is written. -Channels are PackBits RLE by default, or raw. ZIP, as Photoshop writes its 16-bit layers, needs a -deflate of yours, zlib's here, since minipsd implements none; the composite stays RLE: +Channels are PackBits RLE by default (`MINIPSD_RLE`, or 0), or raw (`MINIPSD_RAW`). ZIP, as +Photoshop writes its 16-bit layers, needs a deflate of yours, zlib's here, since minipsd +implements none; the composite stays RLE (raw in a raw document): ```c static int deflate_zlib(void *user, const uint8_t *src, size_t src_size, uint8_t *dst, size_t *dst_size) @@ -286,7 +298,12 @@ rc = minipsd_write(&doc, &zip, to_file, f); ``` Each channel is deflated twice, once to size it and once to write it, so the same input must give -the same bytes, as zlib's do. +the same bytes, as zlib's do. A layer may have a compression of its own, `compression` (0: the +document's); one that deflates asks for the deflate as the document's would: + +```c +logo.compression = MINIPSD_RAW; /* this layer raw, the others as doc.compression */ +``` The composite, the flattened image that viewers and thumbnails show, is yours to render when the layers use anything beyond plain "over" (libvips does most of it, see @@ -294,7 +311,8 @@ the layers use anything beyond plain "over" (libvips does most of it, see full canvas; the writer mattes it as the file stores it: ```c -doc.composite = flattened; /* width * height * 4, straight alpha; NULL: composed, a best effort */ +doc.composite = flattened; /* width * height * 4, straight alpha; NULL: composed, a best effort */ +doc.composite_alpha = 1; /* 0: width * height * 3, the colour channels only */ ``` Without one, the writer composes the visible layers with "over" at their opacity: enough for a @@ -329,8 +347,8 @@ is out: the writer sized everything from them at the start. ### To a blocking stream -`minipsd_write` drives the pull writer to the end and hands each piece, up to 4 KiB, to the -callback; a callback that returns 0 stops it with `MINIPSD_ERR_WRITE`. A file is +`minipsd_write` drives the pull writer to the end and hands each piece, up to 4 KiB or the +widest row, to the callback; a callback that returns 0 stops it with `MINIPSD_ERR_WRITE`. A file is [Write a PSD](#write-a-psd) above. A blocking descriptor, such as a pipe or a socket, may take less than it is given, so its callback loops: @@ -412,9 +430,43 @@ else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { /* the target faile ``` `minipsd_writer_total` gives the size up front, for a `Content-Length` header or a progress bar. -The whole program, an `O_NONBLOCK` pipe driven by `poll` with its reading end in the same loop: +A call that stops inside a row encodes it again from its start in the next call, so a buffer of +at least `MINIPSD_ROW_BOUND(width, depth)` for the widest row cuts each row at most once. The +whole program, an `O_NONBLOCK` pipe driven by `poll` with its reading end in the same loop: [examples/write_nonblocking.c](../examples/write_nonblocking.c). +### Channels encoded by you + +`minipsd_write` takes the pixels whole and encodes each row twice, once to size it and once to +write it. A program that reads its image once, top to bottom (a decoder, a libvips pipeline), or +that would rather hold it compressed, encodes each channel itself, row by row as the rows come, +and gives minipsd the channel as the file stores it: `layer.stored` for a layer's channels, +`doc.composite_stored` for the composite. minipsd copies those as they are. Three calls do to a +row what the writer does: + +| Call | What it makes of a row | +|---|---| +| `minipsd_encode_row` | PackBits (`MINIPSD_RLE`), or raw (`MINIPSD_RAW`), big-endian | +| `minipsd_predict_row` | the row that `MINIPSD_ZIP_PREDICTED` deflates; plain ZIP deflates the raw rows | +| `minipsd_matte_row` | a composite's row matted against white, as the writer mattes a given composite (RGB) | + +```c +uint8_t row[MINIPSD_ROW_BOUND(WIDTH, 8)]; /* the most one row encodes to */ +size_t n; +for (y = 0; y < height; ++y) { /* red, out of RGBA rows as they come */ + minipsd_encode_row(MINIPSD_RLE, 8, rgba + (size_t)y * WIDTH * 4, 4, WIDTH, row, sizeof row, &n); + /* keep row[0 .. n) and n: the row, and its size for the table */ +} +``` + +A stored channel is its compression marker (two bytes, big-endian: 0 raw, 1 RLE, 2 ZIP, 3 ZIP +with prediction: the file's numbers, not the `MINIPSD_*` constants), then, for RLE, every row's +size (two bytes in a PSD, four in a PSB), then the rows; for ZIP, the zlib stream of every row. +The composite's is one block: one marker, every channel's row sizes, then every channel's rows. A +PSD's row sizes are 16-bit, so a row longer than 65 535 bytes needs a PSB. The libvips plugin's +`psdsave` saves this way, one top-to-bottom pass in bands: +[libvips-plugin/foreign/psdsave.c](../libvips-plugin/foreign/psdsave.c). + ## Reading The reader describes a file and decodes its pixels, layers and composite. It works on bytes you @@ -655,6 +707,12 @@ malformed file: The byte limit is per call, so a layer larger than it can still be decoded in bands of rows. +`minipsd_read` takes the same limits, its byte limit over the whole document. Without them it +allocates what the file asks for, and a small file can ask for a lot: PackBits packs up to 64 to +1, deflate about a thousand to 1. For a file you do not trust, set `max_decoded_bytes`, or call +`minipsd_read_size` first, compare its answer with your budget, then `minipsd_read_into` into +memory of yours. + ## Round trip `minipsd_read` is the mirror of `minipsd_write`: a whole file in, a `minipsd_doc` out, which @@ -673,7 +731,10 @@ minipsd_doc_free(doc); Every colour mode and depth the writer takes comes through, with its colour mode data (a palette, a duotone's inks); a file it could not take back, such as 32-bit CMYK, is -`MINIPSD_ERR_UNSUPPORTED`. +`MINIPSD_ERR_UNSUPPORTED`. Transparency comes as the file has it: a layer without a transparency +channel has `no_alpha` set and its pixels are the colour channels only, and the composite has the +channels the file stores, `composite_alpha` saying whether one of them is transparency, so +writing the document back keeps both as they were. The document lives in one block: from `MINIPSD_MALLOC`, freed by `minipsd_doc_free`, or in memory of yours with `minipsd_read_size` and `minipsd_read_into`. Nothing in it points into the @@ -759,7 +820,9 @@ made it (the program, and the feature you used): that is what sets the order of minipsd writes and reads PSD; it renders nothing. When your layers are libvips images, let libvips flatten them and hand minipsd the result as the document's composite, the image a -viewer that ignores layers shows. The two share their memory layout (8-bit RGBA, straight +viewer that ignores layers shows. The plugin's `psdsave_layers` does all of it for you +([PSD in libvips: the plugin](#psd-in-libvips-the-plugin)); below is the same with minipsd's own +API. The two share their memory layout (8-bit RGBA, straight alpha, rows top down), so nothing is converted. [examples/vips_composite.c](../examples/vips_composite.c) is the whole program; its steps: @@ -770,8 +833,8 @@ alpha, rows top down), so nothing is converted. (`vips_linear` on the alpha band), hidden layers and group markers left out, blend keys mapped by the table in [Working with libvips](SPECIFICATION.md#working-with-libvips). Modes without a counterpart, masks, clipping and effects are yours to draw. -3. **Written by minipsd.** The render goes into `doc.composite`, `composite_matted` 0: the writer - mattes it as the format wants. +3. **Written by minipsd.** The render goes into `doc.composite`, `composite_alpha` 1 (it has + transparency), `composite_matted` 0: the writer mattes it as the format wants. 4. **Read back** (the example's check): `minipsd_decode_composite` into a buffer that `vips_image_new_from_memory` wraps without a copy gives the render again, exactly where alpha is full and within the un-matting bound elsewhere. @@ -786,10 +849,101 @@ doc.composite = vips_image_write_to_memory(out, &size); /* width * height * 4, ``` libvips computes lazily, in strips; minipsd takes a whole layer and a whole composite, so each is -materialised before writing. Reading, bands of rows (`minipsd_decode_opts`) fit libvips' strips. +materialised before writing, unless you encode the channels yourself as the strips come +([Channels encoded by you](#channels-encoded-by-you)), as the plugin's `psdsave` does. Reading, bands of rows (`minipsd_decode_opts`) fit libvips' strips. Build the example with `-DMINIPSD_VIPS_EXAMPLE=ON`: on Windows CMake fetches libvips' prebuilt build, elsewhere pkg-config finds yours. minipsd itself never depends on libvips. +### PSD in libvips: the plugin + +[libvips-plugin/](../libvips-plugin/) makes minipsd libvips' loader and saver of PSD and PSB. In +short, it: + +- **reads** a file's composite, or any one of its layers, with the layer's name, opacity, + visibility, blend mode, clipping, its mask, and the records of its groups; +- **writes** one image (`psdsave`: one layer and its composite, or a flat file), or several + images as the layers of one document (`psdsave_layers`), with masks and groups, the composite + given or composed by libvips. + +Pixels stay libvips images throughout; minipsd encodes and decodes them. libvips' prebuilt +Windows builds load no modules, so your program registers the operations, once, after +`VIPS_INIT`: + +```c +#include "minipsd_vips.h" + +if (VIPS_INIT(argv[0])) vips_error_exit(NULL); +minipsd_vips_init(); +VipsImage *page = vips_image_new_from_file("page.psd", NULL); /* the composite */ +VipsImage *third = vips_image_new_from_file("page.psd", "layer", 2, NULL); /* the third layer up */ +vips_image_write_to_file(page, "copy.psd", "layer_name", "Page", NULL); +``` + +- **Loading** (`psdload`, `psdload_buffer`, `psdload_source`): the composite, or with `layer` one + layer, bottom first, placed by `xoffset` and `yoffset`. Grey, RGB and CMYK come as libvips + names them (CMYK with 0 for no ink, the other way round from PSD), bitmap as `B_W` (black 0), + indexed through its palette as sRGB, Lab and multichannel as `MULTIBAND`, each at the file's + depth. With it: the resolution, the ICC profile, EXIF, XMP, IPTC, `bits-per-sample`, and + `psd-*` fields: `psd-layer-count`, and a layer's look in `psd-layer-*` (the list: the spec's + [The plugin](SPECIFICATION.md#the-plugin)). A layer without pixels (an empty one, a group's + record, an adjustment) comes as one transparent pixel with `psd-layer-empty`; a group's record + also has `psd-layer-section`; a layer's mask is an image in `psd-layer-mask`. Bands of rows are + decoded from libvips' threads at once; with `access=sequential` memory stays at a few bands + of rows per thread, so it grows with the width and the threads, not the height. A layer + without transparency comes without an alpha band. +- **Saving one image** (`psdsave`, `psdsave_buffer`, `psdsave_target`): an image with alpha as one + layer and its composite. One without alpha as a flat file, the composite only and without + transparency, as Photoshop saves an image without layers; with `layered`, one layer without + transparency as well. Grey, RGB or CMYK at 8 or 16 bits, grey and RGB as floats. Options: + `compression` (`rle`, the default; `zip`; `zip-predicted`; `none`, the composite raw too), + `psb`, `layer_name`, `layered`. The image is read once, top to bottom, in libvips' bands, each + channel encoded as its rows come: memory about the file's size, not the image's. A PSB when + asked, for a file named `.psb`, or when a PSD cannot hold the image: a side above 30 000, a + row longer than a PSD's two-byte count, or a file above 2 GB, which Adobe documents as + Photoshop's limit for a PSD. +- **Saving layers** (`psdsave_layers`, `_buffer`, `_target`; in C `minipsd_vips_psdsave_layers` + and its two variants): a saver takes one image, so this is an operation over an array of + them, as `vips_composite` is. Each image is a layer, bottom first; its look comes from the same + `psd-layer-*` fields psdload sets, its position from `xoffset` and `yoffset`: + + ```c + VipsImage *layers[2] = { page, blank }; /* blank: any image with psd-layer-empty set */ + vips_image_set_string(page, "psd-layer-name", "Template"); + vips_image_set_int(blank, "psd-layer-empty", 1); + vips_image_set_string(blank, "psd-layer-name", "Layer"); + minipsd_vips_psdsave_layers(layers, 2, "template.psd", "composite", page, NULL); + ``` + + - `psd-layer-empty` makes a layer without pixels, as Photoshop writes a new, unpainted one. + - A mask is a one-band image of the layers' depth in `psd-layer-mask`, with its own + `xoffset` and `yoffset`, `psd-layer-mask-default-color` (its value outside, 0 or 255) and + `psd-layer-mask-disabled`. + - A group is records in the list, as the file has them: below its layers an image with + `psd-layer-section` `"end"`, above them one with `"open"` or `"closed"` that carries the + group's name, visibility, opacity and blend mode (`"pass"` passes through). + - The composite: `composite`, as given; else `vips_composite` of the visible layers, blend + modes by the spec's table, opacity, masks and the groups' visibility and opacity in the + alpha, flattened against white when no layer has alpha. Clipping, a group's own blend mode + and mask, and modes libvips lacks are not drawn; libvips computes in floats, so where alpha + is partial the composite is off by a level or two. A 16-bit CMYK document needs it given. + - The canvas: `width` and `height`; else the composite's size; else every layer from (`left`, + `top`), which also places the canvas in the layers' coordinates. + - Every layer with pixels is of one mode and depth; none is converted. `compression` and + `psb` as `psdsave`; each image is read once, in bands. + + A file's records loaded one by one with psdload and saved come back as the layers it had: the + look above, the pixels, masks and groups. Nothing else of a record travels: text, effects, + smart objects, an adjustment's settings (it comes back an empty layer) and other blocks are + left behind. A file minipsd wrote from those fields comes back byte for byte. +- **Building:** `-DMINIPSD_VIPS_PLUGIN=ON` gives the static library `minipsd::vips`; link it. + minipsd is compiled into it privately (`MINIPSD_STATIC`), so your own use of minipsd keeps its + own. libvips comes as for the example, or from your project: define `minipsd_libvips` and + `minipsd_libvips_zlib` (libvips, and the zlib it links, which inflates ZIP layers) before + adding minipsd, or point `FETCHCONTENT_SOURCE_DIR_MINIPSD_LIBVIPS` at the libvips you fetched + ([INSTALL.md](INSTALL.md#the-libvips-plugin)). +- **Released** as `minipsd-vips-X.Y.Z.zip` with each GitHub Release: the plugin as a CMake project + of its own, minipsd inside it, with its SBOM fragment ([INSTALL.md](INSTALL.md#the-libvips-plugin)). + ## Memory and ownership minipsd allocates only inside `minipsd_write`, which frees it before returning, and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ca3fa49..c3abad8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,10 +35,10 @@ The order of work from 0.1.0 to the design in [CODE-ARCH.md](CODE-ARCH.md). Case | [1. Module split](#1-module-split) | the API stays in `minipsd.h`, the implementation becomes modules | 0.2.0, at S3.4 | | [2. Pull writer](#2-pull-writer) | non-blocking, heap-free writing | 0.2.0, at S3.4 | | [3. I/O out of the core](#3-io-out-of-the-core) | stdio leaves the library; examples instead | **0.2.0**, at S3.4 (breaking) | -| [4. Reader](#4-reader) | buffer to structure, for minipsd's own subset | the next release, at S4.10 | +| [4. Reader](#4-reader) | buffer to structure, for minipsd's own subset | 0.3.0, at S4.10 | | [5. Round trip and conveniences](#5-round-trip-and-conveniences) | layer rectangles, full import, cheap re-save, kept unknown content | batched, when decided | | [6. Format features](#6-format-features) | blend modes, groups, masks, 16-bit, PSB | batched, when decided | -| [7. Interoperability](#7-interoperability) | libvips | batched, when decided | +| [7. Interoperability](#7-interoperability) | libvips | 0.3.0 (S7.1), 0.4.0 (S7.2 to S7.6, at S4.12) | | [8. Test quality](#8-test-quality) | branch coverage of compound conditions | with any release | | [Not scheduled](#not-scheduled) | large cases, only on real demand | — | @@ -189,12 +189,20 @@ Additive. See [Reader](CODE-ARCH.md#reader). the pre-1.0 rules. Before it: regenerate [COMPATIBILITY.md](COMPATIBILITY.md) with the new version, and have one CI job fetch psd-tools' files (`MINIPSD_PSD_TOOLS_FILES`; done: `linux-gcc`), so that Photoshop's ZIP files are checked through zlib there too, not only locally. -- [ ] **S4.11** Release **0.3.1** per [GITHUB-RELEASE.md](GITHUB-RELEASE.md): additions and a fix, +- [x] **S4.11** Release **0.3.1** per [GITHUB-RELEASE.md](GITHUB-RELEASE.md): additions and a fix, so a PATCH. `MINIPSD_STATIC` without unused-function warnings, `MINIPSD_NO_READER` and `MINIPSD_NO_WRITER`, the typed setters (S6.20), the pixel aspect ratio (S6.21); the release workflow checks CI's run on the tagged commit instead of running it again. Before it: [COMPATIBILITY.md](COMPATIBILITY.md) regenerated with the version (done), the PR merged and CI green on `master`. +- [x] **S4.12** Release **0.4.0** per [GITHUB-RELEASE.md](GITHUB-RELEASE.md): breaking, so a + MINOR (`composite_alpha` for every composite, the compression constants renumbered, + `minipsd_read` giving transparency as the file has it). The libvips plugin (S7.2 to S7.6), + layered documents written through libvips included, and its zip; layers without transparency, a layer's own compression, the row functions; + the fixes the pre-release review found (a mask's unbounded rectangle, an RLE count a PSD + cannot hold, `minipsd_write`'s cut rows). Prepared as 0.3.2, never tagged, then broken. + Before it: [COMPATIBILITY.md](COMPATIBILITY.md) regenerated with the version, the PR merged + and CI green on `master`; Platemaker sets `composite_alpha = 1` when it moves to 0.4.0. ## 5. Round trip and conveniences @@ -521,9 +529,11 @@ feature; a supplied composite needs nothing. See libvips is where minipsd is meant to be used: as a loader and saver of PSD and PSB, and in programs that render a composite with libvips and write the layers with minipsd. Steps 7.2 on are the loader and saver, built first as a plugin in this repository (`libvips-plugin/`): libvips -foreign classes registered by the program that links them, then, if libvips' prebuilt builds -load modules, a loadable module. Platemaker uses it, which is the proof; a pull request to libvips -comes at the end of stage 7, or of 8, with the same files. +foreign classes registered by the program that links them. libvips' prebuilt Windows builds load +no modules (8.18.7: `enable modules: false`), so a loadable module waits for a Linux user who wants +one. Platemaker uses it, which is the proof. It stays a plugin: libvips' CONTRIBUTING.md accepts no +contribution made with generative AI, and minipsd is made with it, so no pull request to libvips +follows. - [x] **S7.1** `examples/vips_composite.c`: a document's composite rendered with libvips (`vips_composite`, blend keys mapped as in the spec's @@ -537,7 +547,7 @@ comes at the end of stage 7, or of 8, with the same files. is read back and its composite must be the render, which is the test. CI's `msvc` job runs it; locally it also runs on `tests/fixtures/Test-g-example.psd`. Tracking libvips' master is left for later. -- [ ] **S7.2** *design.* minipsd as a libvips loader and saver: +- [x] **S7.2** *design.* minipsd as a libvips loader and saver: - **Loading.** What the image is: the composite by default, one layer by an option, its offset in the metadata. Metadata: ICC profile, resolution, interpretation, sample format. @@ -546,18 +556,68 @@ comes at the end of stage 7, or of 8, with the same files. - **What minipsd still needs.** The row provider (S7.5); the metadata ImageMagick gives (S6.17, P1). - **Building.** The build dependency: pkg-config, a meson wrap. -- [ ] **S7.3** Loader: a PSD or PSB as a libvips image, the composite or a layer, decoded in + As done: registered by the program (`minipsd_vips_init`), no module; the composite by + default, `layer=N` with its offset; the samples as libvips holds them (CMYK's ink the other + way round, bitmap `B_W`, indexed through its palette: libvips has no indexed image), the + rest as stored, what libvips cannot hold `MULTIBAND`, nothing converted; minipsd compiled + in privately; zlib libvips' own; libvips from `cmake/minipsd_libvips.cmake` or the program. + Pages as layers, and a meson wrap, wait for a user who asks. +- [x] **S7.3** Loader: a PSD or PSB as a libvips image, the composite or a layer, decoded in strips; ZIP through zlib. *Done when:* psd-tools' files that minipsd decodes load with the samples psd-tools gives; a large PSB loads with memory bounded by strips, not by the image. -- [ ] **S7.4** Saver: an image as a one-layer PSD or PSB with its composite, the image whole in - memory; RLE, or ZIP through zlib. + As done: `vips.plugin` compares every composite and layer of psd-tools' set (and + `tests/fixtures`) with minipsd's decode, which `psd-tools-roundtrip` holds to psd-tools' + (318 of 319 composites, 1330 of 2280 layers; the rest refused by both: a truncated file and + 950 empty layers); a 1024 x 30000 PSD read with `access=sequential` leaves libvips holding + under 2 MB of its 60 MB (all of it when the loader is not sequential). A PSD, not a PSB: the same bands. +- [x] **S7.4** Saver: an image as a one-layer PSD or PSB with its composite, the image whole in + memory; RLE, or ZIP through zlib. (S7.5 changed what it writes and how; see there.) *Done when:* psd-tools and GIMP open what it writes, and the loader reads it back the same. -- [ ] **S7.5** Row provider (`W6`): a layer's or the composite's rows asked from a callback instead + As done: `psdsave`, `_buffer`, `_target`; options `psb`, `zip`, `layer_name`. psd-tools + opens every file the test writes, its composite the layer (but where alpha is partial, by + the matte's rounding), full cyan as (255, 0, 0, 0); GIMP opens them, by hand. GIMP showed a + 32-bit file's transparency as an extra channel: the writer now names it as Photoshop does + where the layer count cannot (see the changelog). +- [x] **S7.5** Row provider (`W6`): a layer's or the composite's rows asked from a callback instead of an `pixels` pointer, twice with RLE or ZIP (sizing, then writing), so it must be deterministic, as libvips pipelines are. The saver then streams. *Done when:* the saver writes an image larger than the memory it is given, the bytes the same as from memory. + As done: not a row provider. libvips hands a saver an image it may read once, top to + bottom, and a provider would be asked for every channel's rows twice, so the saver encodes + each channel once, as its rows come, and gives minipsd the channels as stored: the row + functions made public for it (`minipsd_encode_row`, `minipsd_predict_row`, + `minipsd_matte_row`), ZIP through a zlib stream a channel. Memory is about the file's size, + not the image's: 20000 x 20000 RGB, an 813 MB file, 1994 MB to 1215 MB, 2.1 s to 1.5 s; + not below the file, so a file larger than memory still fails (temporary files were + declined). Found on the way, and done with it: an image without alpha is written flat (no + layer, no transparency, as Photoshop does; `layered` keeps a layer without it), so + `composite_alpha` now governs every composite and a layer may be `no_alpha` (breaking); + `compression` (`rle`, `zip`, `zip-predicted`, `none`) in place of `zip`, and a layer's own + compression in minipsd (the constants renumbered, breaking); a PSB chosen by itself when a + PSD cannot hold the file; the plugin released as a zip. psdsave is now as fast as + ImageMagick's `magicksave` or faster, its files 4 to 6 times smaller, with the right + pixels where magicksave's are wrong (alpha) or it fails (20000 x 20000). +- [x] **S7.6** A layered document saved through libvips: several images, each a layer with its + name, position, opacity, visibility and blend mode, and the composite, in one call, so + that a program uses minipsd's writer through libvips as it reads through it (`psdload` + gives every layer, with `layer`, and its `psd-layer-*` fields). A saver takes one image, + so this is an operation over an array of images, as `vips_composite` is. + *Done when:* Platemaker's template (a "Template" layer and an empty "Layer" above it) is + written through it, the bytes those of `minipsd_write` from the same layers; a file's + layers loaded with `psdload` and saved with it come back with their pixels and fields. + As done: `psdsave_layers` (`_buffer`, `_target`; `minipsd_vips_psdsave_layers` in C), each + record's look from the `psd-layer-*` fields psdload sets, masks (`psd-layer-mask`) and + groups (records with `psd-layer-section`) too, Blend If left out; the composite given or + composed by `vips_composite` (masks, groups' visibility and opacity in the alpha); the + canvas by `width`, `height`, `left`, `top`. psdload now loads every record (one without + pixels as one transparent pixel with `psd-layer-empty`): psd-tools' set, 2280 of 2280 + layers. Platemaker's template is byte for byte `minipsd_write`'s, and a document with a + placed, half-opaque, multiplied, clipped layer, a disabled mask and a group comes back the + same bytes; Platemaker writes its template through it. The plugin's names became its own + (`MinipsdVips*`, `minipsd_vips_*`): libvips' CONTRIBUTING.md accepts no contribution made + with generative AI, so it never moves into libvips. The zip carries its SBOM fragment. ## 8. Test quality @@ -583,7 +643,11 @@ Large cases. Each would start with a *design* step, and only when a real consume - The composed composite learning blend modes, masks, clipping or groups: a supplied composite is the way to get them right. -- Layer-at-a-time producer (`W8`); the row provider (`W6`) is S7.5. +- Layer-at-a-time producer (`W8`); the row provider (`W6`), which S7.5 did without. +- A streaming deflate (init, feed, finish) beside `minipsd_deflate_fn`, so the writer holds no + ZIP channel whole; and the writer keeping each layer row's size, so RLE encodes a row twice, + not three times. Both only when a program writing with minipsd itself needs them: the plugin + encodes its channels itself. - Parallel encoding split API (`W9`). - Segment output for zero-copy saves (`E3`). - In-place patching of fixed-size fields. diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index b55773f..609c81b 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -111,6 +111,9 @@ The declarations, with every parameter and return code, are in | `minipsd_writer_total(w)` | the file size, known before the first byte | | `minipsd_writer_fill(w, buf, cap, &produced)` | the next bytes, into the caller's buffer: `MINIPSD_MORE` until `MINIPSD_OK` | | `minipsd_write(doc, opts, fn, user)` | the whole file through a callback, on a workspace from `MINIPSD_MALLOC`; absent under `MINIPSD_NO_MALLOC` | +| `minipsd_encode_row(compression, depth, samples, step, width, out, cap, &size)` | one row of one channel as the writer encodes it, PackBits or raw, into the caller's buffer (`MINIPSD_ROW_BOUND` always enough): with the marker and the row sizes, a channel for `stored` and `composite_stored` | +| `minipsd_predict_row(depth, samples, step, width, out, cap)` | one row as the writer hands it to the deflate for `MINIPSD_ZIP_PREDICTED` (`MINIPSD_ROW_BYTES`) | +| `minipsd_matte_row(color_mode, depth, pixels, width)` | a composite's row matted against white in place, as the writer mattes a given one (RGB; other modes as given) | | `minipsd_reader_workspace_size(&size)` | the reader's workspace: fixed, known before the file | | `minipsd_reader_init(&r, data, size, workspace, workspace_size, limits, &info)` | the file's description and its resources, from the whole file or a prefix of it | | `minipsd_reader_needed(r)` | after `MINIPSD_ERR_SHORT`: the least buffer size with which the call goes further | @@ -169,7 +172,7 @@ its layers and composite as stored: no colour is converted. | Type | What it is | |---|---| -| `minipsd_doc`, `minipsd_layer` | the document: canvas, dpi, layers bottom first, ICC profile, compression | +| `minipsd_doc`, `minipsd_layer` | the document: canvas, dpi, layers bottom first, ICC profile, compression (the document's, and a layer's own), whether the composite and each layer have transparency | | `minipsd_workspace` | `void`: caller memory lent to the writer or the reader, sized by its `*_workspace_size` function | | `minipsd_writer` | opaque; built by `minipsd_writer_init` inside the workspace | | `minipsd_write_fn` | the callback of `minipsd_write`: non-zero to go on, 0 to abort | @@ -184,7 +187,12 @@ its layers and composite as stored: no colour is converted. | `minipsd_decode_opts` | the rows to decode, the target's `depth` and, for the composite, `keep_matte`; `NULL` for all rows, 8-bit, un-matted | **Defaults and field meanings** -- A zero-initialised document selects RLE (`MINIPSD_RLE = 0`). +- A zero-initialised document selects RLE: `MINIPSD_COMPRESSION_DEFAULT` (0) is RLE for the + document and the document's compression for a layer. The other constants (`MINIPSD_RLE` 1, + `MINIPSD_RAW` 2, `MINIPSD_ZIP` 3, `MINIPSD_ZIP_PREDICTED` 4) are not the markers the file + stores (0 raw, 1 RLE, 2 ZIP, 3 ZIP with prediction). +- `composite_alpha = 0` is a composite without transparency, its colour channels only; + `no_alpha = 0` a layer with transparency. - `opacity = 0` means a fully transparent layer; it is **not** "default opaque". - `pixels = NULL` is an empty layer; `name = NULL` is an empty name. - Layers are listed bottom first. @@ -245,12 +253,13 @@ the caller frees with `minipsd_doc_free`. Everything else is the caller's memory | Name | Owner | Size | Must stay valid | May live in | |---|---|---|---|---| | `minipsd_doc`, `minipsd_layer[]` | caller; borrowed | `sizeof`; `layer_count` layers | the call; for the pull writer, from init for as long as the writer is used | stack, static, heap | -| raw document data: layer pixels (`pixels`) or stored channels (`stored`), names, ICC profile, the supplied `composite`, kept `additional_info`, `resources` and `layer_tail` | caller; borrowed, read in place, never copied | a layer's rectangle times 4 samples, of 1 or 2 bytes (or its channels' stored size); `width * height * 4` samples for the composite | as `minipsd_doc` | wherever the application keeps it | +| raw document data: layer pixels (`pixels`) or stored channels (`stored`), names, ICC profile, the supplied `composite`, kept `additional_info`, `resources` and `layer_tail` | caller; borrowed, read in place, never copied | a layer's rectangle times its pixel's samples, the mode's colour channels and transparency unless `no_alpha` (or its channels' stored size); `width * height` times the colour channels, and transparency with `composite_alpha`, for the composite | as `minipsd_doc` | wherever the application keeps it | | `minipsd_workspace` of the writer | caller; lent to the writer | `minipsd_writer_workspace_size()` | for as long as the writer is used | heap; stack or static sized for the largest document | | `minipsd_writer` | inside `minipsd_workspace`; opaque | part of the workspace | as the workspace | the caller keeps only the pointer | -| raw PSD bytes | passed to the callback of `minipsd_write()` | pieces of up to 4 KiB, in order | the callback copies what it keeps | the callback decides | -| output buffer, `buf` of `minipsd_writer_fill()` | caller | any `cap`, 1 included; a few rows (64 KiB) keeps re-encoding negligible | the call | stack, static or heap | -| `minipsd_write()` internals | minipsd: the workspace through `MINIPSD_MALLOC` / `MINIPSD_FREE`, a 4 KiB chunk on the stack | `minipsd_writer_workspace_size()` | the call | heap and stack | +| raw PSD bytes | passed to the callback of `minipsd_write()` | pieces of up to 4 KiB, or of the widest row's `MINIPSD_ROW_BOUND` when that is more, in order | the callback copies what it keeps | the callback decides | +| output buffer, `buf` of `minipsd_writer_fill()` | caller | any `cap`, 1 included; one of at least the widest row's `MINIPSD_ROW_BOUND` encodes each row at most twice | the call | stack, static or heap | +| `minipsd_write()` internals | minipsd: the workspace and its buffer through `MINIPSD_MALLOC` / `MINIPSD_FREE` | `minipsd_writer_workspace_size()`, and the larger of 4 KiB and the widest row's bound | the call | heap | +| `out` of `minipsd_encode_row()`, `minipsd_predict_row()`; `pixels` of `minipsd_matte_row()` | caller; written (`pixels` read and written in place) | `MINIPSD_ROW_BOUND`, `MINIPSD_ROW_BYTES`; `width` pixels | the call | stack, static or heap | | the PSD to read (`data` of `minipsd_reader_init()`) | caller; borrowed, read in place, never copied | the file, or a prefix of it | for as long as the reader or any view from it is used, unchanged | wherever it was read, mapped or received | | `minipsd_workspace` of the reader | caller; lent to the reader | `minipsd_reader_workspace_size()`, the same for every file | for as long as the reader is used | stack, static or heap | | `minipsd_reader` | inside its workspace; opaque | part of the workspace | as the workspace | the caller keeps only the pointer | @@ -289,6 +298,7 @@ interact, whatever the table says. | `minipsd_writer_init` | no | fills the writer's workspace; several writers of one document at once are fine | | `minipsd_writer_fill` | no | advances the writer | | `minipsd_writer_total` | yes | reads what `minipsd_writer_init` set; safe beside `minipsd_writer_fill` | +| `minipsd_encode_row`, `minipsd_predict_row`, `minipsd_matte_row` | yes | read the caller's samples; write only the caller's buffer (`minipsd_matte_row` its row) | | `minipsd_reader_init` | no | fills the reader's workspace; several readers of one PSD at once are fine | | `minipsd_reader_needed` | yes | reads the reader; after a decode returned `MINIPSD_ERR_SHORT`, call it in that thread | | `minipsd_reader_next_resource` | yes | each thread its own `pos`; only reads the reader | @@ -335,7 +345,8 @@ several threads from one reader into one image. bits, RGB and grey also at 32, indexed at 8, bitmap at 1. Another depth of a mode is `MINIPSD_ERR_UNSUPPORTED`; a mode or depth the format does not have, `MINIPSD_ERR_ARG`. - **Samples** are the mode's colour channels then transparency (`pixels`, `composite`), as the - file stores them: no colour is converted (CMYK inverted, 255 no ink; Lab's L, a, b). A sample + file stores them, transparency only where there is some (a layer's unless `no_alpha`, the + composite's with `composite_alpha`): no colour is converted (CMYK inverted, 255 no ink; Lab's L, a, b). A sample is a byte at 8 bits, a `uint16_t` at 16, a `float` (0.0 to 1.0) at 32, in host order, written big-endian; a mask is one channel of the same depth. - **The colour mode data** (`color_mode_data`) is written as given: an indexed document's palette @@ -355,7 +366,11 @@ several threads from one reader into one image. Photoshop's 16-bit files have them; what the block held goes, the document's layers win. - **No layers:** a document with a composite (supplied or stored) and `layer_count` 0 is written flat: an empty layer info and the tail, as `minipsd_read` keeps a flat file with - `opts->stored`. + `opts->stored`; when the composite has transparency (not in a flat mode), an empty `Mtrn`, + `Mt16` or `Mt32` by its depth after the tail, unless the tail has one (Adobe's "merged + transparency"; no file in psd-tools' set is flat with transparency, since Photoshop keeps it on + a layer). Without transparency (`composite_alpha` 0) it is the colour channels alone, the count + positive: how Photoshop saves an image that has neither layers nor transparency. - **The format is what the document is.** A PSB is written with its wide fields (eight-byte section and channel lengths, four-byte RLE row counts), and everything kept from a file is written as it is, so it must be in that format's layout: `minipsd_read` sets `format` to the @@ -390,9 +405,11 @@ several threads from one reader into one image. - the writer's own blocks first, `luni` (a kept one as it is while it says the name) and the group marker, then the blocks kept from a file (`additional_info`), as they are and in their order: the name wins, and kept `luni` blocks go; - - channel data encoded from `pixels`, or copied as it is from `stored` (any compression the - file had, ZIP included), so a layer that was not changed is not encoded again; a stored - layer without transparency is written without one (three colour channels); + - channel data encoded from `pixels` in the layer's compression (`compression`, 0 the + document's), or copied as it is from `stored` (any compression the file had, ZIP included), + so a layer that was not changed is not encoded again; a layer without transparency + (`no_alpha`, or stored with a transparency channel of size 0) is written without one, its + colour channels only; - groups (`section_divider_type`), bottom first: a divider (`lsct` type 3), the group's layers, then the group's top record (type 1 open, 2 closed) with its name, visibility and mode in its `lsct` block (`blend_mode`, Pass Through by default; the record keeps `norm`). A kept `lsct` of the same @@ -407,17 +424,27 @@ several threads from one reader into one image. writes a length of 0, as GIMP does; a file's are written back as they were (Photoshop writes five: the composite grey, then four channels). The composed composite ignores them. - **Composite image:** - - the caller's `composite` when set (the full canvas, straight alpha), whatever the layers - hold: flattening layers is rendering, the caller's domain; + - the caller's `composite` when set (the full canvas: the colour channels, then, with + `composite_alpha`, transparency, straight), whatever the layers hold: flattening layers is + rendering, the caller's domain; - otherwise a file's composite kept as it stores it (`composite_stored`, with `composite_channels`: up to 56, the mode's colour channels, then transparency when `composite_alpha` says so, then alpha and spot channels), copied byte for byte, never decoded nor encoded; the header's channel count follows it, and the layer count is negative with `composite_alpha`; - otherwise composed as a **best effort, with no guarantee**: the visible layers combined with - straight-alpha "over", each at its rectangle, clipped to the canvas. Blend modes, masks, - clipping, groups and effects are not drawn; - - transparency (of a supplied or composed composite, always, but in a flat mode) is the first extra channel, + straight-alpha "over", each at its rectangle, clipped to the canvas, a layer without + transparency as opaque. Blend modes, masks, clipping, groups and effects are not drawn. + Without `composite_alpha` the result is matted against white and its colour channels alone + are written; + - transparency, whichever the composite (supplied, composed or stored), only when + `composite_alpha` says it has some (never in a flat mode: `MINIPSD_ERR_ARG`), is the first extra channel, signalled by writing the layer count as a **negative** number, as the format specifies; + where the count cannot say it (the writer's `Lr32`, whose count is positive, and a document + with no layers), by the `Mt32` (`Mtrn`, `Mt16`) block and by naming the channel as + Photoshop's `transparentbg.psd` in psd-tools' set does: 1006 "Transparency", 1045 the same + with its NUL, 1053 id 0. GIMP takes the channel for transparency only by a negative count or + that name, and reads none of the blocks; a document that keeps any of 1006, 1045 or 1053 + gets none of the writer's; - **colour is matted against white:** `c*a + max*(1-a)`, `max` being 255 or 65535 (at 32 bits `c*a + (1-a)`). Photoshop stores it this way and readers subtract the white back out. The writer mattes, a supplied composite too, unless `composite_matted` says it already is: then it @@ -435,6 +462,8 @@ several threads from one reader into one image. 1064 goes. The resolution stays square: the ratio is never folded into 1005. Photoshop's files in psd-tools' set all hold 1.0 (290 version 2, 15 version 1), so another ratio is written as the format lays it out but not verified against a reader; + - 1006, 1045 and 1053 naming the composite's transparency, where the layer count cannot say it + (Composite image, above); - a resource of odd length is padded to even. - **Typed setters:** the mirror of the typed views (in [What is read](#what-is-read)): a struct in, the bytes the view reads out, into the caller's buffer, counted first (`needed`, and @@ -459,19 +488,24 @@ several threads from one reader into one image. - **After the layer info:** the kept `layer_tail` (global layer mask, global blocks) as it is, or an empty global layer mask, also for a file read that ends the section with its layer info (GIMP's). -- **Compression:** PackBits RLE (runs from 3 bytes, packets up to 128 bytes) or raw. - Row byte counts are computed in a separate sizing pass, so output streams forward through the - callback and never seeks back. +- **Compression:** PackBits RLE (runs from 3 bytes, packets up to 128 bytes) or raw, the + document's (`compression`) or a layer's own (`minipsd_layer.compression`, its mask's channels + too). Row byte counts are computed in a separate sizing pass, so output streams forward through + the callback and never seeks back. A PSD counts an RLE row's bytes in 16 bits: a row that packs + to more than 65 535 bytes (32-bit samples that do not pack, wider than about 16 380 px) is + `MINIPSD_ERR_SIZE`, the layer's or the composite's; a PSB counts in 32. ZIP (`MINIPSD_ZIP`, and `MINIPSD_ZIP_PREDICTED` with each sample stored as its difference from its left neighbour) compresses the layers' and masks' channels through the caller's deflate, - `minipsd_write_opts.deflate` (`MINIPSD_ERR_ARG` without one): minipsd implements none. The - composite is then RLE, as Photoshop writes it. The sizing pass deflates each channel once to + `minipsd_write_opts.deflate` (`MINIPSD_ERR_ARG` without one, whenever any layer deflates): + minipsd implements none. The composite is RLE, as Photoshop writes it, and raw in a raw + document. The sizing pass deflates each channel once to learn its size and the writing pass once more, so the deflate must give the same bytes for the same input; one that fails, or gives other bytes, is `MINIPSD_ERR_WRITE`. The workspace holds the largest such channel raw and the room for its stream. - **Allocations:** see [Memory and ownership](#memory-and-ownership). - **Limits** (all checked before anything is written): - each side 1 .. 30 000 px in a PSD, 1 .. 300 000 in a PSB, otherwise `MINIPSD_ERR_SIZE`; + - an RLE row of at most 65 535 bytes in a PSD (Compression, above), otherwise `MINIPSD_ERR_SIZE`; - 1 .. 32 767 layers, otherwise `MINIPSD_ERR_ARG`; - dpi must round to 1 .. 2^32 - 1 in 16.16 fixed point, i.e. from about 0.0000076 to just below 65 536 (NaN and infinity excluded), otherwise `MINIPSD_ERR_ARG`; @@ -585,7 +619,11 @@ them; nothing is copied or allocated. Results on real files from other programs: (or, with `opts->stored`, its channels as stored, ZIP ones too) and blocks, the composite as stored (`composite_matted`; with `opts->stored`, every channel's bytes in `composite_stored`), the dpi (72 when none is stored), the ICC profile, every resource and the layer tail, the `Lr16` or `Lr32` its layers came from in it, empty, all copied into one - block. A file without layers gets one, "Background", holding its image; with `opts->stored` it + block. A layer without a transparency channel decodes to its colour channels, `no_alpha` set; + the composite to the channels the file has, `composite_alpha` saying whether one of them is + transparency. A mask whose side is above the format's is `MINIPSD_ERR_UNSUPPORTED`, as a + layer's. A file without layers gets one, "Background", holding its image, without transparency + when the file has none; with `opts->stored` it stays without, its image the composite, copied, and so does a flat mode's (bitmap, indexed, multichannel), which has no layers: decoded, but multichannel's always copied. Anything else is reported in `lost` (`MINIPSD_LOST_*`), never dropped silently. @@ -600,7 +638,8 @@ them; nothing is copied or allocated. Results on real files from other programs: `minipsd_reader_needed` says how much more a call needs. - **Limits:** every length is checked against the buffer and the section it lies in; the caller's `minipsd_limits` add the canvas's and the layers' side, the layer count and the bytes - one decode call writes, all checked before anything is decoded. + one decode call writes, all checked before anything is decoded. A channel whose side is above + either format's (300 000) is `MINIPSD_ERR_UNSUPPORTED` before its size is computed. ## Blend modes @@ -684,6 +723,132 @@ does, and its memory images are minipsd's buffers, with no conversion: of libvips, the composite rendered by `vips_composite`, the file written with it and read back; CI's `msvc` job runs it on libvips' prebuilt Windows build, pinned to the newest release. +### The plugin + +`libvips-plugin/` (`MINIPSD_VIPS_PLUGIN`, the static library `minipsd::vips`) is minipsd as +libvips' loader and saver, laid out as libvips' own (a base class over a `VipsSource` or a +`VipsTarget`, file and buffer variants that make one), in names of its own (`MinipsdVips*`, +`minipsd_vips_*`) that never meet libvips'. It stays a project apart from libvips: libvips' +CONTRIBUTING.md accepts no contribution made with generative AI, and minipsd is made with it. + +- **Registration.** `minipsd_vips_init()`, after `VIPS_INIT`, registers the nine operations' + types; libvips then finds them among its loaders and savers by `is_a` and suffix. libvips' + prebuilt Windows builds (8.18.7: `enable modules: false`) load no modules, so a loadable module + would not work there; the program registers instead. The loader's priority is libvips' + default, 0, above `magickload`'s -100. +- **Loading** (`psdload`, `psdload_buffer`, `psdload_source`). The file is mapped + (`vips_source_map`) and read in place. The image is the composite, or with `layer` (bottom + first; -1, the default, the composite) that layer: its rectangle, placed by `xoffset` and + `yoffset`, with transparency when the layer has it (no alpha band when it has none). A record + without pixels (an empty layer, a group's record, an adjustment) is one transparent pixel + with `psd-layer-empty`, so every record loads and `layer=N` counts them all. Bands are the mode's colour channels and transparency, up to + `MINIPSD_PLANES` (a multichannel file's sixth channel on is not decoded): + + | Mode | libvips | Samples | + |---|---|---| + | grey, duotone | `B_W`, `GREY16` at 16 bits | as stored | + | RGB | `sRGB`, `RGB16`, `scRGB` at 32 bits | as stored | + | CMYK | `CMYK` | ink inverted from PSD's (full: no ink) to libvips' (0: no ink), as its `jpegload` makes Adobe's inverted CMYK and ImageMagick makes PSD's; transparency as stored | + | bitmap | `B_W`, 8-bit | black 0, white 255 | + | indexed | `sRGB`, 8-bit | through the palette, a fourth band when 1047 names a transparent index | + | Lab, multichannel | `MULTIBAND` | as stored | + + Decoding is `SEQUENTIAL`: `vips_image_generate`, then `vips_sequential`, as `spngload` does. + Each band of rows is one `minipsd_decode_composite` (or `_layer`) straight into the region, + from libvips' threads at once on the one reader (see Threads); with `access=sequential` the + image streams, otherwise libvips keeps it for random access. A ZIP layer inflates through the + zlib libvips links, each thread keeping the layer's channels inflated between its bands + (`scratch_kept`), so every thread holds the layer's channels once. +- **Metadata**, set on the image: + + | Field | From | + |---|---| + | `xres`, `yres`, `resolution-unit` | 1005 (horizontal and vertical, in or cm); else `dpi` | + | `icc-profile-data`, `exif-data`, `xmp-data`, `iptc-data` | 1039, 1058, 1060, 1028 | + | `bits-per-sample` | the file's depth (1 for bitmap) | + | `psd-color-mode-data` | the palette or the duotone data | + | `psd-pixel-aspect-ratio` | 1064, 1.0 without it | + | `psd-has-real-merged-data` | 1057; 1 without it | + | `psd-layer-count` | the file's layers | + | `psd-layer-name`, `-opacity`, `-hidden`, `-clipping` | the record, with `layer` | + | `psd-layer-blend-mode` | the record's key; a group's top record, its group's mode from its `lsct` block (`pass`: passes through) | + | `psd-layer-empty` | 1 for a record without pixels (one transparent pixel stands for it), else 0 | + | `psd-layer-section` | a group's records only: `open`, `closed` (the top record), `end` (the divider below its layers) | + | `psd-layer-mask` | the record's mask, an image: one band of the file's depth (`B_W`, `GREY16`), its rectangle in `xoffset`, `yoffset`; one pixel with `psd-layer-empty` for a mask without area; decoded whole, a mask being one band | + | `psd-layer-mask-default-color`, `-mask-disabled` | the mask's value outside its rectangle (0 or 255); its flag 0x02 | + +- **Saving** (`psdsave`, `psdsave_buffer`, `psdsave_target`). Grey, RGB or CMYK (by + `interpretation` and bands, each with or without alpha), 8 or 16 bits, grey and RGB as floats; + other band counts are refused. With alpha: one layer and the composite, each with + transparency. Without: a flat file, the composite only, its colour channels (as Photoshop's + `history.psd`, `0layers_tblocks.psd` and `slices.psd` in psd-tools' set, and psd-tools' + `frompil`); with `layered`, one layer without transparency as well, which psdload reads back + without an alpha band. Nothing is added: an image's bands are what is written. CMYK goes back + to PSD's inverted ink. Kept: the resolution, the ICC profile, EXIF, XMP, IPTC and + `psd-pixel-aspect-ratio`. Options: `compression` (the layer's channels: `rle`, the default, + `zip`, `zip-predicted`, or `none`, which writes the composite raw too; otherwise the composite + is RLE; `MinipsdVipsCompression` in `minipsd_vips.h`), `psb`, `layer_name` ("Layer 1"), + `layered`. + + The image is read once, top to bottom, in the bands libvips hands a saver + (`vips_sink_disc`), and each channel's rows are encoded as they come with + `minipsd_encode_row`, `minipsd_predict_row` and `minipsd_matte_row`, ZIP through a zlib stream + a channel, then handed to minipsd as `stored` and `composite_stored`: memory about the file's + size, not the image's (an 8000 x 8000 RGBA image, 247 MB as a file, peaks at 326 MB). Each + channel keeps room ahead of its rows for its marker and a PSB's row sizes, so its block is + written in place for either format. zlib's stream depends on how its input is cut, so a ZIP + channel's bytes are those of a deflate fed one row at a time. + + PSB by the option, for a file named `.psb` (read from the target, since libvips writes files + through `psdsave_target`), or a side past 30 000; then, the channels encoded, when a PSD + cannot hold the file: an RLE row past 65 535 bytes, a layer section past 4 GiB, or a file + past 2 GB, which Adobe documents as Photoshop's limit for a PSD. +- **Saving layers** (`psdsave_layers`, `psdsave_layers_buffer`, `psdsave_layers_target`; in C + `minipsd_vips_psdsave_layers`, `_buffer`, `_target`, through `vips_call_split`). A saver takes + one image, so this is a `VipsOperation` over a `VipsArrayImage`, as `vips_composite` is, + `NOCACHE` since it writes. Each image is a record, bottom first, read from the fields the + loader sets (the table above), so a file's records loaded and saved come back: + + | Field | Written as | + |---|---| + | `psd-layer-name` | the name; else "Layer N" (N from 1, bottom first), a divider "" | + | `-opacity`, `-hidden`, `-clipping` | as given; opacity clamped to 0 to 255 | + | `-blend-mode` | a four-character key, any other length refused; `pass` on a group's top record as none, which minipsd writes as Photoshop does (record `norm`, `lsct` `pass`) | + | `-empty`, `-section` | no pixels, no rectangle; `section_divider_type` (another text refused) | + | `xoffset`, `yoffset` | the rectangle, less (`left`, `top`) | + | `-mask` and its two fields | the mask: one band of the layers' depth (else refused), its rectangle from its offsets, `flags` 0x02 when disabled, encoded as the layer is | + + Every record with pixels is of one mode and depth (the first such decides, else the composite, + else the first record); none is converted. Each image is read once, in bands, into its + channels (one helper encodes a layer, a mask and the composite), and the document goes to + `minipsd_write` with them stored; PSB as `psdsave` chooses it, retried over every block. + What a record holds beyond these fields (text, effects, smart objects, an adjustment's + settings, Blend If, its other flags and blocks) is not carried. + + Options: `composite`, `compression`, `psb`, `width`, `height`, `left`, `top`. The canvas is + `width` by `height`; else the composite's size; else the smallest from (`left`, `top`) that + holds every record with pixels; (`left`, `top`) is where it lies in the records' coordinates. + The composite is the given one (the canvas's size, the records' mode and depth), or composed: + `vips_composite` of the visible records onto a transparent canvas, blend modes by the table + above (others over), opacity, a mask (not disabled; its default colour outside its rectangle) + and its groups' visibility and opacity (walked top down) in the alpha, flattened against + white (CMYK: no ink) when no record has alpha. Clipping, a group's own blend mode and mask are + not drawn. libvips computes in floats: on the test's RGBA, 4774 of 244 412 bytes are off by + 1 or 2 levels where alpha is partial, none where it is full. 16-bit CMYK needs the composite + given (libvips takes CMYK's alpha as 8-bit). +- **Builds.** The plugin compiles minipsd in privately (`MINIPSD_STATIC`). On Windows it uses the + zip's `libz1` (zlib-ng, built zlib-compatible) with zlib 1.3.1's headers, fetched and pinned, + since the zip has none; elsewhere pkg-config's `vips` and `zlib`. A program's own libvips + replaces the fetched one by `FETCHCONTENT_SOURCE_DIR_MINIPSD_LIBVIPS`, or by the targets + `minipsd_libvips` and `minipsd_libvips_zlib`. Released as `minipsd-vips-X.Y.Z.zip` + (`tools/package_vips.cmake`): the plugin as a CMake project, minipsd's single header private to + it, with its SBOM fragment (`minipsd-vips.spdx.json`, every other file with its checksums, + `MINIPSD_SBOM_FILE` on `minipsd::vips`; `tools/check_spdx.cmake` checks it), built from the zip + and its example run by CI and by the release. libvips 8.17 replaced + `VipsSaveable` with `VipsForeignSaveable`; both are handled. CI's `msvc` job runs its test, + which compares every pixel with minipsd's own decode, on psd-tools' set too, and checks that a + 30000-row image read sequentially leaves libvips holding a few rows' worth. + ## Consumer requirements Platemaker is the first consumer. Breaking any of these breaks it: diff --git a/docs/USE-CASES.md b/docs/USE-CASES.md index 34685de..dceaf0c 100644 --- a/docs/USE-CASES.md +++ b/docs/USE-CASES.md @@ -172,7 +172,9 @@ This is not the roadmap, just where each case fits against the current plan in - **Large, only on real demand:** - R9 (streaming parser): PSD's planar layout means a whole compressed layer held when read forward only (ROADMAP S6.15); - - W6 and W8 (row provider, layer-at-a-time); + - W6 and W8 (row provider, layer-at-a-time); W6's own case, a saver that reads its image once, + is served without it: the channels encoded by the program, row by row, and given as stored + (`minipsd_encode_row`, the libvips plugin's `psdsave`); - W9 (parallel split API); - E3 (segment output); - in-place patching. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 55414ce..5ac8bb2 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -117,42 +117,9 @@ foreach(_psd IN LISTS _other_psds) endforeach() # vips_composite: libvips renders the composite, minipsd writes the file with it (MINIPSD_VIPS_EXAMPLE, -# off by default: it needs libvips). minipsd never depends on libvips; only this example does. -# Windows fetches libvips' official prebuilt "web" build, pinned (the newest release when set); -# elsewhere pkg-config finds the installed one. The example links the C API only. -option(MINIPSD_VIPS_EXAMPLE "Build and run examples/vips_composite.c against libvips (fetched on Windows)" OFF) +# off by default: it needs libvips, from cmake/minipsd_libvips.cmake). minipsd never depends on +# libvips; only this example does. It links the C API only. if(MINIPSD_VIPS_EXAMPLE) - if(WIN32) - set(MINIPSD_VIPS_VERSION 8.18.7) - include(FetchContent) - FetchContent_Declare(minipsd_vips - URL https://github.com/libvips/build-win64-mxe/releases/download/v${MINIPSD_VIPS_VERSION}/vips-dev-x64-web-${MINIPSD_VIPS_VERSION}.zip - URL_HASH SHA256=3a122eb3d588690008216f786338e2f9329dba3c3c2768b5933cf7299d549241 - DOWNLOAD_EXTRACT_TIMESTAMP TRUE - SOURCE_SUBDIR no-cmake-project) # files only: nothing to add as a subproject - FetchContent_MakeAvailable(minipsd_vips) - set(_vips "${minipsd_vips_SOURCE_DIR}") - add_library(minipsd_libvips INTERFACE IMPORTED) - if(MSVC) # the zip's import libraries - target_link_libraries(minipsd_libvips INTERFACE - "${_vips}/lib/libvips.lib" "${_vips}/lib/libglib-2.0.lib" "${_vips}/lib/libgobject-2.0.lib") - else() # MinGW links the DLLs themselves - target_link_libraries(minipsd_libvips INTERFACE - "${_vips}/bin/libvips-42.dll" "${_vips}/bin/libglib-2.0-0.dll" "${_vips}/bin/libgobject-2.0-0.dll") - endif() - target_include_directories(minipsd_libvips SYSTEM INTERFACE - "${_vips}/include" "${_vips}/include/glib-2.0" "${_vips}/lib/glib-2.0/include") - set(_vips_path "${_vips}/bin;$ENV{PATH}") # the DLLs, for the tests - string(REPLACE ";" "\\;" _vips_path "${_vips_path}") - set(_vips_env "PATH=${_vips_path}") - else() - find_package(PkgConfig REQUIRED) - pkg_check_modules(VIPS REQUIRED IMPORTED_TARGET vips) - add_library(minipsd_libvips INTERFACE IMPORTED) - target_link_libraries(minipsd_libvips INTERFACE PkgConfig::VIPS) - set(_vips_env "") - endif() - add_executable(vips_composite vips_composite.c) target_link_libraries(vips_composite PRIVATE minipsd::minipsd minipsd_libvips) target_compile_options(vips_composite PRIVATE ${MINIPSD_WARNINGS}) @@ -173,7 +140,7 @@ if(MINIPSD_VIPS_EXAMPLE) endif() # TIMEOUT: libvips' exit has once hung on Windows, after the result; a hang fails fast. set_tests_properties(${_vips_tests} PROPERTIES PASS_REGULAR_EXPRESSION "composite read back: as rendered" TIMEOUT 120) - if(_vips_env) - set_tests_properties(${_vips_tests} PROPERTIES ENVIRONMENT "${_vips_env}") + if(MINIPSD_LIBVIPS_ENV) + set_tests_properties(${_vips_tests} PROPERTIES ENVIRONMENT "${MINIPSD_LIBVIPS_ENV}") endif() endif() diff --git a/examples/demo.h b/examples/demo.h index ede3de8..c35f396 100644 --- a/examples/demo.h +++ b/examples/demo.h @@ -43,6 +43,7 @@ static void demo_init(demo *d) memset(&d->doc, 0, sizeof d->doc); d->doc.width = DEMO_W; d->doc.height = DEMO_H; d->doc.dpi = 300.0; d->doc.layers = d->layers; d->doc.layer_count = 2; d->doc.compression = MINIPSD_RLE; + d->doc.composite_alpha = 1; /* the layers have transparency, and so has the composite composed of them */ } #endif /* EXAMPLES_DEMO_H */ diff --git a/examples/hello_write.c b/examples/hello_write.c index 58b6e6f..63aca7f 100644 --- a/examples/hello_write.c +++ b/examples/hello_write.c @@ -21,7 +21,8 @@ int main(void) static uint8_t rgba[64 * 64 * 4]; /* straight alpha, rows top down */ /* Fields left out are zero, the defaults: visible, the full canvas, RLE. */ minipsd_layer layer = {.name = "Hello", .pixels = rgba, .opacity = 255}; - minipsd_doc doc = {.width = 64, .height = 64, .dpi = 72.0, .layers = &layer, .layer_count = 1}; + minipsd_doc doc = {.width = 64, .height = 64, .dpi = 72.0, .layers = &layer, .layer_count = 1, + .composite_alpha = 1}; /* composed from the layer, with its transparency */ FILE *f; int i, rc; diff --git a/examples/vips_composite.c b/examples/vips_composite.c index de4b073..d0a56d5 100644 --- a/examples/vips_composite.c +++ b/examples/vips_composite.c @@ -31,7 +31,8 @@ #include #include -#define RGBA 4 /* bands: 8-bit red, green, blue, alpha */ +#define RGB 3 /* bands: 8-bit red, green, blue */ +#define RGBA 4 /* ... and alpha */ #define FULL 255 /* an 8-bit sample's full value */ /* The whole file in a new buffer; NULL when it cannot be read. */ @@ -83,10 +84,17 @@ static VipsBlendMode blend_mode(const char key[4], int *mapped) /* A layer's pixels as a libvips image, wrapped without a copy, its opacity in its alpha. */ static VipsImage *layer_image(const minipsd_layer *l) { - const int w = (int)(l->right - l->left), h = (int)(l->bottom - l->top); + const int w = (int)(l->right - l->left), h = (int)(l->bottom - l->top), bands = l->no_alpha ? RGB : RGBA; VipsImage *wrapped, *srgb, *out; - wrapped = vips_image_new_from_memory(l->pixels, (size_t)w * (size_t)h * RGBA, w, h, RGBA, VIPS_FORMAT_UCHAR); + wrapped = vips_image_new_from_memory(l->pixels, (size_t)w * (size_t)h * (size_t)bands, w, h, bands, VIPS_FORMAT_UCHAR); if (!wrapped) return NULL; + if (l->no_alpha) { /* a layer without transparency: opaque, so that every layer is RGBA */ + VipsImage *opaque; + const int rc = vips_bandjoin_const1(wrapped, &opaque, FULL, NULL); + g_object_unref(wrapped); + if (rc) return NULL; + wrapped = opaque; + } if (vips_copy(wrapped, &srgb, "interpretation", VIPS_INTERPRETATION_sRGB, NULL)) { /* RGBA, not 4 bands of anything */ g_object_unref(wrapped); return NULL; @@ -127,7 +135,7 @@ static uint8_t *render(const minipsd_doc *d, size_t *unmapped) || l->right <= l->left || l->bottom <= l->top) continue; if (!(in[n] = layer_image(l))) goto done; - modes[n - 1] = blend_mode(l->blend_mode, &mapped); + modes[n - 1] = (int)blend_mode(l->blend_mode, &mapped); *unmapped += !mapped; x[n - 1] = (int)l->left; /* where the layer goes; it may reach past the canvas */ y[n - 1] = (int)l->top; @@ -199,12 +207,12 @@ static void make_layers(minipsd_layer layers[4]) int x, y; for (y = 0; y < H; ++y) for (x = 0; x < W; ++x) { /* a gradient: red across, green down, opaque */ - uint8_t *p = background + ((size_t)y * W + x) * RGBA; + uint8_t *p = background + ((size_t)y * W + (size_t)x) * RGBA; p[0] = (uint8_t)(x * FULL / (W - 1)); p[1] = (uint8_t)(y * FULL / (H - 1)); p[2] = 160; p[3] = FULL; } for (y = 0; y < 128; ++y) for (x = 0; x < 128; ++x) { /* an orange disc, transparent around it */ - uint8_t *p = disc + ((size_t)y * 128 + x) * RGBA; + uint8_t *p = disc + ((size_t)y * 128 + (size_t)x) * RGBA; const int dx = x - 64, dy = y - 64; p[0] = 255; p[1] = 150; p[2] = 40; p[3] = dx * dx + dy * dy < 60 * 60 ? FULL : 0; } @@ -258,6 +266,7 @@ int main(int argc, char **argv) if (!flat) { fprintf(stderr, "vips_composite: libvips: %s\n", vips_error_buffer()); goto done; } doc->composite = flat; /* ... and minipsd writes them with that image */ doc->composite_matted = 0; /* straight alpha: the writer mattes it, as the format wants */ + doc->composite_alpha = 1; /* RGBA: it has transparency */ f = fopen(argv[1], "wb"); if (!f) rc = MINIPSD_ERR_OPEN; diff --git a/include/minipsd.h b/include/minipsd.h index c2a2fc3..791e9b5 100644 --- a/include/minipsd.h +++ b/include/minipsd.h @@ -51,7 +51,7 @@ * * \par What it writes * A document in any colour mode (minipsd_doc.color_mode) at the depths Photoshop has it, as a PSD - * or (minipsd_doc.format) a PSB, with a transparency channel but in the flat modes (bitmap, + * or (minipsd_doc.format) a PSB, with or without transparency (none in the flat modes: bitmap, * indexed, multichannel, which have no layers); no colour is converted: * - one or more layers, bottom first, each a rectangle anywhere on or off the canvas (by default * the full canvas) or empty, with a name, opacity, visibility, blend key, clipping and flags; @@ -60,9 +60,11 @@ * - the composite (merged) image, which is what a reader that ignores layers shows: the caller's * own render (minipsd_doc.composite), the right way, since flattening layers is rendering; or, * without one, a best effort with no guarantee: the visible layers blended with straight-alpha - * "over" at their rectangles, clipped to the canvas. Its transparency rides as the first extra - * channel, flagged by a negative layer count, and its colour is matted against white - * (c * a + 255 * (1 - a)), as Photoshop stores it and readers expect to undo; + * "over" at their rectangles, clipped to the canvas. Its colour is matted against white + * (c * a + 255 * (1 - a)), as Photoshop stores it and readers expect to undo; its transparency, + * when it has one (minipsd_doc.composite_alpha), rides as the first extra channel, flagged by a + * negative layer count. Without layers and without transparency the file is flat, as + * Photoshop saves an image without layers; * - the resolution, in pixels per inch (resource 1005); * - an optional ICC profile (resource 1039); without one, readers assume sRGB; * - resources and the global layer mask and blocks kept from a file, as they are. @@ -79,8 +81,9 @@ * Bytes come out in order, never seeking back, so a pipe or a socket works as well as a file: * pulled by the caller into its own buffer (minipsd_writer_fill), or pushed to a callback * (minipsd_write). The pull writer allocates nothing; its workspace, from the caller, holds a - * size table (layer_count * 6 numbers), the composite's row sizes and one composite row. - * minipsd_write allocates that workspace once. Layer pixels and a supplied composite are read + * size table (layer_count * 7 numbers), the composite's row sizes and one composite row, and, + * when a layer is ZIP, its largest channel twice (raw, then deflated). minipsd_write allocates + * that workspace once, with a buffer for the widest row. Layer pixels and a supplied composite are read * in place, never copied whole. * * \par Project @@ -108,12 +111,12 @@ * The single source of the project version: CMakeLists.txt reads these three lines. */ #define MINIPSD_VERSION_MAJOR 0 -#define MINIPSD_VERSION_MINOR 3 -#define MINIPSD_VERSION_PATCH 1 +#define MINIPSD_VERSION_MINOR 4 +#define MINIPSD_VERSION_PATCH 0 #define MPSD__STR(x) #x #define MPSD__XSTR(x) MPSD__STR(x) -/** \brief The version as a string literal, e.g. "0.3.1". */ +/** \brief The version as a string literal, e.g. "0.4.0". */ #define MINIPSD_VERSION_STRING \ MPSD__XSTR(MINIPSD_VERSION_MAJOR) "." MPSD__XSTR(MINIPSD_VERSION_MINOR) "." MPSD__XSTR(MINIPSD_VERSION_PATCH) @@ -233,16 +236,19 @@ enum { }; /** - * \brief Channel compression. Zero is RLE, so a zero-initialised document gets the compact one. + * \brief Channel compression. Zero is the default: a document's RLE, the compact one; a layer's + * the document's. * * ZIP is for the layers' and masks' channels, through the caller's deflate - * (minipsd_write_opts.deflate); the composite is then RLE, as Photoshop writes it. + * (minipsd_write_opts.deflate); the composite is RLE then, as Photoshop writes it, and raw only in + * a raw document. */ enum { - MINIPSD_RLE = 0, /*!< PackBits run-length encoding. */ - MINIPSD_RAW = 1, /*!< Uncompressed. */ - MINIPSD_ZIP = 2, /*!< zlib, through the caller's deflate. */ - MINIPSD_ZIP_PREDICTED = 3 /*!< zlib of each sample's difference from its left neighbour, + MINIPSD_COMPRESSION_DEFAULT = 0, /*!< A document's: RLE; a layer's: the document's. */ + MINIPSD_RLE = 1, /*!< PackBits run-length encoding. */ + MINIPSD_RAW = 2, /*!< Uncompressed. */ + MINIPSD_ZIP = 3, /*!< zlib, through the caller's deflate. */ + MINIPSD_ZIP_PREDICTED = 4 /*!< zlib of each sample's difference from its left neighbour, as Photoshop writes its 16-bit layers. */ }; @@ -374,8 +380,9 @@ typedef struct minipsd_layer { /* NOLINT(clang-analyzer-optin.performance.Paddi const uint8_t *pixels; /*!< (right - left) * (bottom - top) pixels, rows top down, each the mode's colour channels then transparency (RGB: R, G, B, A), straight (not premultiplied) alpha, as minipsd_target_interleaved - decodes them; NULL means an empty layer. A sample is a byte, or - with depth 16 a uint16_t in host order (two bytes). */ + decodes them; without transparency (no_alpha) the colour + channels only. NULL means an empty layer. A sample is a byte, + or with depth 16 a uint16_t in host order (two bytes). */ uint8_t opacity; /*!< 255 is opaque, 0 is fully transparent. */ uint8_t hidden; /*!< Non-zero: the layer starts hidden (and stays out of the composite). */ int32_t top, left, bottom, right; /*!< The layer's rectangle on the canvas, as in @@ -418,12 +425,21 @@ typedef struct minipsd_layer { /* NOLINT(clang-analyzer-optin.performance.Paddi file's names say, they are written as they are, this one and a kept 'luni'; once it changes, the writer's own. Size 0 and no kept 'luni': the writer's own. */ + uint8_t no_alpha; /*!< Non-zero: the layer has no transparency: pixels hold the + colour channels only, and it is written without a transparency + channel (composed as opaque). A stored layer says it by its + transparency of size 0 instead. minipsd_read sets it for a + layer whose file has none. */ + int compression; /*!< This layer's channels, its mask's too: MINIPSD_RLE, _RAW, + _ZIP or _ZIP_PREDICTED (a ZIP one with + minipsd_write_opts.deflate); 0 (MINIPSD_COMPRESSION_DEFAULT): + the document's. Stored channels keep their own. */ } minipsd_layer; /** * \brief A whole document: canvas, resolution, layers and options. * - * Zero-initialise it and set the fields you need; zero compression selects RLE. Pointers are + * Zero-initialise it and set the fields you need; zero compression is the default, RLE. Pointers are * borrowed for the duration of the write call. */ typedef struct minipsd_doc { @@ -435,11 +451,14 @@ typedef struct minipsd_doc { a flat file, its image the composite. */ const uint8_t *icc; /*!< Optional ICC profile; NULL means none (sRGB). */ size_t icc_size; /*!< Size of icc in bytes; 0 when icc is NULL. */ - int compression; /*!< MINIPSD_RLE, MINIPSD_RAW, or MINIPSD_ZIP(_PREDICTED) - with minipsd_write_opts.deflate. */ + int compression; /*!< The layers' channels: MINIPSD_RLE (or 0, the + default), MINIPSD_RAW, or MINIPSD_ZIP(_PREDICTED) + with minipsd_write_opts.deflate; a layer may have + its own (minipsd_layer.compression). The + composite: raw when this is, else RLE. */ const uint8_t *composite; /*!< The composite (merged) image: width * height - pixels laid out as a layer's (the mode's colour - channels then transparency), rows top down, + pixels, the mode's colour channels then, with + composite_alpha, transparency; rows top down, straight alpha, written whatever the layers hold. NULL: composite_stored is copied; without it too, composed from the @@ -491,13 +510,15 @@ typedef struct minipsd_doc { (composite_alpha) and alpha or spot channels, in the file's order. Ignored without composite_stored. */ - uint8_t composite_alpha; /*!< Non-zero: composite_stored's fourth channel is - transparency, and the layer count is written - negative; zero: any fourth channel is an alpha - channel, the count positive. Ignored without - composite_stored: a supplied or composed - composite always has transparency. minipsd_read - sets the file's. */ + uint8_t composite_alpha; /*!< Non-zero: the composite has transparency, and the + layer count is written negative: a supplied + composite's pixels end with it, a composed one + keeps the layers', composite_stored's channel + after the colour ones is it. Zero: the colour + channels only, the count positive (a composed one + matted against white; a stored one's further + channels are alpha channels). Not in the flat + modes. minipsd_read sets the file's. */ uint16_t color_mode; /*!< MINIPSD_MODE_*: what the samples are; 0 is RGB (bitmap with depth 1). minipsd_read sets the file's. */ @@ -710,8 +731,8 @@ MINIPSDDEF int minipsd_doc_check(const minipsd_doc *doc, const minipsd_write_opt /** * \brief Writes a document as PSD (or PSB) through a callback. * - * The callback receives the whole file front to back, in pieces of up to 4 KiB, and is never - * asked to seek. After it returns 0 it is not called again. The same document always produces + * The callback receives the whole file front to back, in pieces of up to 4 KiB (or of the + * widest row's MINIPSD_ROW_BOUND, when that is more), and is never asked to seek. After it returns 0 it is not called again. The same document always produces * the same bytes. This is the pull writer driven to the end on a workspace from MINIPSD_MALLOC. * * \param doc The document to write. @@ -791,8 +812,10 @@ MINIPSDDEF uint64_t minipsd_writer_total(const minipsd_writer *w); * order, and returns at once: the writer never waits on anything, so it suits a non-blocking * target as well as a blocking one. A target that took only part of the bytes is the caller's * business: keep the rest and send it before calling again. Any cap works, 1 included; a call - * re-encodes the row it stops in, so a buffer of a few rows (64 KiB is plenty) keeps that cost - * negligible. The bytes are those minipsd_write produces for the same document. + * re-encodes the row it stops in from its start, so a buffer of at least the widest row + * (MINIPSD_ROW_BOUND of its width and the depth: a row is one channel's, so 64 KiB holds every + * PSD row at 8 and 16 bits, and rows to about 16 000 px at 32) cuts each row at most once. + * The bytes are those minipsd_write produces for the same document. * * \param w A writer from minipsd_writer_init. * \param buf Receives up to cap bytes. @@ -804,6 +827,84 @@ MINIPSDDEF uint64_t minipsd_writer_total(const minipsd_writer *w); * \retval MINIPSD_ERR_ARG w or produced is NULL, or buf is NULL with cap above 0. */ MINIPSDDEF int minipsd_writer_fill(minipsd_writer *w, void *buf, size_t cap, size_t *produced); + +/** \brief The bytes a row of width samples of depth bits (1, 8, 16, 32) takes in a file, raw. */ +#define MINIPSD_ROW_BYTES(width, depth) (((uint64_t)(width) * (uint64_t)(depth) + 7) / 8) + +/** \brief The most bytes minipsd_encode_row writes for that row: raw, or PackBits' worst case, a + header byte for each 128 bytes. */ +#define MINIPSD_ROW_BOUND(width, depth) (MINIPSD_ROW_BYTES(width, depth) + (MINIPSD_ROW_BYTES(width, depth) + 127) / 128) + +/** + * \brief Encodes one row of one channel as minipsd_write encodes it: PackBits, or raw, its samples + * big-endian as the file stores them. + * + * With every row of a channel encoded so, its compression marker (two bytes, big-endian: 1 for + * PackBits, 0 raw) and, for PackBits, each row's size ahead of the rows (two bytes in a PSD, four + * in a PSB), they are the channel as a file stores it: what minipsd_layer.stored and + * minipsd_doc.composite_stored take, written then as they are. A program that holds its image + * whole or reads it once, top to bottom, encodes each channel once this way, where minipsd_write, + * which keeps nothing between pieces, encodes a row again for its size. + * + * \param compression MINIPSD_RLE (or 0, the default) or MINIPSD_RAW. + * \param depth Bits a sample: 8, 16 (a uint16_t in host order), 32 (a float); at 1, the + * row already packed, eight pixels a byte, step 1. + * \param samples The row's first sample. + * \param step Bytes from a sample to the next: the pixel's size for one channel out of + * interleaved pixels. + * \param width Samples in the row. + * \param out Receives the row; cap bytes, MINIPSD_ROW_BOUND(width, depth) always enough. + * \param cap The size of out. + * \param size Receives the bytes written; with MINIPSD_ERR_SIZE, the bytes it needs. + * \return MINIPSD_OK or an error code. + * + * \retval MINIPSD_ERR_ARG samples, out or size is NULL, the compression is not MINIPSD_RLE or + * MINIPSD_RAW, or the depth not 1, 8, 16 or 32. + * \retval MINIPSD_ERR_SIZE The row does not fit in cap bytes. + */ +MINIPSDDEF int minipsd_encode_row(int compression, int depth, const void *samples, size_t step, uint32_t width, + uint8_t *out, size_t cap, size_t *size); + +/** + * \brief Mattes a row of a composite against white in place, as minipsd_write stores a supplied + * composite with transparency: c * a + max * (max - a), rounded (at 32 bits c * a + 1 - a). + * + * minipsd_write mattes an RGB composite only; every other mode is left as given, and so it is + * here. A program that encodes a composite itself (minipsd_encode_row, for composite_stored) + * mattes each row first, as minipsd_write would. + * + * \param color_mode MINIPSD_MODE_*. + * \param depth Bits a sample: 8, 16 or 32. + * \param pixels width pixels: the mode's colour channels, then transparency. + * \param width Pixels in the row. + * \return MINIPSD_OK, or MINIPSD_ERR_ARG when pixels is NULL or the depth not 8, 16 or 32. + */ +MINIPSDDEF int minipsd_matte_row(int color_mode, int depth, void *pixels, uint32_t width); + +/** + * \brief A row of one channel as minipsd_write hands it to the deflate for MINIPSD_ZIP_PREDICTED: + * its samples big-endian, each the difference from its left neighbour. + * + * At 8 bits the differences are the bytes'; at 16 the samples'; at 32 the row is first shuffled + * (every sample's first byte, then every second byte, and so on) and the differences are the + * shuffled bytes'. Every row of a channel so, deflated as one zlib stream after the compression + * marker (two bytes, big-endian: 3), is the channel as a file stores it, for + * minipsd_layer.stored. For MINIPSD_ZIP, without prediction, the rows are minipsd_encode_row's + * raw ones. A program that deflates as rows come (zlib's deflate, fed row by row) holds no raw + * channel. + * + * \param depth Bits a sample: 8, 16 (a uint16_t in host order) or 32 (a float). + * \param samples The row's first sample. + * \param step Bytes from a sample to the next, as minipsd_encode_row's. + * \param width Samples in the row. + * \param out Receives the row: MINIPSD_ROW_BYTES(width, depth) bytes. + * \param cap The size of out. + * \return MINIPSD_OK or an error code. + * + * \retval MINIPSD_ERR_ARG samples or out is NULL, or the depth not 8, 16 or 32. + * \retval MINIPSD_ERR_SIZE cap is less than MINIPSD_ROW_BYTES(width, depth). + */ +MINIPSDDEF int minipsd_predict_row(int depth, const void *samples, size_t step, uint32_t width, uint8_t *out, size_t cap); #endif /* MINIPSD_NO_WRITER */ #ifndef MINIPSD_NO_READER @@ -1123,8 +1224,9 @@ typedef struct minipsd_decode_opts { int keep_matte; /*!< Composite only: non-zero keeps the white matte, as stored. */ int depth; /*!< The target's bits a sample, which must be the file's (minipsd_info.depth): 1, 0 or 8, 16, 32 (see minipsd_plane). */ - minipsd_inflate_fn inflate; /*!< Layers and masks: decodes ZIP channels (compression 2, and - 3 with prediction); NULL leaves them MINIPSD_ERR_UNSUPPORTED. */ + minipsd_inflate_fn inflate; /*!< Layers and masks: decodes ZIP channels (the file's marker 2, + and 3 with prediction: MINIPSD_ZIP and MINIPSD_ZIP_PREDICTED + when written); NULL leaves them MINIPSD_ERR_UNSUPPORTED. */ void *inflate_user; /*!< Passed to inflate. */ uint8_t *scratch; /*!< With inflate: where a ZIP channel is inflated whole, even for a band; at least the layer's (or mask's) height times rows of diff --git a/include/minipsd/detail/composite.h b/include/minipsd/detail/composite.h index 601786f..cc3abd8 100644 --- a/include/minipsd/detail/composite.h +++ b/include/minipsd/detail/composite.h @@ -45,6 +45,23 @@ MPSD__SHARED static int mpsd__doc_flat(const minipsd_doc *d) MPSD__SHARED static unsigned mpsd__doc_colors(const minipsd_doc *d) { return mpsd__colors(mpsd__doc_mode(d), d->composite_channels); } MPSD__SHARED static unsigned mpsd__doc_pixel(const minipsd_doc *d) { return mpsd__doc_colors(d) + !mpsd__doc_flat(d); } +/* The samples of a layer's pixel: the colour channels, then transparency unless it has none. */ +MPSD__SHARED static unsigned mpsd__layer_pixel(const minipsd_doc *d, const minipsd_layer *l) { return mpsd__doc_colors(d) + !l->no_alpha; } + +/* The samples of a supplied composite's pixel: the colour channels, then transparency when it has + one (never in the flat modes). */ +MPSD__SHARED static unsigned mpsd__supplied_pixel(const minipsd_doc *d) +{ + return mpsd__doc_colors(d) + (d->composite_alpha && !mpsd__doc_flat(d)); +} + +/* The samples a pixel of mpsd__composite_row's row takes: a supplied composite without + transparency is handed out as it is; every other row is a full pixel. */ +MPSD__SHARED static unsigned mpsd__composite_step(const minipsd_doc *d) +{ + return d->composite ? mpsd__supplied_pixel(d) : mpsd__doc_pixel(d); +} + MPSD__SHARED static int mpsd__contributes(const minipsd_layer *l) { return l->pixels && !l->hidden && l->opacity; } /* A layer's rectangle as written, and the size of its pixels: 0 by 0 for a layer without pixels or @@ -80,7 +97,8 @@ MPSD__SHARED static void mpsd__composite_init(mpsd__composite *c, const minipsd_ # define MPSD__COMPOSED() ((void)0) #endif -/* The visible layers' row y blended "over" into row, straight alpha: the best effort. */ +/* The visible layers' row y blended "over" into row, straight alpha: the best effort. A layer + without transparency counts as opaque. */ MPSD__SHARED static void mpsd__compose(const minipsd_doc *d, uint8_t *row, uint32_t y) { const unsigned wide = mpsd__doc_shift(d), pixel = (unsigned)MPSD__RGBA << wide; @@ -95,13 +113,15 @@ MPSD__SHARED static void mpsd__compose(const minipsd_doc *d, uint8_t *row, uint3 const int64_t ly = (int64_t)y - r.top; const int64_t x0 = r.left > 0 ? r.left : 0; const int64_t x1 = r.right < (int64_t)d->width ? r.right : (int64_t)d->width; + const unsigned lp = mpsd__layer_pixel(d, l) << wide; const uint8_t *src; if (!mpsd__contributes(l) || ly < 0 || ly >= (int64_t)r.height) continue; - src = l->pixels + (size_t)ly * r.width * pixel; + src = l->pixels + (size_t)ly * r.width * lp; for (x = x0; x < x1; ++x) { uint8_t *o = row + (size_t)x * pixel; - const uint8_t *px = src + (size_t)(x - r.left) * pixel; - const float sa = (float)mpsd__sample(px + ((unsigned)MINIPSD_CH_A << wide), wide) / max * (l->opacity / (float)MPSD__FULL_8); + const uint8_t *px = src + (size_t)(x - r.left) * lp; + const float la = l->no_alpha ? max : (float)mpsd__sample(px + ((unsigned)MINIPSD_CH_A << wide), wide); + const float sa = la / max * (l->opacity / (float)MPSD__FULL_8); const float da = (float)mpsd__sample(o + ((unsigned)MINIPSD_CH_A << wide), wide) / max; const float oa = sa + da * (1.0f - sa); unsigned k; @@ -116,10 +136,32 @@ MPSD__SHARED static void mpsd__compose(const minipsd_doc *d, uint8_t *row, uint3 } } +/* width pixels of `pixel` samples matted against white in place, as the merged image is stored: + each of the colour channels before the alpha at `colors`, c * a + max * (max - a) rounded, or at + 32 bits (floats) c * a + 1 - a. */ +MPSD__SHARED static void mpsd__matte(uint8_t *row, uint32_t width, unsigned pixel, unsigned colors, unsigned wide, uint32_t max) +{ + uint32_t x; + for (x = 0; x < width && wide == MPSD__SHIFT_32; ++x) { /* floats: c * a + (1 - a) */ + uint8_t *o = row + ((size_t)x * pixel << wide); + const float a = mpsd__float(o + (colors << wide)); + unsigned k; + for (k = 0; k < colors; ++k) mpsd__set_float(o + (k << wide), mpsd__float(o + (k << wide)) * a + (1.0f - a)); + } + for (x = 0; x < width && wide != MPSD__SHIFT_32; ++x) { + uint8_t *o = row + ((size_t)x * pixel << wide); + const uint32_t a = mpsd__sample(o + (colors << wide), wide); + unsigned k; + for (k = 0; k < colors; ++k) /* at most max * max + max / 2: fits 32 bits */ + mpsd__set_sample(o + (k << wide), wide, (mpsd__sample(o + (k << wide), wide) * a + max * (max - a) + max / 2) / max); + } +} + /* The composite's row y as the file stores it, matted against white: a supplied row that is - already matted, or of another mode than RGB, as it is (bitmap packed into the caller's row); - otherwise the supplied row copied, or the layers composed, into the caller's row, then matted - there. */ + already matted, of another mode than RGB, or without transparency (nothing to matte), as it is + (bitmap packed into the caller's row), mpsd__composite_step samples a pixel; otherwise the + supplied row copied, or the layers composed, into the caller's row, then matted there, a full + pixel each, of which a composite without transparency writes the colour. */ MPSD__SHARED static const uint8_t *mpsd__composite_row(mpsd__composite *c, uint32_t y) { const minipsd_doc *d = c->doc; @@ -127,6 +169,8 @@ MPSD__SHARED static const uint8_t *mpsd__composite_row(mpsd__composite *c, uint3 const uint32_t max = mpsd__doc_full(d); const size_t size = (size_t)d->width * pixel; uint32_t x; + if (d->composite && mpsd__supplied_pixel(d) < mpsd__doc_pixel(d)) /* no transparency: colour as given */ + return d->composite + (size_t)y * d->width * (mpsd__supplied_pixel(d) << wide); if (mpsd__doc_depth(d) == MPSD__CH_1BIT) { /* bitmap: eight pixels a byte, the first the high bit, 1 black */ const uint8_t *in = d->composite + (size_t)y * d->width; memset(c->row, 0, (size_t)mpsd__row_bytes(d->width, MPSD__CH_1BIT)); @@ -138,19 +182,7 @@ MPSD__SHARED static const uint8_t *mpsd__composite_row(mpsd__composite *c, uint3 else if (d->composite_matted || mpsd__doc_mode(d) != MINIPSD_MODE_RGB) /* other modes: as given */ return d->composite + (size_t)y * size; else memcpy(c->row, d->composite + (size_t)y * size, size); - for (x = 0; x < d->width && wide == MPSD__SHIFT_32; ++x) { /* floats: c * a + (1 - a) */ - uint8_t *o = c->row + (size_t)x * pixel; - const float a = mpsd__float(o + ((unsigned)MINIPSD_CH_A << wide)); - unsigned k; - for (k = 0; k < MINIPSD_CH_A; ++k) mpsd__set_float(o + (k << wide), mpsd__float(o + (k << wide)) * a + (1.0f - a)); - } - for (x = 0; x < d->width && wide != MPSD__SHIFT_32; ++x) { /* the merged image is stored over white */ - uint8_t *o = c->row + (size_t)x * pixel; - const uint32_t a = mpsd__sample(o + ((unsigned)MINIPSD_CH_A << wide), wide); - unsigned k; - for (k = 0; k < MINIPSD_CH_A; ++k) /* at most max * max + max / 2: fits 32 bits */ - mpsd__set_sample(o + (k << wide), wide, (mpsd__sample(o + (k << wide), wide) * a + max * (max - a) + max / 2) / max); - } + mpsd__matte(c->row, d->width, mpsd__doc_pixel(d), MINIPSD_CH_A, wide, max); /* the merged image is stored over white */ return c->row; } diff --git a/include/minipsd/read.h b/include/minipsd/read.h index e138cbc..cc26404 100644 --- a/include/minipsd/read.h +++ b/include/minipsd/read.h @@ -128,7 +128,7 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit uint64_t decoded, canvas; uint32_t loss = 0; size_t i, count, n = 0; - unsigned wide, planes; + unsigned wide, planes, cplanes; uint32_t side; int rc, flat_mode; @@ -149,6 +149,7 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit } wide = r->shift; /* wider samples, aligned for their type */ planes = r->colors + !flat_mode; /* a pixel: the colour channels, then transparency */ + cplanes = r->colors + (!flat_mode && info.composite_has_alpha && info.channels > r->colors); /* the composite's, as the file has it */ side = mpsd__max_side(r->psb); memset(&all, 0, sizeof all); /* every row, un-matted; new fields zero */ all.depth = (int)r->depth; @@ -158,7 +159,7 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit all.scratch_size = (size_t)a->zip; /* measured: the block holds it */ as_stored = all; as_stored.keep_matte = 1; - canvas = (uint64_t)info.width * info.height * planes << wide; + canvas = (uint64_t)info.width * info.height * cplanes << wide; /* Stored: the composite copied as it is, every channel, never decoded; multichannel's always. */ keep_composite = ((opts && opts->stored) || info.color_mode == MINIPSD_MODE_MULTICHANNEL) && info.channels >= r->colors; decoded = keep_composite ? 0 : canvas; /* the composite */ @@ -182,6 +183,7 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit uint8_t *mask_pixels = NULL; uint32_t j; int k, keep; + unsigned lplanes; /* decoded: transparency only when the file has it */ rc = minipsd_reader_next_layer(r, &v); if (rc != MINIPSD_MORE) return rc == MINIPSD_OK ? MINIPSD_ERR_FORMAT : rc; if (v.other_channels) loss |= MINIPSD_LOST_CHANNELS; /* TODO: alpha and spot channels (ROADMAP, Not scheduled) */ @@ -198,12 +200,13 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit stored = (minipsd_bytes *)mpsd__take(a, planes * sizeof *stored, 1); for (k = 0; k < (int)planes; ++k) copies[k] = mpsd__copy(a, v.channel[k]); } - pixels = w && h && !keep ? (uint8_t *)mpsd__take(a, w * h * planes << wide, (int)wide) : NULL; + lplanes = r->colors + (v.channel[r->colors].size != 0); + pixels = w && h && !keep ? (uint8_t *)mpsd__take(a, w * h * lplanes << wide, (int)wide) : NULL; extra = mpsd__copy(a, v.additional_info); /* every block, 'luni' too */ pascal = mpsd__copy(a, v.name_pascal); ranges = (minipsd_blending_range *)mpsd__take(a, (uint64_t)v.blending_range_count * sizeof *ranges, 1); - if (pixels) decoded += w * h * planes << wide; - for (k = 0; w && h && !keep && k < (int)planes; ++k) /* decoded: pixels, NULL while measuring */ + if (pixels) decoded += w * h * lplanes << wide; + for (k = 0; w && h && !keep && k < (int)lplanes; ++k) /* decoded: pixels, NULL while measuring */ mpsd__zip_room(a, v.channel[k], w * h << wide); if (v.has_mask) { /* the mask: decoded, or stored with the layer */ const uint64_t mw = (uint64_t)((int64_t)v.mask.right - v.mask.left), mh = (uint64_t)((int64_t)v.mask.bottom - v.mask.top); @@ -215,6 +218,7 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit kept.stored.data = NULL; kept.stored.size = 0; if (copy) kept.stored = mpsd__copy(a, v.mask.stored); + if (mw > side || mh > side) return MINIPSD_ERR_UNSUPPORTED; /* the writer's limit, as a layer's */ if (!copy && mw && mh) { mask_pixels = (uint8_t *)mpsd__take(a, mw * mh << wide, (int)wide); /* NULL while measuring */ decoded += mw * mh << wide; @@ -228,12 +232,13 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit if (!d) continue; (void)minipsd_layer_name(&v, name, n, NULL); if (pixels) { - mpsd__target(&t, pixels, (size_t)(w * planes << wide), planes, wide); + mpsd__target(&t, pixels, (size_t)(w * lplanes << wide), lplanes, wide); rc = minipsd_decode_layer(r, &v, &t, &all); if (rc != MINIPSD_OK) return rc; } layers[i].name = name; layers[i].pixels = pixels; + layers[i].no_alpha = (uint8_t)(lplanes == r->colors); /* an empty one's too: its channels as the file's */ layers[i].opacity = v.opacity; layers[i].hidden = v.hidden; layers[i].top = v.top; layers[i].left = v.left; @@ -295,16 +300,17 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit if (!d) return MINIPSD_OK; if (pixels) { - mpsd__target(&t, pixels, (size_t)info.width * planes << wide, planes, wide); + mpsd__target(&t, pixels, (size_t)info.width * cplanes << wide, cplanes, wide); rc = minipsd_decode_composite(r, &t, &as_stored); if (rc != MINIPSD_OK) return rc; } - if (flat) { - mpsd__target(&t, flat, (size_t)info.width * planes << wide, planes, wide); - rc = minipsd_decode_composite(r, &t, &all); + if (flat) { /* un-matted; without transparency there is no matte to undo */ + mpsd__target(&t, flat, (size_t)info.width * cplanes << wide, cplanes, wide); + rc = minipsd_decode_composite(r, &t, cplanes > r->colors ? &all : &as_stored); if (rc != MINIPSD_OK) return rc; layers[0].name = "Background"; layers[0].pixels = flat; + layers[0].no_alpha = (uint8_t)(cplanes == r->colors); layers[0].opacity = 255; layers[0].bottom = (int32_t)info.height; /* the rectangle a reader then sees */ layers[0].right = (int32_t)info.width; @@ -320,7 +326,7 @@ static int mpsd__read(const void *data, size_t size, const minipsd_limits *limit d->composite_matted = 1; d->composite_stored = composite; d->composite_channels = keep_composite ? (uint16_t)info.channels : 0; - d->composite_alpha = (uint8_t)(keep_composite && !flat_mode && info.composite_has_alpha && info.channels > r->colors); + d->composite_alpha = (uint8_t)(cplanes > r->colors); /* stored or decoded, as the file has it */ { /* the file's pixel aspect ratio, kept when it is one the writer takes; else 0: none of its own */ minipsd_resource aspect; const double ratio = info.pixel_aspect_ratio; diff --git a/include/minipsd/reader.h b/include/minipsd/reader.h index eef3e0f..d1b2da0 100644 --- a/include/minipsd/reader.h +++ b/include/minipsd/reader.h @@ -732,6 +732,8 @@ static int mpsd__chan_open(const minipsd_reader *r, minipsd_bytes c, uint32_t wi const uint64_t plane = mpsd__row_bytes(width, r->depth) * height; /* the raw rows' */ uint64_t left, sum = 0; uint32_t y; + if (width > MINIPSD_MAX_SIDE_PSB || height > MINIPSD_MAX_SIDE_PSB) /* above either format's: plane could wrap */ + return MINIPSD_ERR_UNSUPPORTED; left = c.size - MPSD__MARKER_SIZE; /* next_layer saw the marker */ mpsd__chan_init(r, ch); switch (mpsd__get16(c.data)) { diff --git a/include/minipsd/writer.h b/include/minipsd/writer.h index 3685afb..98664f6 100644 --- a/include/minipsd/writer.h +++ b/include/minipsd/writer.h @@ -53,23 +53,101 @@ static void mpsd__raw(mpsd__sink *s, const uint8_t *src, size_t n, size_t stride } } -/* One row of one channel, width samples of the document's depth, step bytes apart; at 1 bit - already packed, a byte apart. */ -static void mpsd__row(mpsd__sink *s, const minipsd_doc *d, const uint8_t *row, size_t step, uint32_t width) +/* One row of one channel, width samples of depth bits, step bytes apart; at 1 bit already packed, + a byte apart: raw with MINIPSD_RAW, else PackBits. */ +static void mpsd__row_as(mpsd__sink *s, int compression, unsigned depth, const uint8_t *row, size_t step, uint32_t width) { - const unsigned wide = mpsd__doc_shift(d); - const size_t n = (size_t)mpsd__row_bytes(width, mpsd__doc_depth(d)); - if (d->compression == MINIPSD_RAW) mpsd__raw(s, row, n, step, wide); - else mpsd__packbits(s, row, n, step, wide); + const unsigned wide = mpsd__shift(depth); + const size_t n = (size_t)mpsd__row_bytes(width, depth); + if (compression == MINIPSD_RAW) mpsd__raw(s, row, n, step, wide); + else mpsd__packbits(s, row, n, step, wide); +} + +/* ... of the document's depth. */ +static void mpsd__row(mpsd__sink *s, const minipsd_doc *d, int compression, const uint8_t *row, size_t step, uint32_t width) +{ + mpsd__row_as(s, compression, mpsd__doc_depth(d), row, step, width); +} + +/* A compression as written: the default is RLE. */ +static int mpsd__or_rle(int compression) { return compression == MINIPSD_COMPRESSION_DEFAULT ? MINIPSD_RLE : compression; } + +/* The compression of layer l's channels: its own, or else the document's. */ +static int mpsd__layer_compression(const minipsd_doc *d, const minipsd_layer *l) +{ + return mpsd__or_rle(l->compression != MINIPSD_COMPRESSION_DEFAULT ? l->compression : d->compression); +} + +/* The composite's: raw in a raw document, else RLE (ZIP is for layers, as Photoshop has it). */ +static int mpsd__composite_compression(const minipsd_doc *d) { return mpsd__or_rle(d->compression) == MINIPSD_RAW ? MINIPSD_RAW : MINIPSD_RLE; } + +/* A sink callback into a buffer of cap bytes; full, it takes nothing more (the sink keeps counting). */ +typedef struct mpsd__span { + uint8_t *out; + size_t cap, used; +} mpsd__span; + +static int mpsd__span_out(void *user, const void *data, size_t size) +{ + mpsd__span *b = (mpsd__span *)user; + if (size > b->cap - b->used) return 0; + memcpy(b->out + b->used, data, size); + b->used += size; + return 1; +} + +MINIPSDDEF int minipsd_encode_row(int compression, int depth, const void *samples, size_t step, uint32_t width, + uint8_t *out, size_t cap, size_t *size) +{ + mpsd__span b; + mpsd__sink s; + compression = mpsd__or_rle(compression); + if (!samples || !out || !size || (compression != MINIPSD_RLE && compression != MINIPSD_RAW) || !mpsd__depth_ok((uint32_t)depth)) + return MINIPSD_ERR_ARG; + b.out = out; b.cap = cap; b.used = 0; + s.fn = mpsd__span_out; s.user = &b; s.count = 0; s.failed = 0; + mpsd__row_as(&s, compression, (unsigned)depth, (const uint8_t *)samples, step, width); + *size = (size_t)s.count; /* past cap: what it needs */ + return s.failed ? MINIPSD_ERR_SIZE : MINIPSD_OK; } +/* A row of samples, step bytes apart, as a ZIP channel's raw row: the file's bytes (big-endian), + shuffled at 32 bits and made differences when predicted. */ +static void mpsd__zip_row(uint8_t *out, const uint8_t *row, size_t step, uint32_t width, unsigned depth, int predicted) +{ + const unsigned wide = mpsd__shift(depth); + const size_t bytes = (size_t)width << wide; + size_t x; + for (x = 0; x < bytes; ++x) /* at 32 bits predicted: shuffled, as the format wants */ + out[predicted && wide == MPSD__SHIFT_32 ? mpsd__shuffled(x, width) : x] = row[mpsd__at(x, (ptrdiff_t)step, wide)]; + if (predicted) mpsd__predict(out, width, depth); +} + +MINIPSDDEF int minipsd_predict_row(int depth, const void *samples, size_t step, uint32_t width, uint8_t *out, size_t cap) +{ + if (!samples || !out || depth == MPSD__CH_1BIT || !mpsd__depth_ok((uint32_t)depth)) return MINIPSD_ERR_ARG; + if (cap < mpsd__row_bytes(width, (unsigned)depth)) return MINIPSD_ERR_SIZE; + mpsd__zip_row(out, (const uint8_t *)samples, step, width, (unsigned)depth, 1); + return MINIPSD_OK; +} + +MINIPSDDEF int minipsd_matte_row(int color_mode, int depth, void *pixels, uint32_t width) +{ + if (!pixels || depth == MPSD__CH_1BIT || !mpsd__depth_ok((uint32_t)depth)) return MINIPSD_ERR_ARG; + if (color_mode == MINIPSD_MODE_RGB) /* the writer mattes RGB only; every other mode as given */ + mpsd__matte((uint8_t *)pixels, width, MPSD__RGBA, MINIPSD_CH_A, mpsd__shift((unsigned)depth), mpsd__full((unsigned)depth)); + return MINIPSD_OK; +} + +#define MPSD__MAX_COUNT_PSD 0xFFFFu /* a PSD's RLE row count: two bytes */ + /* The compression marker a channel the writer encodes starts with. */ -static unsigned mpsd__marker(const minipsd_doc *d) { return d->compression == MINIPSD_RAW ? MPSD__COMP_RAW : MPSD__COMP_RLE; } +static unsigned mpsd__marker(int compression) { return compression == MINIPSD_RAW ? MPSD__COMP_RAW : MPSD__COMP_RLE; } -static uint64_t mpsd__row_size(const minipsd_doc *d, const uint8_t *row, size_t step, uint32_t width) +static uint64_t mpsd__row_size(const minipsd_doc *d, int compression, const uint8_t *row, size_t step, uint32_t width) { mpsd__sink c = { NULL, NULL, 0, 0 }; - mpsd__row(&c, d, row, step, width); + mpsd__row(&c, d, compression, row, step, width); return c.count; } @@ -88,8 +166,9 @@ static int mpsd__channel_id(const minipsd_doc *d, int k) channels first, transparency last. */ static int mpsd__channel_of(const minipsd_doc *d, int k) { return k ? k - 1 : (int)mpsd__doc_colors(d); } -/* The channels written are [mpsd__first_channel, mpsd__channel_count) of that order: a stored - layer without transparency, as its file had it, starts at the first colour channel. */ +/* The channels written are [mpsd__first_channel, mpsd__channel_count) of that order: a layer + without transparency (stored without it, as its file had it, or no_alpha) starts at the first + colour channel. */ static int mpsd__channel_count(const minipsd_doc *d, const minipsd_layer *l) { return mpsd__ch_mask(d) + (l->mask ? 1 + (l->mask->real.size != 0) : 0); @@ -97,7 +176,7 @@ static int mpsd__channel_count(const minipsd_doc *d, const minipsd_layer *l) static int mpsd__first_channel(const minipsd_doc *d, const minipsd_layer *l) { - return l->stored && !l->stored[mpsd__doc_colors(d)].size; + return l->stored ? !l->stored[mpsd__doc_colors(d)].size : l->no_alpha != 0; } /* Where channel k of a layer comes from: bytes to copy as they are, or samples to encode, step @@ -139,8 +218,8 @@ static mpsd__source mpsd__channel_source(const minipsd_doc *d, const minipsd_lay src.samples = r.height ? full : NULL; src.step = 0; } else { - src.samples = r.height ? l->pixels + ((size_t)at << wide) : NULL; - src.step = (size_t)mpsd__doc_pixel(d) << wide; + src.samples = r.height ? l->pixels + ((size_t)at << wide) : NULL; /* transparency: never asked without */ + src.step = (size_t)mpsd__layer_pixel(d, l) << wide; } src.width = r.width; src.height = r.height; @@ -173,8 +252,17 @@ static uint64_t mpsd__stored_size(const mpsd__source *src, unsigned from, unsign return src->stored->size - (uint64_t)src->height * mpsd__count_size(from) + (uint64_t)src->height * mpsd__count_size(out); } -/* Does the document compress its layers' and masks' channels with the caller's deflate? */ -static int mpsd__zip(const minipsd_doc *d) { return d->compression == MINIPSD_ZIP || d->compression == MINIPSD_ZIP_PREDICTED; } +/* Is a compression the caller's deflate? */ +static int mpsd__zip(int compression) { return compression == MINIPSD_ZIP || compression == MINIPSD_ZIP_PREDICTED; } + +/* Does the document deflate anything: its compression, or a layer's? */ +static int mpsd__deflates(const minipsd_doc *d) +{ + size_t i; + for (i = 0; i < d->layer_count; ++i) + if (mpsd__zip(mpsd__layer_compression(d, &d->layers[i]))) return 1; + return mpsd__zip(d->compression); +} /* The largest channel the writer deflates, raw: its bytes, 0 for none. */ static uint64_t mpsd__zip_plane(const minipsd_doc *d) @@ -182,8 +270,9 @@ static uint64_t mpsd__zip_plane(const minipsd_doc *d) uint64_t max = 0; size_t i; int k; - for (i = 0; mpsd__zip(d) && i < d->layer_count; ++i) - for (k = 0; k <= mpsd__ch_mask(d) && k < mpsd__channel_count(d, &d->layers[i]); ++k) { + for (i = 0; i < d->layer_count; ++i) + for (k = 0; mpsd__zip(mpsd__layer_compression(d, &d->layers[i])) && k <= mpsd__ch_mask(d) + && k < mpsd__channel_count(d, &d->layers[i]); ++k) { const mpsd__source src = mpsd__channel_source(d, &d->layers[i], k); const uint64_t n = src.samples ? (uint64_t)src.width * src.height << mpsd__doc_shift(d) : 0; if (n > max) max = n; @@ -197,16 +286,19 @@ static uint64_t mpsd__deflate_room(uint64_t n) { return n + (n >> 11) + 16; } /* Is the composite copied as the file stored it? Only when the caller gives none of their own. */ static int mpsd__copies_composite(const minipsd_doc *d) { return !d->composite && d->composite_stored.size; } -/* The composite's channels in the file: the stored one's, or R, G, B and transparency. */ +/* Has the composite transparency, its channel after the colour ones? As composite_alpha says, + whichever the composite: supplied, composed or stored. The layer count says it, negative, or + where it cannot (mpsd__alpha_uncounted) a 'Mtrn' ('Mt16', 'Mt32') block and the channel's + names. */ +static int mpsd__composite_alpha(const minipsd_doc *d) { return d->composite_alpha && !mpsd__doc_flat(d); } + +/* The composite's channels in the file: the stored one's, or the colour channels and, with it, + transparency. */ static unsigned mpsd__composite_channels(const minipsd_doc *d) { - return mpsd__copies_composite(d) ? d->composite_channels : mpsd__doc_pixel(d); + return mpsd__copies_composite(d) ? d->composite_channels : mpsd__doc_colors(d) + (unsigned)mpsd__composite_alpha(d); } -/* Is the composite's fourth channel transparency, the layer count negative? Always for a - supplied or composed one. */ -static int mpsd__composite_alpha(const minipsd_doc *d) { return !mpsd__copies_composite(d) || d->composite_alpha; } - /* No source: the pieces outside a layer channel and the composite. */ static mpsd__source mpsd__no_source(void) { @@ -398,19 +490,24 @@ static void mpsd__layer_extra(mpsd__sink *s, const minipsd_layer *l, unsigned fr /* What layer channel k's data takes in the out format: its compression, then (RLE) the row byte counts, two bytes each or four in a PSB, then the rows; or the stored channel, its count table - re-written when the formats differ. */ + re-written when the formats differ. 0 (never a size: the marker alone is 2) when a row's count + does not fit a PSD's two bytes, as 32-bit rows wider than about 16 380 px may not. */ static uint64_t mpsd__layer_channel_size(const minipsd_doc *d, const minipsd_layer *l, int k, unsigned out) { const mpsd__source src = mpsd__channel_source(d, l, k); const unsigned from = mpsd__doc_psb(d); + const int compression = mpsd__layer_compression(d, l); uint64_t size = MPSD__MARKER_SIZE; uint32_t y; if (src.stored) return mpsd__stored_size(&src, from, out); if (!src.samples) return size; MPSD__ENCODED(l); - if (d->compression == MINIPSD_RAW) return size + ((uint64_t)src.width * src.height << mpsd__doc_shift(d)); - for (y = 0; y < src.height; ++y) - size += mpsd__count_size(out) + mpsd__row_size(d, src.samples + (size_t)y * src.width * src.step, src.step, src.width); + if (compression == MINIPSD_RAW) return size + ((uint64_t)src.width * src.height << mpsd__doc_shift(d)); + for (y = 0; y < src.height; ++y) { + const uint64_t n = mpsd__row_size(d, compression, src.samples + (size_t)y * src.width * src.step, src.step, src.width); + if (!out && n > MPSD__MAX_COUNT_PSD) return 0; + size += mpsd__count_size(out) + n; + } return size; } @@ -455,6 +552,16 @@ static uint64_t mpsd__max_layers_section(unsigned psb) { return psb ? MPSD__FAR static const minipsd_resource *mpsd__find_resource(const minipsd_doc *d, unsigned id); static int mpsd__aspect_kept(const minipsd_doc *d); +static int mpsd__names_transparency(const minipsd_doc *d); +static size_t mpsd__tail_block(const minipsd_doc *d, const char *a, const char *b, size_t *end); + +/* Photoshop's names for the composite's transparency, as its files hold them: the alpha channel + names (1006, a Pascal string; 1045, a count of UTF-16 units, then the name with its NUL) and + their identifiers (1053). */ +static const uint8_t mpsd__transparency_name[] = { 12, 'T', 'r', 'a', 'n', 's', 'p', 'a', 'r', 'e', 'n', 'c', 'y' }; +static const uint8_t mpsd__transparency_unicode[] = { 0, 0, 0, 13, 0, 'T', 0, 'r', 0, 'a', 0, 'n', 0, 's', 0, 'p', + 0, 'a', 0, 'r', 0, 'e', 0, 'n', 0, 'c', 0, 'y', 0, 0 }; +static const uint8_t mpsd__transparency_id[] = { 0, 0, 0, 0 }; /* A kept resource is written unless the document's own field says it: dpi, icc, and a pixel aspect ratio the document sets that no kept 1064 says. */ @@ -542,7 +649,8 @@ static void mpsd__pixel_aspect(mpsd__sink *s, const minipsd_doc *d) } /* The image resources: the writer's own first (the resolution, the ICC profile if any, the pixel - aspect ratio if set), then the kept ones. A kept one of these goes: the document's field says + aspect ratio if set, the transparency's names where the layer count cannot say it), then the + kept ones. A kept one of these goes: the document's field says it. */ static void mpsd__resources(mpsd__sink *s, const minipsd_doc *d) { @@ -553,6 +661,12 @@ static void mpsd__resources(mpsd__sink *s, const minipsd_doc *d) mpsd__resolution(s, d, res ? res->name : none); if (d->icc) mpsd__put_resource(s, MINIPSD_RES_ICC_PROFILE, icc ? icc->name : none, d->icc, d->icc_size); if (d->pixel_aspect_ratio > 0.0 && !mpsd__aspect_kept(d)) mpsd__pixel_aspect(s, d); + if (mpsd__names_transparency(d)) { + mpsd__put_resource(s, MINIPSD_RES_ALPHA_NAMES, none, mpsd__transparency_name, sizeof mpsd__transparency_name); + mpsd__put_resource(s, MINIPSD_RES_UNICODE_ALPHA_NAMES, none, mpsd__transparency_unicode, + sizeof mpsd__transparency_unicode); + mpsd__put_resource(s, MINIPSD_RES_ALPHA_IDENTIFIERS, none, mpsd__transparency_id, sizeof mpsd__transparency_id); + } for (i = 0; i < d->resource_count; ++i) if (mpsd__kept_resource(d, &d->resources[i])) mpsd__put_resource(s, d->resources[i].id, d->resources[i].name, d->resources[i].data.data, d->resources[i].data.size); @@ -594,6 +708,13 @@ static int mpsd__stored_ok(minipsd_bytes c, uint32_t width, uint32_t height, uns layers: the writer writes them there and leaves the layer info empty. What it held goes: the document's layers win. */ static size_t mpsd__layers_block(const minipsd_doc *d, size_t *end) +{ + return mpsd__tail_block(d, "Lr16", "Lr32", end); +} + +/* Where the first block of the kept tail keyed a or b starts, and where it ends (padded); both the + tail's size when there is none. */ +static size_t mpsd__tail_block(const minipsd_doc *d, const char *a, const char *b, size_t *end) { const minipsd_bytes t = d->layer_tail; const unsigned psb = mpsd__doc_psb(d); @@ -611,7 +732,7 @@ static size_t mpsd__layers_block(const minipsd_doc *d, size_t *end) size = mpsd__getlen(p + MPSD__BLOCK_LEN, head > MPSD__BLOCK_HEAD); if (size > left - head) return t.size; span = mpsd__pad((size_t)size, 4) < left - head ? mpsd__pad((size_t)size, 4) : left - head; - if (mpsd__tag(p + MPSD__BLOCK_KEY, "Lr16") || mpsd__tag(p + MPSD__BLOCK_KEY, "Lr32")) { *end = at + head + span; return at; } + if (mpsd__tag(p + MPSD__BLOCK_KEY, a) || mpsd__tag(p + MPSD__BLOCK_KEY, b)) { *end = at + head + span; return at; } at += head + span; } return t.size; @@ -663,6 +784,38 @@ static int mpsd__own_layers_block(const minipsd_doc *d) return mpsd__doc_depth(d) == MPSD__CH_32BIT && d->layer_count && mpsd__layers_block(d, &end) == d->layer_tail.size; } +/* Is the composite's transparency where the layer count cannot say it? In the writer's 'Lr32', + whose count is positive, and in a document with no layers, whose count is 0; a flat mode has + none. */ +static int mpsd__alpha_uncounted(const minipsd_doc *d) +{ + return mpsd__composite_alpha(d) && !mpsd__doc_flat(d) && (mpsd__own_layers_block(d) || !d->layer_count); +} + +/* Then the writer names it as Photoshop does, which GIMP reads it by, unless the document keeps + alpha channel names or identifiers of its own. */ +static int mpsd__names_transparency(const minipsd_doc *d) +{ + return mpsd__alpha_uncounted(d) && !mpsd__find_resource(d, MINIPSD_RES_ALPHA_NAMES) + && !mpsd__find_resource(d, MINIPSD_RES_UNICODE_ALPHA_NAMES) && !mpsd__find_resource(d, MINIPSD_RES_ALPHA_IDENTIFIERS); +} + +/* A document with no layers says it with an empty block after the tail, as the 'Lr32' one does + with 'Mt32': 'Mtrn', 'Mt16' or 'Mt32' by its depth (Adobe's "merged transparency"), unless the + kept tail has one. */ +static const char *mpsd__merged_key(const minipsd_doc *d) +{ + const unsigned depth = mpsd__doc_depth(d); + return depth == MPSD__CH_32BIT ? "Mt32" : depth == MPSD__CH_16BIT ? "Mt16" : "Mtrn"; +} + +static int mpsd__own_merged_block(const minipsd_doc *d) +{ + size_t end; + return !d->layer_count && mpsd__alpha_uncounted(d) && mpsd__tail_block(d, "Mtrn", "Mt16", &end) == d->layer_tail.size + && mpsd__tail_block(d, "Mt32", "Mt32", &end) == d->layer_tail.size; +} + /* A block of the writer's own, of no data or of size bytes that follow it: '8BIM', or in a PSB '8B64' as Photoshop marks the long keys these are, the key, the length. */ static void mpsd__own_block(mpsd__sink *s, const char *key, uint64_t size, unsigned out) @@ -691,6 +844,7 @@ static uint64_t mpsd__section_size(const minipsd_doc *d, uint64_t info, unsigned if (mpsd__layers_block(d, &end) < d->layer_tail.size || mpsd__own_layers_block(d)) c.count += MPSD__BLOCK_LEN + mpsd__len_size(out) + (mpsd__pad((size_t)info, 4) - info); /* '8BIM', 'Lr16', length; padding */ if (mpsd__own_layers_block(d) && mpsd__composite_alpha(d)) c.count += MPSD__BLOCK_LEN + mpsd__len_size(out); /* 'Mt32' */ + if (mpsd__own_merged_block(d)) c.count += MPSD__BLOCK_LEN + mpsd__len_size(out); /* 'Mtrn' */ return mpsd__len_size(out) + info + c.count; } @@ -737,7 +891,7 @@ static int mpsd__table_ok(const mpsd__source *src, unsigned from, unsigned out) if (!mpsd__stored_ok(*src->stored, 0, src->height, MPSD__CH_8BIT, from)) return MINIPSD_ERR_ARG; /* a real mask's table: mpsd__mask_ok knew no rows */ for (y = 0; !out && y < src->height; ++y) /* a PSD's counts: two bytes */ - if (mpsd__count(src->stored->data + MPSD__MARKER_SIZE, y, 1) > 0xFFFFu) return MINIPSD_ERR_SIZE; + if (mpsd__count(src->stored->data + MPSD__MARKER_SIZE, y, 1) > MPSD__MAX_COUNT_PSD) return MINIPSD_ERR_SIZE; return MINIPSD_OK; } @@ -812,7 +966,7 @@ static int mpsd__doc_mode_ok(const minipsd_doc *d) : d->color_mode_data.size && depth != MPSD__CH_32BIT) return MINIPSD_ERR_ARG; if ((mode != MINIPSD_MODE_RGB || depth == MPSD__CH_32BIT) && !d->composite && !d->composite_stored.size) return MINIPSD_ERR_ARG; - if (mpsd__doc_flat(d) && (d->layer_count || (d->composite_stored.size && d->composite_alpha))) return MINIPSD_ERR_ARG; + if (mpsd__doc_flat(d) && (d->layer_count || d->composite_alpha)) return MINIPSD_ERR_ARG; if (mode == MINIPSD_MODE_MULTICHANNEL && (d->composite || !d->composite_channels)) return MINIPSD_ERR_ARG; return MINIPSD_OK; } @@ -829,7 +983,8 @@ MINIPSDDEF int minipsd_doc_check(const minipsd_doc *d, const minipsd_write_opts res = d->dpi * MPSD__FIXED_ONE + 0.5; if ((d->layer_count && !d->layers) || (!d->layer_count && !d->composite && !d->composite_stored.size) || d->layer_count > MPSD__MAX_LAYERS || !(res >= 1.0 && res < 4294967296.0) || (d->depth != 0 && d->depth != MPSD__CH_1BIT && d->depth != MPSD__CH_8BIT && d->depth != MPSD__CH_16BIT && d->depth != MPSD__CH_32BIT) || d->format > MINIPSD_PSB - || d->compression < MINIPSD_RLE || d->compression > MINIPSD_ZIP_PREDICTED || (mpsd__zip(d) && !(opts && opts->deflate)) + || d->compression < MINIPSD_COMPRESSION_DEFAULT || d->compression > MINIPSD_ZIP_PREDICTED + || (mpsd__deflates(d) && !(opts && opts->deflate)) || (d->color_mode_data.size && !d->color_mode_data.data) || !mpsd__floats_not_negative(d->pixel_aspect_ratio)) return MINIPSD_ERR_ARG; @@ -844,11 +999,13 @@ MINIPSDDEF int minipsd_doc_check(const minipsd_doc *d, const minipsd_write_opts if (mpsd__zip_plane(d) > ((size_t)-1 >> 2)) return MINIPSD_ERR_SIZE; /* the workspace holds it twice */ rc = mpsd__composite_ok(d, out); if (rc != MINIPSD_OK) return rc; - for (i = 0; i < d->layer_count; ++i) { /* a side within the format's: so is any RLE row count */ + for (i = 0; i < d->layer_count; ++i) { /* a side within the format's; RLE row counts are checked as measured */ const minipsd_layer *l = &d->layers[i]; const int64_t w = (int64_t)l->right - l->left, h = (int64_t)l->bottom - l->top; int k; - if (w < 0 || h < 0 || (!l->pixels && !l->stored && w && h)) return MINIPSD_ERR_ARG; + if (w < 0 || h < 0 || (!l->pixels && !l->stored && w && h) + || l->compression < MINIPSD_COMPRESSION_DEFAULT || l->compression > MINIPSD_ZIP_PREDICTED) + return MINIPSD_ERR_ARG; if (w > mpsd__max_side(out) || h > mpsd__max_side(out)) return MINIPSD_ERR_SIZE; rc = l->mask ? mpsd__mask_ok(d, l->mask, out) : MINIPSD_OK; for (k = mpsd__ch_mask(d); rc == MINIPSD_OK && k < mpsd__channel_count(d, l); ++k) rc = mpsd__retable_ok(d, l, k, out); @@ -922,8 +1079,9 @@ static void mpsd__tail(mpsd__sink *s, const minipsd_doc *d, uint64_t content, ui mpsd__zeros(s, mpsd__pad((size_t)info, 4) - (size_t)info); } else { (void)mpsd__tail_range(s, d, 0, d->layer_tail.size, out); /* minipsd_doc_check has accepted it */ + if (mpsd__own_merged_block(d)) mpsd__own_block(s, mpsd__merged_key(d), 0, out); } - if (!mpsd__copies_composite(d)) mpsd__u16(s, mpsd__marker(d)); /* a stored composite brings its own */ + if (!mpsd__copies_composite(d)) mpsd__u16(s, mpsd__marker(mpsd__composite_compression(d))); /* a stored one brings its own */ } /* --- the pull writer: everything in the caller's workspace --- */ @@ -984,7 +1142,7 @@ static size_t mpsd__writer_layout(const minipsd_doc *d, minipsd_workspace *works { const size_t self = mpsd__pad(sizeof(minipsd_writer), MPSD__ALIGN); const size_t sizes = d->layer_count * MPSD__CHANNELS * sizeof(uint64_t); - const size_t rows = d->compression == MINIPSD_RAW ? 0 : (size_t)d->height * mpsd__doc_pixel(d) * sizeof(uint32_t); + const size_t rows = mpsd__composite_compression(d) == MINIPSD_RAW ? 0 : (size_t)d->height * mpsd__doc_pixel(d) * sizeof(uint32_t); const size_t row = (size_t)d->width * mpsd__doc_pixel(d) << mpsd__doc_shift(d); const size_t plane = (size_t)mpsd__zip_plane(d), room = plane ? (size_t)mpsd__deflate_room(plane) : 0; /* doc_check bounds it */ if (workspace) { @@ -1004,7 +1162,7 @@ static size_t mpsd__writer_layout(const minipsd_doc *d, minipsd_workspace *works /* Is layer i's channel k deflated? With ZIP, every channel encoded from samples. */ static int mpsd__zips(const minipsd_doc *d, size_t i, int k) { - return mpsd__zip(d) && k <= mpsd__ch_mask(d) && mpsd__channel_source(d, &d->layers[i], k).samples; + return mpsd__zip(mpsd__layer_compression(d, &d->layers[i])) && k <= mpsd__ch_mask(d) && mpsd__channel_source(d, &d->layers[i], k).samples; } /* Layer i's channel k deflated into zip_out, unless it is there already: its samples gathered as @@ -1014,19 +1172,14 @@ static int mpsd__zip_channel(minipsd_writer *w, size_t i, int k) { const minipsd_doc *d = w->doc; const mpsd__source src = mpsd__channel_source(d, &d->layers[i], k); - const unsigned wide = mpsd__doc_shift(d); - const size_t bytes = (size_t)src.width << wide; - size_t x, y, size = w->zip_room; + const size_t bytes = (size_t)src.width << mpsd__doc_shift(d); + const int predicted = mpsd__layer_compression(d, &d->layers[i]) == MINIPSD_ZIP_PREDICTED; + size_t y, size = w->zip_room; if (w->zip_layer == i && w->zip_channel == k) return MINIPSD_OK; w->zip_layer = (size_t)-1; - for (y = 0; y < src.height; ++y) { - const uint8_t *row = src.samples + y * src.width * src.step; - uint8_t *out = w->zip_plane + y * bytes; - for (x = 0; x < bytes; ++x) /* at 32 bits predicted: shuffled, as the format wants */ - out[d->compression == MINIPSD_ZIP_PREDICTED && wide == MPSD__SHIFT_32 ? mpsd__shuffled(x, src.width) : x] - = row[mpsd__at(x, (ptrdiff_t)src.step, wide)]; - if (d->compression == MINIPSD_ZIP_PREDICTED) mpsd__predict(out, src.width, mpsd__doc_depth(d)); - } + for (y = 0; y < src.height; ++y) + mpsd__zip_row(w->zip_plane + y * bytes, src.samples + y * src.width * src.step, src.step, src.width, mpsd__doc_depth(d), + predicted); if (!w->deflate /* never: minipsd_doc_check asks for one; spelled out for static analysers */ || w->deflate(w->deflate_user, w->zip_plane, bytes * src.height, w->zip_out, &size) || size > w->zip_room) return MINIPSD_ERR_WRITE; @@ -1059,6 +1212,7 @@ static int mpsd__measure_layers(minipsd_writer *w) sizes[i * MPSD__CHANNELS + (size_t)k] = MPSD__MARKER_SIZE + (uint64_t)w->zip_size; } else { sizes[i * MPSD__CHANNELS + (size_t)k] = mpsd__layer_channel_size(d, &d->layers[i], k, psb); + if (!sizes[i * MPSD__CHANNELS + (size_t)k]) return MINIPSD_ERR_SIZE; /* a row too long for its count */ } w->content += sizes[i * MPSD__CHANNELS + (size_t)k]; } @@ -1110,14 +1264,15 @@ MINIPSDDEF int minipsd_writer_init(minipsd_writer **out, const minipsd_doc *d, c if (mpsd__copies_composite(d)) { const mpsd__source src = mpsd__composite_source(d); total += mpsd__stored_size(&src, mpsd__doc_psb(d), w->out) - MPSD__MARKER_SIZE; - } else if (d->compression == MINIPSD_RAW) { - total += mpsd__row_bytes(d->width, mpsd__doc_depth(d)) * d->height * mpsd__doc_pixel(d); + } else if (mpsd__composite_compression(d) == MINIPSD_RAW) { + total += mpsd__row_bytes(d->width, mpsd__doc_depth(d)) * d->height * mpsd__composite_channels(d); } else { /* each row's count, then the row */ mpsd__composite_init(&comp, d, w->composite_row); for (y = 0; y < d->height; ++y) { const uint8_t *row = mpsd__composite_row(&comp, y); - for (k = 0; k < (int)mpsd__doc_pixel(d); ++k) { - const uint64_t n = mpsd__row_size(d, row + ((size_t)k << wide), (size_t)mpsd__doc_pixel(d) << wide, d->width); + for (k = 0; k < (int)mpsd__composite_channels(d); ++k) { + const uint64_t n = mpsd__row_size(d, MINIPSD_RLE, row + ((size_t)k << wide), (size_t)mpsd__composite_step(d) << wide, d->width); + if (!w->out && n > MPSD__MAX_COUNT_PSD) return MINIPSD_ERR_SIZE; /* a row too long for its count */ w->composite_sizes[(size_t)k * d->height + y] = (uint32_t)n; total += mpsd__count_size(w->out) + n; } @@ -1187,9 +1342,9 @@ static void mpsd__writer_piece(minipsd_writer *w, mpsd__sink *s) switch (w->piece) { case MPSD__HEAD: mpsd__head(s, d, w->info, w->out); break; case MPSD__RECORD: mpsd__layer_record(s, d, l, w->channel_sizes + w->layer * MPSD__CHANNELS, w->out); break; - case MPSD__COMPRESSION: mpsd__u16(s, mpsd__marker(d)); break; - case MPSD__COUNT: mpsd__putcount(s, (uint32_t)mpsd__row_size(d, row, src.step, src.width), w->out); break; - case MPSD__ROW: mpsd__row(s, d, row, src.step, src.width); break; + case MPSD__COMPRESSION: mpsd__u16(s, mpsd__marker(mpsd__layer_compression(d, l))); break; + case MPSD__COUNT: mpsd__putcount(s, (uint32_t)mpsd__row_size(d, mpsd__layer_compression(d, l), row, src.step, src.width), w->out); break; + case MPSD__ROW: mpsd__row(s, d, mpsd__layer_compression(d, l), row, src.step, src.width); break; case MPSD__STORED: mpsd__put(s, src.stored->data, src.stored->size); break; case MPSD__STORED_HEAD: mpsd__put(s, src.stored->data, MPSD__MARKER_SIZE); break; case MPSD__STORED_COUNT: mpsd__putcount(s, mpsd__count(src.stored->data + MPSD__MARKER_SIZE, w->row, mpsd__doc_psb(d)), w->out); break; @@ -1199,13 +1354,14 @@ static void mpsd__writer_piece(minipsd_writer *w, mpsd__sink *s) break; } case MPSD__ZIP: /* minipsd_writer_fill deflated it */ - mpsd__u16(s, d->compression == MINIPSD_ZIP ? MPSD__COMP_ZIP : MPSD__COMP_ZIP_PREDICTED); + mpsd__u16(s, mpsd__layer_compression(d, l) == MINIPSD_ZIP ? MPSD__COMP_ZIP : MPSD__COMP_ZIP_PREDICTED); mpsd__put(s, w->zip_out, w->zip_size); break; case MPSD__TAIL: mpsd__tail(s, d, w->content, w->info, w->out); break; case MPSD__COMPOSITE_COUNT: mpsd__putcount(s, w->composite_sizes[(size_t)w->channel * d->height + w->row], w->out); break; default: - mpsd__row(s, d, mpsd__writer_composite(w, w->row) + ((size_t)w->channel << mpsd__doc_shift(d)), (size_t)mpsd__doc_pixel(d) << mpsd__doc_shift(d), d->width); + mpsd__row(s, d, mpsd__composite_compression(d), mpsd__writer_composite(w, w->row) + ((size_t)w->channel << mpsd__doc_shift(d)), + (size_t)mpsd__composite_step(d) << mpsd__doc_shift(d), d->width); break; } } @@ -1229,7 +1385,6 @@ static int mpsd__channel_piece(const minipsd_writer *w) static void mpsd__writer_next(minipsd_writer *w) { const minipsd_doc *d = w->doc; - const int rle = d->compression != MINIPSD_RAW; const uint32_t rows = w->copying ? mpsd__composite_source(d).height : w->piece > MPSD__RECORD && w->piece < MPSD__TAIL ? mpsd__channel_source(d, &d->layers[w->layer], w->channel).height : 0; w->offset = 0; @@ -1244,7 +1399,7 @@ static void mpsd__writer_next(minipsd_writer *w) w->piece = mpsd__channel_piece(w); return; case MPSD__COMPRESSION: /* rows follow, unless the layer is empty */ - if (rows) { w->piece = rle ? MPSD__COUNT : MPSD__ROW; return; } + if (rows) { w->piece = mpsd__layer_compression(d, &d->layers[w->layer]) != MINIPSD_RAW ? MPSD__COUNT : MPSD__ROW; return; } break; case MPSD__COUNT: if (++w->row < rows) return; @@ -1274,12 +1429,12 @@ static void mpsd__writer_next(minipsd_writer *w) w->piece = mpsd__source_piece(&src, w); return; } - w->piece = rle ? MPSD__COMPOSITE_COUNT : MPSD__COMPOSITE_ROW; + w->piece = mpsd__composite_compression(d) != MINIPSD_RAW ? MPSD__COMPOSITE_COUNT : MPSD__COMPOSITE_ROW; return; default: /* the composite: channel by channel, row by row */ if (++w->row < d->height) return; w->row = 0; - if (++w->channel < (int)mpsd__doc_pixel(d)) return; + if (++w->channel < (int)mpsd__composite_channels(d)) return; w->channel = 0; w->piece = w->piece == MPSD__COMPOSITE_COUNT ? MPSD__COMPOSITE_ROW : MPSD__DONE; return; @@ -1328,23 +1483,43 @@ MINIPSDDEF int minipsd_writer_fill(minipsd_writer *w, void *buf, size_t cap, siz /* --- the push writer: the pull writer driven to the end on an allocated workspace --- */ -#define MPSD__CHUNK 4096 /* bytes per callback; on the stack */ +#define MPSD__CHUNK 4096 /* bytes per callback, at least; more when the widest row needs it */ + +/* The widest row the writer encodes: the canvas's, a layer's or a mask's. fill encodes a row cut + by the buffer's end again from its start, so a chunk that holds the widest row cuts each row at + most once; a smaller one cuts a long row many times. */ +static uint32_t mpsd__widest(const minipsd_doc *d) +{ + uint32_t widest = d->width; + size_t i; + for (i = 0; i < d->layer_count; ++i) { + const minipsd_layer *l = &d->layers[i]; + const uint32_t w = (uint32_t)((int64_t)l->right - l->left); /* checked: within a side */ + if (w > widest) widest = w; + if (l->mask && (uint32_t)((int64_t)l->mask->right - l->mask->left) > widest) + widest = (uint32_t)((int64_t)l->mask->right - l->mask->left); + } + return widest; +} MINIPSDDEF int minipsd_write(const minipsd_doc *d, const minipsd_write_opts *opts, minipsd_write_fn fn, void *user) { minipsd_writer *w; void *workspace; - uint8_t chunk[MPSD__CHUNK]; - size_t size = 0, n; + uint8_t *chunk; + size_t size = 0, cap, n; int rc = d ? minipsd_writer_workspace_size(d, opts, &size) : MINIPSD_ERR_ARG; /* d spelled out for static analysers */ if (rc != MINIPSD_OK) return rc; if (!fn) return MINIPSD_ERR_ARG; - workspace = MINIPSD_MALLOC(size); + cap = (size_t)MINIPSD_ROW_BOUND(mpsd__widest(d), mpsd__doc_depth(d)); /* the sides checked: it fits */ + if (cap < MPSD__CHUNK) cap = MPSD__CHUNK; + workspace = MINIPSD_MALLOC(size + cap); /* the chunk after the workspace */ if (!workspace) return MINIPSD_ERR_ALLOC; + chunk = (uint8_t *)workspace + size; rc = minipsd_writer_init(&w, d, opts, workspace, size); if (rc == MINIPSD_OK) { do { /* never n == 0: the call that produces the last byte already returns MINIPSD_OK */ - rc = minipsd_writer_fill(w, chunk, sizeof chunk, &n); + rc = minipsd_writer_fill(w, chunk, cap, &n); if (!fn(user, chunk, n)) rc = MINIPSD_ERR_WRITE; } while (rc == MINIPSD_MORE); } diff --git a/libvips-plugin/CMakeLists.txt b/libvips-plugin/CMakeLists.txt new file mode 100644 index 0000000..caa3f85 --- /dev/null +++ b/libvips-plugin/CMakeLists.txt @@ -0,0 +1,53 @@ +# --------------------------------------------------------------------------- +# The libvips plugin: minipsd as libvips' psdload (MINIPSD_VIPS_PLUGIN). A static library the +# program links, then registers with minipsd_vips_init() after VIPS_INIT(); minipsd is compiled +# into it, private (MINIPSD_STATIC). libvips comes from cmake/minipsd_libvips.cmake, or from the +# program, which defines minipsd_libvips and minipsd_libvips_zlib before adding minipsd. +# +# minipsd::vips the plugin +# vips.plugin documents minipsd writes and the PSDs at hand, loaded through libvips and +# compared with minipsd's own decode (test_vips.c) +# --------------------------------------------------------------------------- + +# foreign/ is what libvips/foreign/ would hold; minipsd_vips.* is the plugin's own (registration, +# the public header). +add_library(minipsd_vips STATIC + minipsd_vips.c minipsd_vips.h + foreign/psdload.c foreign/psdsave.c foreign/psd.c foreign/psd.h) +add_library(minipsd::vips ALIAS minipsd_vips) +target_include_directories(minipsd_vips PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(minipsd_vips PUBLIC minipsd_libvips PRIVATE minipsd::minipsd minipsd_libvips_zlib) +target_compile_options(minipsd_vips PRIVATE ${MINIPSD_WARNINGS}) +set_target_properties(minipsd_vips PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) + +if(NOT MINIPSD_BUILD_TESTS) + return() +endif() + +add_executable(minipsd-vips-test test_vips.c) +target_link_libraries(minipsd-vips-test PRIVATE minipsd::vips minipsd::minipsd minipsd_libvips_zlib) +target_compile_options(minipsd-vips-test PRIVATE ${MINIPSD_WARNINGS}) +set_target_properties(minipsd-vips-test PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) +if(MSVC) + target_compile_definitions(minipsd-vips-test PRIVATE _CRT_SECURE_NO_WARNINGS) # plain fopen +endif() + +# The files compared beside the documents: tests/data, then, when there, tests/fixtures and +# psd-tools' set (MINIPSD_PSD_TOOLS_FILES), one path a line. +file(GLOB _vips_files "${PROJECT_SOURCE_DIR}/tests/data/*.psd") +foreach(_dir IN ITEMS "${_minipsd_fixtures}" "${_minipsd_psd_tools_files}") + if(_dir) + file(GLOB_RECURSE _found "${_dir}/*.psd" "${_dir}/*.psb") + list(APPEND _vips_files ${_found}) + endif() +endforeach() +list(JOIN _vips_files "\n" _vips_list) +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/files.txt" "${_vips_list}\n") + +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/out") +add_test(NAME vips.plugin COMMAND minipsd-vips-test "${CMAKE_CURRENT_BINARY_DIR}/out" "${CMAKE_CURRENT_BINARY_DIR}/files.txt") +# TIMEOUT: libvips' exit has once hung on Windows (examples/), so a hang fails fast. +set_tests_properties(vips.plugin PROPERTIES PASS_REGULAR_EXPRESSION "vips plugin: all passed" TIMEOUT 120) +if(MINIPSD_LIBVIPS_ENV) + set_tests_properties(vips.plugin PROPERTIES ENVIRONMENT "${MINIPSD_LIBVIPS_ENV}") +endif() diff --git a/libvips-plugin/foreign/psd.c b/libvips-plugin/foreign/psd.c new file mode 100644 index 0000000..c19c3ff --- /dev/null +++ b/libvips-plugin/foreign/psd.c @@ -0,0 +1,42 @@ +/** + * \file + * \brief The helpers psd.h declares, which psdload.c and psdsave.c share. + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#include "psd.h" + +#include +#include + +const char *minipsd_vips__suffs[] = { ".psd", ".psb", NULL }; + +const char *const minipsd_vips__sections[PSD_SECTIONS] = { NULL, "open", "closed", "end" }; + +void minipsd_vips__class_properties(void *class) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS(class); + gobject_class->set_property = vips_object_set_property; + gobject_class->get_property = vips_object_get_property; +} + +void minipsd_vips__invert_cmyk(void *pixels, size_t count, unsigned bands, int depth) +{ + size_t i; + unsigned c; + for (i = 0; i < count; ++i) + for (c = 0; c < PSD_CMYK; ++c) { + if (depth == 32) { + float *v = (float *)pixels + i * bands + c; + *v = 1.0f - *v; + } else if (depth == 16) { + uint16_t *v = (uint16_t *)pixels + i * bands + c; + *v = (uint16_t)(USHRT_MAX - *v); + } else { + uint8_t *v = (uint8_t *)pixels + i * bands + c; + *v = (uint8_t)(UCHAR_MAX - *v); + } + } +} diff --git a/libvips-plugin/foreign/psd.h b/libvips-plugin/foreign/psd.h new file mode 100644 index 0000000..3f65409 --- /dev/null +++ b/libvips-plugin/foreign/psd.h @@ -0,0 +1,69 @@ +/** + * \file + * \brief Internal to the plugin: what the loader and the saver share. + * + * Constants, the PSD_ARGS_* pair, the few helpers both sides use (defined once in psd.c, prefixed + * minipsd_vips__), and the operations' types, which minipsd_vips_init registers. + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#ifndef MINIPSD_VIPS_PSD_H +#define MINIPSD_VIPS_PSD_H + +#include + +#include + +#define PSD_RGB 3 /* RGB's colour channels */ +#define PSD_CMYK 4 /* CMYK's colour channels */ +#define MM_PER_INCH 25.4 + +/* libvips' VIPS_ARG_* macros pass vips_argument_get_id()'s int as a guint property id, and + G_STRUCT_OFFSET's glong as a guint offset, which -Wsign-conversion and -Wconversion report in + our code; only around them are they off. */ +#if defined(__GNUC__) +# define PSD_ARGS_BEGIN \ + _Pragma("GCC diagnostic push") _Pragma("GCC diagnostic ignored \"-Wsign-conversion\"") \ + _Pragma("GCC diagnostic ignored \"-Wconversion\"") +# define PSD_ARGS_END _Pragma("GCC diagnostic pop") +#else +# define PSD_ARGS_BEGIN +# define PSD_ARGS_END +#endif + +/* The suffixes libvips matches files by: .psd and .psb. */ +extern const char *minipsd_vips__suffs[]; + +/* A record's psd-layer-section, by its MINIPSD_SECTION_* type: NULL for a layer, then "open", + "closed" (a group's top record, shown open or closed) and "end" (the divider below a group's + layers). psdload sets it, psdsave_layers reads it. */ +#define PSD_SECTIONS 4 +extern const char *const minipsd_vips__sections[PSD_SECTIONS]; + +/* GObject resets set_property and get_property in every class (g_object_base_class_init), so + each class with arguments of its own sets libvips' again. */ +void minipsd_vips__class_properties(void *class); + +/* CMYK the other way round, in place: PSD stores ink inverted (full: no ink), libvips holds it + as TIFF does (0: no ink), as its own jpegload makes Adobe's inverted CMYK and ImageMagick + makes PSD's. The colour channels only, not transparency; both ways, loading and saving. + count pixels of bands samples, each of depth bits (8, 16, or 32 as floats). */ +void minipsd_vips__invert_cmyk(void *pixels, size_t count, unsigned bands, int depth); + +/* The operations: each abstract base, then the three made from it (psdload.c, psdsave.c). */ +GType minipsd_vips_load_get_type(void); +GType minipsd_vips_load_source_get_type(void); +GType minipsd_vips_load_file_get_type(void); +GType minipsd_vips_load_buffer_get_type(void); +GType minipsd_vips_save_get_type(void); +GType minipsd_vips_save_target_get_type(void); +GType minipsd_vips_save_file_get_type(void); +GType minipsd_vips_save_buffer_get_type(void); +GType minipsd_vips_save_layers_get_type(void); +GType minipsd_vips_save_layers_target_get_type(void); +GType minipsd_vips_save_layers_file_get_type(void); +GType minipsd_vips_save_layers_buffer_get_type(void); + +#endif /* MINIPSD_VIPS_PSD_H */ diff --git a/libvips-plugin/foreign/psdload.c b/libvips-plugin/foreign/psdload.c new file mode 100644 index 0000000..d3e1ccc --- /dev/null +++ b/libvips-plugin/foreign/psdload.c @@ -0,0 +1,648 @@ +/** + * \file + * \brief psdload, psdload_buffer, psdload_source: a PSD or PSB as a libvips image. + * + * Laid out as libvips' own loaders are (a base class over a VipsSource; the file and buffer + * variants make one), in the plugin's own names (MinipsdVips*, minipsd_vips_*), so they never + * meet libvips' own. The file is mapped (vips_source_map) and read in place by minipsd's + * reader; nothing is copied. + * Pixels are decoded in bands of rows as libvips asks for them, from its threads at once. + * + * The image is the composite, or with `layer` one layer (its position in xoffset and yoffset), as + * the file stores it, no colour converted: the mode's colour channels, then transparency where it + * has one (a layer's channel, the composite's). A layer's look comes in psd-layer-* fields + * (name, opacity, hidden, blend mode, clipping, empty; a group record's psd-layer-section; its + * mask, an image, in psd-layer-mask), the ones psdsave_layers takes back. What libvips has no interpretation for (Lab, + * multichannel) is MULTIBAND; the colour mode data is kept as "psd-color-mode-data". Indexed is + * the exception: libvips has no indexed image, so it comes through its palette as sRGB, as + * libvips' own loaders give PNG, GIF and TIFF palettes (a lookup, no colour converted). CMYK + * comes the other way round from PSD's inverted ink, and bitmap as B_W (black 0), as libvips + * holds them. ZIP layers are inflated by the zlib libvips links. + * + * minipsd's reader is compiled in here, private (MINIPSD_STATIC; the writer left out), so a + * program that uses minipsd itself keeps its own. + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#define MINIPSD_STATIC +#define MINIPSD_NO_WRITER +#define MINIPSD_IMPLEMENTATION +#include "minipsd.h" + +#include "psd.h" + +#include + +#include +#include + +#define PSD_SIGNATURE "8BPS" +#define PSD_SIGNATURE_SIZE 4 +#define PSD_SNIFF_SIZE 6 /* the signature and the version (1 PSD, 2 PSB), big-endian */ +#define PSD_UNIT_CM 2 /* resolution info's hResUnit: pixels per cm, shown in cm */ +#define PSD_MARKER_SIZE 2 /* a stored channel's compression marker, big-endian */ +#define PSD_STORED_ZIP 2 /* a channel's stored compression marker: ZIP, and ... */ +#define PSD_STORED_ZIP_PRED 3 /* ... ZIP with prediction */ +#define PSD_PALETTE_ENTRIES 256 /* an indexed file's palette: 256 reds, 256 greens, 256 blues */ +#define PSD_COMPOSITE (-1) /* the `layer` option's default: the composite */ +#define PSD_LSCT_MODE 8 /* an 'lsct' block: the type, "8BIM", then the group's mode ... */ +#define PSD_LSCT_FULL 12 /* ... which ends here */ +#define PSD_MODE_SIZE 4 /* a blend mode's key */ +#define PSD_MASK_DISABLED 0x02 /* a mask's flag: disabled */ + +/* --- The base class: a PSD read from a source --------------------------------------------- */ + +typedef struct _MinipsdVipsLoad { + VipsForeignLoad parent_object; + int layer; /* the option: -1 the composite, else a layer, bottom first */ + VipsSource *source; /* set by the subclass's build */ + minipsd_reader *reader; + minipsd_info info; + minipsd_layer_view view; /* the layer, with layer >= 0 */ + uint32_t width, height; /* the image: the canvas, or the layer's rectangle */ + unsigned planes; /* what minipsd decodes: colour channels and transparency, + up to MINIPSD_PLANES */ + unsigned bands; /* what libvips gets: planes, or indexed's RGB(A) */ + int indexed; /* the palette makes the colour */ + int transparent; /* indexed: the transparent index (resource 1047), or -1 */ + int empty; /* the layer has no pixels: one transparent pixel stands for it */ + size_t scratch_size; /* a ZIP layer's channels inflated whole; 0 without ZIP */ + double xres, yres; /* pixels per mm */ +} MinipsdVipsLoad; + +typedef VipsForeignLoadClass MinipsdVipsLoadClass; + +G_DEFINE_ABSTRACT_TYPE(MinipsdVipsLoad, minipsd_vips_load, VIPS_TYPE_FOREIGN_LOAD) + +/* Do these bytes start a PSD or a PSB? */ +static int minipsd_vips_load_is_a_bytes(const unsigned char *data, size_t size) +{ + return size >= PSD_SNIFF_SIZE && memcmp(data, PSD_SIGNATURE, PSD_SIGNATURE_SIZE) == 0 && data[4] == 0 + && (data[5] == MINIPSD_PSD || data[5] == MINIPSD_PSB); +} + +static gboolean minipsd_vips_load_is_a_source(VipsSource *source) +{ + const unsigned char *data = vips_source_sniff(source, PSD_SNIFF_SIZE); + return data && minipsd_vips_load_is_a_bytes(data, PSD_SNIFF_SIZE); +} + +static void minipsd_vips_load_dispose(GObject *gobject) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)gobject; + VIPS_UNREF(psd->source); + G_OBJECT_CLASS(minipsd_vips_load_parent_class)->dispose(gobject); +} + +static VipsForeignFlags minipsd_vips_load_get_flags(VipsForeignLoad *load) +{ + (void)load; + return VIPS_FOREIGN_SEQUENTIAL; /* bands of rows; libvips keeps the image for random access */ +} + +static void minipsd_vips_load_error(MinipsdVipsLoad *psd, const char *message) +{ + vips_error(VIPS_OBJECT_GET_CLASS(psd)->nickname, "%s", message); +} + +/* The bands' format: a sample of depth bits. Bitmap's 1-bit samples come as bytes. */ +static VipsBandFormat minipsd_vips_load_format(const minipsd_info *info) +{ + return info->depth == 32 ? VIPS_FORMAT_FLOAT : info->depth == 16 ? VIPS_FORMAT_USHORT : VIPS_FORMAT_UCHAR; +} + +/* What the samples are, as libvips names it; MULTIBAND where it has no name for the mode. */ +static VipsInterpretation minipsd_vips_load_interpretation(const minipsd_info *info) +{ + switch (info->color_mode) { + case MINIPSD_MODE_RGB: + return info->depth == 32 ? VIPS_INTERPRETATION_scRGB + : info->depth == 16 ? VIPS_INTERPRETATION_RGB16 : VIPS_INTERPRETATION_sRGB; + case MINIPSD_MODE_INDEXED: + return VIPS_INTERPRETATION_sRGB; + case MINIPSD_MODE_BITMAP: + case MINIPSD_MODE_GREY: + case MINIPSD_MODE_DUOTONE: + return info->depth == 16 ? VIPS_INTERPRETATION_GREY16 : VIPS_INTERPRETATION_B_W; + case MINIPSD_MODE_CMYK: + return VIPS_INTERPRETATION_CMYK; + default: + return VIPS_INTERPRETATION_MULTIBAND; + } +} + +/* The image's fields: on out, and on the image load() generates. */ +static int minipsd_vips_load_fields(MinipsdVipsLoad *psd, VipsImage *image) +{ + vips_image_init_fields(image, (int)psd->width, (int)psd->height, (int)psd->bands, + minipsd_vips_load_format(&psd->info), VIPS_CODING_NONE, + minipsd_vips_load_interpretation(&psd->info), psd->xres, psd->yres); + if (psd->layer >= 0) { + image->Xoffset = psd->view.left; + image->Yoffset = psd->view.top; + } + return vips_image_pipelinev(image, VIPS_DEMAND_STYLE_THINSTRIP, NULL); +} + +/* A resource's bytes as a blob of the image, when the file has it. */ +static void minipsd_vips_load_set_blob(MinipsdVipsLoad *psd, uint16_t id, const char *name) +{ + minipsd_resource res; + if (minipsd_reader_find_resource(psd->reader, id, &res) == MINIPSD_OK && res.data.size) + vips_image_set_blob_copy(((VipsForeignLoad *)psd)->out, name, res.data.data, res.data.size); +} + +/* Bytes to inflate the layer's ZIP channels whole, each in its own part (scratch_kept); 0 when + none of the planes is ZIP. */ +static size_t minipsd_vips_load_scratch_size(const MinipsdVipsLoad *psd) +{ + const size_t row = ((size_t)psd->width * psd->info.depth + 7) / 8; + unsigned i; + int zip = 0; + for (i = 0; i < psd->planes; ++i) + if (psd->view.channel[i].size >= 2 && psd->view.channel[i].data && psd->view.channel[i].data[0] == 0 + && (psd->view.channel[i].data[1] == PSD_STORED_ZIP || psd->view.channel[i].data[1] == PSD_STORED_ZIP_PRED)) + zip = 1; + return zip ? psd->planes * psd->height * row : 0; +} + +/* zlib's uncompress, as minipsd_inflate_fn: the stream must fill dst exactly. */ +static int minipsd_vips_load_inflate(void *user, const uint8_t *src, size_t src_size, uint8_t *dst, size_t dst_size) +{ + uLongf n = (uLongf)dst_size; + (void)user; + if ((uLong)src_size != src_size || n != dst_size) return 1; /* past zlib's uLong: 32 bits on Windows */ + return uncompress(dst, &n, src, (uLong)src_size) != Z_OK || n != dst_size; +} + +/* A group's own blend mode, from its 'lsct' block, into mode (a C string); 0 without one. The + record's own key says "norm" for a group that passes through. */ +static int minipsd_vips_load_group_mode(const minipsd_layer_view *view, char mode[PSD_MODE_SIZE + 1]) +{ + minipsd_block block; + size_t pos = 0; + while (minipsd_layer_next_block(view, &pos, &block) == MINIPSD_MORE) + if (memcmp(block.key, "lsct", PSD_MODE_SIZE) == 0 && block.data.size >= PSD_LSCT_FULL) { + memcpy(mode, block.data.data + PSD_LSCT_MODE, PSD_MODE_SIZE); + mode[PSD_MODE_SIZE] = '\0'; + return 1; + } + return 0; +} + +/* The layer's mask as an image on out: "psd-layer-mask", one band of the file's depth, its own + rectangle in xoffset and yoffset (one pixel with psd-layer-empty for a rectangle without area), + its colour outside in "psd-layer-mask-default-color", "psd-layer-mask-disabled". Decoded + whole: a mask is one band. */ +static int minipsd_vips_load_mask(MinipsdVipsLoad *psd, VipsImage *out) +{ + const minipsd_mask *m = &psd->view.mask; + const int empty = m->right <= m->left || m->bottom <= m->top; + const uint32_t width = empty ? 1 : (uint32_t)(m->right - m->left), height = empty ? 1 : (uint32_t)(m->bottom - m->top); + const VipsBandFormat format = minipsd_vips_load_format(&psd->info); + const size_t row = (size_t)width * (size_t)vips_format_sizeof(format), bytes = row * height; + const int zip = m->stored.size >= PSD_MARKER_SIZE && m->stored.data + && (m->stored.data[1] == PSD_STORED_ZIP || m->stored.data[1] == PSD_STORED_ZIP_PRED); + uint8_t *pixels = (uint8_t *)g_try_malloc0(bytes), *scratch = zip ? (uint8_t *)g_try_malloc(bytes) : NULL; + VipsImage *image = NULL; + minipsd_decode_opts opts; + minipsd_plane plane; + int rc = !pixels || (zip && !scratch) ? MINIPSD_ERR_ALLOC : MINIPSD_OK; + + if (rc == MINIPSD_OK && !empty) { + memset(&opts, 0, sizeof opts); + opts.depth = psd->info.depth; + opts.inflate = minipsd_vips_load_inflate; + opts.scratch = scratch; + opts.scratch_size = zip ? bytes : 0; + plane.data = pixels; + plane.pixel_step = (ptrdiff_t)vips_format_sizeof(format); + plane.row_step = (ptrdiff_t)row; + rc = minipsd_decode_mask(psd->reader, &psd->view, &plane, &opts); + } + if (rc == MINIPSD_OK && !(image = vips_image_new_from_memory_copy(pixels, bytes, (int)width, (int)height, 1, format))) + rc = MINIPSD_ERR_ALLOC; + g_free(pixels); + g_free(scratch); + if (rc != MINIPSD_OK) { + minipsd_vips_load_error(psd, minipsd_error_string(rc)); + return -1; + } + image->Type = psd->info.depth == 16 ? VIPS_INTERPRETATION_GREY16 : VIPS_INTERPRETATION_B_W; + image->Xoffset = m->left; + image->Yoffset = m->top; + vips_image_set_int(image, "psd-layer-empty", empty); + vips_image_set_image(out, "psd-layer-mask", image); + g_object_unref(image); + vips_image_set_int(out, "psd-layer-mask-default-color", m->default_color); + vips_image_set_int(out, "psd-layer-mask-disabled", (m->flags & PSD_MASK_DISABLED) != 0); + return 0; +} + +/* The layer the option names: its record, its size, its name and look as metadata. */ +static int minipsd_vips_load_find_layer(MinipsdVipsLoad *psd) +{ + VipsImage *out = ((VipsForeignLoad *)psd)->out; + char *name, mode[PSD_MODE_SIZE + 1]; + size_t needed = 0; + int rc, i; + for (i = 0; i <= psd->layer; ++i) + if ((rc = minipsd_reader_next_layer(psd->reader, &psd->view)) != MINIPSD_MORE) { + minipsd_vips_load_error(psd, rc == MINIPSD_OK ? "no such layer" : minipsd_error_string(rc)); + return -1; + } + /* An empty layer (no area: Photoshop writes its new, unpainted layers so) has no pixels, and a + libvips image has one at least: one transparent pixel, with psd-layer-empty, which + psdsave_layers writes back as an empty layer. */ + psd->empty = psd->view.right <= psd->view.left || psd->view.bottom <= psd->view.top; + psd->width = psd->empty ? 1 : (uint32_t)(psd->view.right - psd->view.left); + psd->height = psd->empty ? 1 : (uint32_t)(psd->view.bottom - psd->view.top); + /* Transparency when the layer has its channel: a layer without one comes without alpha, as it + was saved. */ + psd->planes = psd->info.color_channels; + if (psd->planes < MINIPSD_PLANES && psd->view.channel[psd->planes].size) ++psd->planes; + + if (minipsd_layer_name(&psd->view, NULL, 0, &needed) == MINIPSD_MORE + && (name = (char *)vips_malloc(VIPS_OBJECT(psd), needed)) + && minipsd_layer_name(&psd->view, name, needed, &needed) == MINIPSD_OK) + vips_image_set_string(out, "psd-layer-name", name); + /* A group's top record: its own mode (pass through, or one it is composed in). */ + if ((psd->view.section_divider_type == MINIPSD_SECTION_GROUP || psd->view.section_divider_type == MINIPSD_SECTION_GROUP_CLOSED) + && minipsd_vips_load_group_mode(&psd->view, mode)) + vips_image_set_string(out, "psd-layer-blend-mode", mode); + else + vips_image_set_string(out, "psd-layer-blend-mode", psd->view.blend_mode); + vips_image_set_int(out, "psd-layer-opacity", psd->view.opacity); + vips_image_set_int(out, "psd-layer-hidden", psd->view.hidden); + vips_image_set_int(out, "psd-layer-clipping", psd->view.clipping); + vips_image_set_int(out, "psd-layer-empty", psd->empty); + if (psd->view.section_divider_type && psd->view.section_divider_type < PSD_SECTIONS) + vips_image_set_string(out, "psd-layer-section", minipsd_vips__sections[psd->view.section_divider_type]); + return psd->view.has_mask && psd->info.depth >= CHAR_BIT ? minipsd_vips_load_mask(psd, out) : 0; +} + +static int minipsd_vips_load_header(VipsForeignLoad *load) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)load; + minipsd_resource res; + minipsd_resolution_info resolution; + minipsd_version_info version; + uint16_t index; + double xres, yres; + const char *unit = "in"; + const void *data; + void *workspace; + size_t size, workspace_size; + int rc; + + if (!(data = vips_source_map(psd->source, &size))) return -1; + if (minipsd_reader_workspace_size(&workspace_size) != MINIPSD_OK + || !(workspace = vips_malloc(VIPS_OBJECT(load), workspace_size))) + return -1; + rc = minipsd_reader_init(&psd->reader, data, size, workspace, workspace_size, NULL, &psd->info); + if (rc != MINIPSD_OK) { + minipsd_vips_load_error(psd, minipsd_error_string(rc)); + return -1; + } + + psd->width = psd->info.width; + psd->height = psd->info.height; + /* Channels past MINIPSD_PLANES (a multichannel file's sixth on) are not decoded. */ + psd->planes = VIPS_MIN(psd->info.color_channels + (psd->info.composite_has_alpha ? 1u : 0u), MINIPSD_PLANES); + if (psd->layer >= 0 && minipsd_vips_load_find_layer(psd)) return -1; + psd->scratch_size = psd->layer >= 0 && !psd->empty ? minipsd_vips_load_scratch_size(psd) : 0; + + /* Indexed: the palette's RGB, and transparency where resource 1047 names an index. */ + psd->bands = psd->planes; + psd->transparent = -1; + psd->indexed = psd->info.color_mode == MINIPSD_MODE_INDEXED; + if (psd->indexed) { + if (psd->info.color_mode_data.size < PSD_RGB * PSD_PALETTE_ENTRIES || !psd->info.color_mode_data.data) { + minipsd_vips_load_error(psd, "an indexed file without its palette"); + return -1; + } + if (minipsd_reader_find_resource(psd->reader, MINIPSD_RES_TRANSPARENCY_INDEX, &res) == MINIPSD_OK + && minipsd_resource_transparency_index(&res, &index) == MINIPSD_OK && index < PSD_PALETTE_ENTRIES) + psd->transparent = index; + psd->bands = PSD_RGB + (psd->transparent >= 0 ? 1u : 0u); + } + + /* The resolution: resource 1005 whole, or none (libvips' default). */ + xres = yres = psd->info.dpi; + if (minipsd_reader_find_resource(psd->reader, MINIPSD_RES_RESOLUTION_INFO, &res) == MINIPSD_OK + && minipsd_resource_resolution_info(&res, &resolution) == MINIPSD_OK) { + xres = resolution.horizontal_resolution; + yres = resolution.vertical_resolution; + if (resolution.horizontal_resolution_unit == PSD_UNIT_CM) unit = "cm"; + } + psd->xres = xres > 0.0 ? xres / MM_PER_INCH : 1.0; + psd->yres = yres > 0.0 ? yres / MM_PER_INCH : 1.0; + if (minipsd_vips_load_fields(psd, load->out)) return -1; + if (xres > 0.0) vips_image_set_string(load->out, VIPS_META_RESOLUTION_UNIT, unit); + + vips_image_set_int(load->out, VIPS_META_BITS_PER_SAMPLE, psd->info.depth); + if (psd->info.icc.size) + vips_image_set_blob_copy(load->out, VIPS_META_ICC_NAME, psd->info.icc.data, psd->info.icc.size); + minipsd_vips_load_set_blob(psd, MINIPSD_RES_EXIF_DATA_1, VIPS_META_EXIF_NAME); + minipsd_vips_load_set_blob(psd, MINIPSD_RES_XMP_METADATA, VIPS_META_XMP_NAME); + minipsd_vips_load_set_blob(psd, MINIPSD_RES_IPTC_NAA, VIPS_META_IPTC_NAME); + if (psd->info.color_mode_data.size) + vips_image_set_blob_copy(load->out, "psd-color-mode-data", psd->info.color_mode_data.data, + psd->info.color_mode_data.size); + vips_image_set_double(load->out, "psd-pixel-aspect-ratio", psd->info.pixel_aspect_ratio); + vips_image_set_int(load->out, "psd-layer-count", (int)psd->info.layer_count); + /* Without version info (before Photoshop 6) the composite is real. */ + vips_image_set_int(load->out, "psd-has-real-merged-data", + minipsd_reader_find_resource(psd->reader, MINIPSD_RES_VERSION_INFO, &res) != MINIPSD_OK + || minipsd_resource_version_info(&res, &version) != MINIPSD_OK + || version.has_real_merged_data); + return 0; +} + +/* A thread's sequence: the scratch a ZIP layer's channels stay inflated in, between its bands. */ +typedef struct minipsd_vips_load_sequence { + uint8_t *scratch; + int kept; +} minipsd_vips_load_sequence; + +static void *minipsd_vips_load_start(VipsImage *out, void *a, void *b) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)a; + minipsd_vips_load_sequence *seq = g_new0(minipsd_vips_load_sequence, 1); + (void)out, (void)b; + /* ponytail: every thread inflates a ZIP layer whole into its own scratch; one shared scratch + under a lock would bound memory at one copy, if layers that large turn up. */ + if (psd->scratch_size && !(seq->scratch = (uint8_t *)g_try_malloc(psd->scratch_size))) { + g_free(seq); + return NULL; + } + return seq; +} + +static int minipsd_vips_load_stop(void *seq, void *a, void *b) +{ + (void)a, (void)b; + g_free(((minipsd_vips_load_sequence *)seq)->scratch); + g_free(seq); + return 0; +} + +/* Indexed, in place: each pixel's index (in its first band) becomes the palette's RGB, and alpha + 0 at the transparent index. */ +static void minipsd_vips_load_expand_palette(const MinipsdVipsLoad *psd, VipsPel *row, int width) +{ + const uint8_t *palette = psd->info.color_mode_data.data; + int x; + for (x = 0; x < width; ++x, row += psd->bands) { + const unsigned index = row[0]; + row[0] = palette[index]; + row[1] = palette[PSD_PALETTE_ENTRIES + index]; + row[2] = palette[2 * PSD_PALETTE_ENTRIES + index]; + if (psd->transparent >= 0) row[PSD_RGB] = (int)index == psd->transparent ? 0 : UCHAR_MAX; + } +} + +/* One band of rows, decoded straight into the region. vips_sequential asks for whole rows; calls + run at once from libvips' threads on the one reader, which minipsd allows on a whole file. */ +static int minipsd_vips_load_generate(VipsRegion *region, void *seq, void *a, void *b, gboolean *stop) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)a; + minipsd_vips_load_sequence *sequence = (minipsd_vips_load_sequence *)seq; + const VipsRect *rect = ®ion->valid; + minipsd_decode_opts opts; + minipsd_target target; + VipsPel *top = VIPS_REGION_ADDR_TOPLEFT(region), *row; + const size_t skip = VIPS_REGION_LSKIP(region); + int rc, x, y; + (void)b, (void)stop; + + if (rect->left != 0 || rect->width != region->im->Xsize) { + minipsd_vips_load_error(psd, "only whole rows are decoded"); + return -1; + } + if (psd->empty) { /* no pixels: transparent, and colour 0 */ + for (y = 0; y < rect->height; ++y) memset(top + (size_t)y * skip, 0, (size_t)rect->width * psd->bands * (size_t)vips_format_sizeof(region->im->BandFmt)); + return 0; + } + memset(&opts, 0, sizeof opts); + opts.y_first = (uint32_t)rect->top; + opts.y_count = (uint32_t)rect->height; + opts.depth = psd->info.depth; + minipsd_target_interleaved(&target, top, skip, psd->planes, psd->info.depth); + if (psd->indexed) target.channel[0].pixel_step = (ptrdiff_t)psd->bands; /* the index, in R */ + if (psd->layer < 0) { + rc = minipsd_decode_composite(psd->reader, &target, &opts); + } else { + opts.inflate = minipsd_vips_load_inflate; + opts.scratch = sequence->scratch; + opts.scratch_size = psd->scratch_size; + opts.scratch_kept = sequence->kept; + rc = minipsd_decode_layer(psd->reader, &psd->view, &target, &opts); + sequence->kept = rc == MINIPSD_OK && sequence->scratch; + } + if (rc != MINIPSD_OK) { + minipsd_vips_load_error(psd, minipsd_error_string(rc)); + return -1; + } + + for (y = 0; y < rect->height; ++y) { + row = top + (size_t)y * skip; + if (psd->indexed) { + minipsd_vips_load_expand_palette(psd, row, rect->width); + } else if (psd->info.color_mode == MINIPSD_MODE_CMYK) { + minipsd_vips__invert_cmyk(row, (size_t)rect->width, psd->bands, psd->info.depth); + } else if (psd->info.color_mode == MINIPSD_MODE_BITMAP) { + /* Bitmap decodes to 1 for black, 0 for white; libvips' B_W is 0 black, 255 white. */ + for (x = 0; x < rect->width; ++x) row[x] = row[x] ? 0 : UCHAR_MAX; + } + } + return 0; +} + +static int minipsd_vips_load_load(VipsForeignLoad *load) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)load; + VipsImage **t = (VipsImage **)vips_object_local_array(VIPS_OBJECT(load), 2); + t[0] = vips_image_new(); + if (minipsd_vips_load_fields(psd, t[0]) + || vips_image_generate(t[0], minipsd_vips_load_start, minipsd_vips_load_generate, + minipsd_vips_load_stop, psd, NULL) + || vips_sequential(t[0], &t[1], NULL) || vips_image_write(t[1], load->real)) + return -1; + return 0; +} + +static void minipsd_vips_load_class_init(MinipsdVipsLoadClass *class) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS(class); + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsForeignLoadClass *load_class = (VipsForeignLoadClass *)class; + + gobject_class->dispose = minipsd_vips_load_dispose; + minipsd_vips__class_properties(class); + + object_class->nickname = "psdload_base"; + object_class->description = "load PSD and PSB with minipsd"; + + load_class->get_flags = minipsd_vips_load_get_flags; + load_class->header = minipsd_vips_load_header; + load_class->load = minipsd_vips_load_load; + + PSD_ARGS_BEGIN + VIPS_ARG_INT(class, "layer", 20, "Layer", "Load this layer (bottom first), -1 the composite", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsLoad, layer), PSD_COMPOSITE, INT_MAX, + PSD_COMPOSITE); + PSD_ARGS_END +} + +static void minipsd_vips_load_init(MinipsdVipsLoad *psd) +{ + psd->layer = PSD_COMPOSITE; +} + +/* --- psdload_source ----------------------------------------------------------------------- */ + +typedef struct _MinipsdVipsLoadSource { + MinipsdVipsLoad parent_object; + VipsSource *source; +} MinipsdVipsLoadSource; + +typedef MinipsdVipsLoadClass MinipsdVipsLoadSourceClass; + +G_DEFINE_TYPE(MinipsdVipsLoadSource, minipsd_vips_load_source, minipsd_vips_load_get_type()) + +static int minipsd_vips_load_source_build(VipsObject *object) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)object; + MinipsdVipsLoadSource *source = (MinipsdVipsLoadSource *)object; + if (source->source) { + psd->source = source->source; + g_object_ref(psd->source); + } + return VIPS_OBJECT_CLASS(minipsd_vips_load_source_parent_class)->build(object); +} + +static void minipsd_vips_load_source_class_init(MinipsdVipsLoadSourceClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsOperationClass *operation_class = VIPS_OPERATION_CLASS(class); + VipsForeignLoadClass *load_class = (VipsForeignLoadClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdload_source"; + object_class->description = "load PSD and PSB from a source with minipsd"; + object_class->build = minipsd_vips_load_source_build; + operation_class->flags |= VIPS_OPERATION_NOCACHE; + load_class->is_a_source = minipsd_vips_load_is_a_source; + + PSD_ARGS_BEGIN + VIPS_ARG_OBJECT(class, "source", 1, "Source", "Source to load from", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsLoadSource, source), VIPS_TYPE_SOURCE); + PSD_ARGS_END +} + +static void minipsd_vips_load_source_init(MinipsdVipsLoadSource *source) +{ + (void)source; +} + +/* --- psdload (a file) ---------------------------------------------------------------------- */ + +typedef struct _MinipsdVipsLoadFile { + MinipsdVipsLoad parent_object; + char *filename; +} MinipsdVipsLoadFile; + +typedef MinipsdVipsLoadClass MinipsdVipsLoadFileClass; + +G_DEFINE_TYPE(MinipsdVipsLoadFile, minipsd_vips_load_file, minipsd_vips_load_get_type()) + +static gboolean minipsd_vips_load_is_a_file(const char *filename) +{ + VipsSource *source = vips_source_new_from_file(filename); + gboolean is_a; + if (!source) return FALSE; + is_a = minipsd_vips_load_is_a_source(source); + g_object_unref(source); + return is_a; +} + +static int minipsd_vips_load_file_build(VipsObject *object) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)object; + MinipsdVipsLoadFile *file = (MinipsdVipsLoadFile *)object; + if (file->filename && !(psd->source = vips_source_new_from_file(file->filename))) return -1; + return VIPS_OBJECT_CLASS(minipsd_vips_load_file_parent_class)->build(object); +} + +static void minipsd_vips_load_file_class_init(MinipsdVipsLoadFileClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsForeignClass *foreign_class = (VipsForeignClass *)class; + VipsForeignLoadClass *load_class = (VipsForeignLoadClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdload"; + object_class->description = "load PSD and PSB with minipsd"; + object_class->build = minipsd_vips_load_file_build; + foreign_class->suffs = minipsd_vips__suffs; + load_class->is_a = minipsd_vips_load_is_a_file; + + PSD_ARGS_BEGIN + VIPS_ARG_STRING(class, "filename", 1, "Filename", "Filename to load from", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsLoadFile, filename), NULL); + PSD_ARGS_END +} + +static void minipsd_vips_load_file_init(MinipsdVipsLoadFile *file) +{ + (void)file; +} + +/* --- psdload_buffer ------------------------------------------------------------------------ */ + +typedef struct _MinipsdVipsLoadBuffer { + MinipsdVipsLoad parent_object; + VipsBlob *blob; +} MinipsdVipsLoadBuffer; + +typedef MinipsdVipsLoadClass MinipsdVipsLoadBufferClass; + +G_DEFINE_TYPE(MinipsdVipsLoadBuffer, minipsd_vips_load_buffer, minipsd_vips_load_get_type()) + +static gboolean minipsd_vips_load_is_a_buffer(const void *data, size_t size) +{ + return minipsd_vips_load_is_a_bytes((const unsigned char *)data, size); +} + +static int minipsd_vips_load_buffer_build(VipsObject *object) +{ + MinipsdVipsLoad *psd = (MinipsdVipsLoad *)object; + MinipsdVipsLoadBuffer *buffer = (MinipsdVipsLoadBuffer *)object; + if (buffer->blob && !(psd->source = vips_source_new_from_blob(buffer->blob))) return -1; + return VIPS_OBJECT_CLASS(minipsd_vips_load_buffer_parent_class)->build(object); +} + +static void minipsd_vips_load_buffer_class_init(MinipsdVipsLoadBufferClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsForeignLoadClass *load_class = (VipsForeignLoadClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdload_buffer"; + object_class->description = "load PSD and PSB from a buffer with minipsd"; + object_class->build = minipsd_vips_load_buffer_build; + load_class->is_a_buffer = minipsd_vips_load_is_a_buffer; + + PSD_ARGS_BEGIN + VIPS_ARG_BOXED(class, "buffer", 1, "Buffer", "Buffer to load from", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsLoadBuffer, blob), VIPS_TYPE_BLOB); + PSD_ARGS_END +} + +static void minipsd_vips_load_buffer_init(MinipsdVipsLoadBuffer *buffer) +{ + (void)buffer; +} diff --git a/libvips-plugin/foreign/psdsave.c b/libvips-plugin/foreign/psdsave.c new file mode 100644 index 0000000..7cbdfc0 --- /dev/null +++ b/libvips-plugin/foreign/psdsave.c @@ -0,0 +1,1408 @@ +/** + * \file + * \brief psdsave, psdsave_buffer, psdsave_target: an image as a PSD or PSB, one layer or flat. + * + * Laid out as libvips' own savers are (a base class over a VipsTarget; the file and buffer + * variants make one), in the plugin's own names (MinipsdVips*, minipsd_vips_*), so they never + * meet libvips' own. An image with alpha is written as one layer that is also the + * composite; one without, flat, as Photoshop saves an image without layers: the composite only, + * without transparency (with `layered`, one layer without transparency too). Grey, RGB or CMYK, + * 8 or 16 bits, grey and RGB also 32, CMYK back to PSD's inverted ink; the resolution, the ICC + * profile, EXIF, XMP and IPTC kept. The image is read once, top to bottom, in bands; each + * channel is encoded as its rows come, as minipsd encodes it, kept compressed and handed to + * minipsd as stored: memory about the file's size, not the image's. PSB when asked, for a .psb + * file, a side past PSD's, or when a PSD cannot hold the file (a row its counts cannot hold, a + * layer section past 4 GiB, a file past the 2 GB Photoshop opens as a PSD). + * + * minipsd's writer is compiled in here, private (MINIPSD_STATIC; the reader left out), so a + * program that uses minipsd itself keeps its own. + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#define MINIPSD_STATIC +#define MINIPSD_NO_READER +#define MINIPSD_IMPLEMENTATION +#include "minipsd.h" + +#include "minipsd_vips.h" +#include "psd.h" + +#define ZLIB_CONST /* next_in const: rows are only read */ +#include + +#include +#include +#include + +/* --- The saver's base class: an image as a one-layer PSD into a target --------------------- */ + +#define PSD_ZIP_LEVEL 6 /* zlib's default */ +#define PSD_DEFAULT_DPI 72.0 /* when the image's resolution is none PSD can hold */ +#define PSD_MAX_DPI 65536.0 /* PSD's resolution is a 16.16 fixed-point number */ +#define PSD_LAYER_NAME "Layer 1" +#define PSD_MAX_RESOURCES 3 /* EXIF, XMP, IPTC */ +#define PSD_MAX_PLANES 5 /* the most bands it takes: CMYK and alpha */ + +typedef struct _MinipsdVipsSave { + VipsForeignSave parent_object; + VipsTarget *target; /* set by the subclass's build */ + gboolean psb; /* the option; also a .psb file, or a side past PSD's */ + MinipsdVipsCompression compression; /* the option: the layer's channels; none: the composite's too */ + char *layer_name; /* the option */ + gboolean layered; /* the option: a layer also for an image without alpha */ +} MinipsdVipsSave; + +typedef VipsForeignSaveClass MinipsdVipsSaveClass; + +G_DEFINE_ABSTRACT_TYPE(MinipsdVipsSave, minipsd_vips_save, VIPS_TYPE_FOREIGN_SAVE) + +/* MinipsdVipsCompression as a GEnum, so libvips takes it by its nick ("rle", "zip", "none", + "zip-predicted"). */ +static GType minipsd_vips_compression_get_type(void) +{ + static gsize type = 0; + if (g_once_init_enter(&type)) { + static const GEnumValue values[] = { + { MINIPSD_VIPS_COMPRESSION_RLE, "MINIPSD_VIPS_COMPRESSION_RLE", "rle" }, + { MINIPSD_VIPS_COMPRESSION_ZIP, "MINIPSD_VIPS_COMPRESSION_ZIP", "zip" }, + { MINIPSD_VIPS_COMPRESSION_NONE, "MINIPSD_VIPS_COMPRESSION_NONE", "none" }, + { MINIPSD_VIPS_COMPRESSION_ZIP_PREDICTED, "MINIPSD_VIPS_COMPRESSION_ZIP_PREDICTED", "zip-predicted" }, + { 0, NULL, NULL }, + }; + g_once_init_leave(&type, g_enum_register_static("MinipsdVipsCompression", values)); + } + return (GType)type; +} + +/* What libvips' band formats become: 8-bit, 16-bit and float samples, as PSD has them. */ +static VipsBandFormat minipsd_vips_save_save_formats[] = { + /* UCHAR, CHAR, USHORT, SHORT, UINT, INT, FLOAT, COMPLEX, DOUBLE, DPCOMPLEX */ + VIPS_FORMAT_UCHAR, VIPS_FORMAT_UCHAR, VIPS_FORMAT_USHORT, VIPS_FORMAT_USHORT, VIPS_FORMAT_USHORT, + VIPS_FORMAT_USHORT, VIPS_FORMAT_FLOAT, VIPS_FORMAT_FLOAT, VIPS_FORMAT_FLOAT, VIPS_FORMAT_FLOAT +}; + +static void minipsd_vips_save_dispose(GObject *gobject) +{ + MinipsdVipsSave *psd = (MinipsdVipsSave *)gobject; + VIPS_UNREF(psd->target); + G_OBJECT_CLASS(minipsd_vips_save_parent_class)->dispose(gobject); +} + +static int minipsd_vips_save_to_target(void *user, const void *data, size_t size) +{ + return vips_target_write((VipsTarget *)user, data, size) == 0; /* 0 stops the writer */ +} + +/* A blob of the image as a resource to keep, when it has one. */ +static void minipsd_vips_save_keep_blob(VipsImage *image, const char *name, uint16_t id, minipsd_resource *res, + size_t *count) +{ + const void *data; + size_t size; + if (!vips_image_get_typeof(image, name) || vips_image_get_blob(image, name, &data, &size) || !size) return; + memset(&res[*count], 0, sizeof res[*count]); + res[*count].id = id; + res[*count].data.data = (const uint8_t *)data; + res[*count].data.size = size; + ++*count; +} + +/* The PSD mode of an image's bands: grey, RGB or CMYK (by its interpretation), each with or + without alpha, its colour channels in *colors; -1 for any other band count. */ +static int minipsd_vips_save_mode(const VipsImage *in, size_t *colors) +{ + if (in->Type == VIPS_INTERPRETATION_CMYK && (in->Bands == PSD_CMYK || in->Bands == PSD_CMYK + 1)) { + *colors = PSD_CMYK; + return MINIPSD_MODE_CMYK; + } + if (in->Bands == PSD_RGB || in->Bands == PSD_RGB + 1) { + *colors = PSD_RGB; + return MINIPSD_MODE_RGB; + } + if (in->Bands == 1 || in->Bands == 2) { + *colors = 1; + return MINIPSD_MODE_GREY; + } + return -1; +} + +/* The PSD depth of an image's samples: 8, 16, 32 (floats); 0 for another format. */ +static int minipsd_vips_save_depth(const VipsImage *in) +{ + return in->BandFmt == VIPS_FORMAT_UCHAR ? 8 : in->BandFmt == VIPS_FORMAT_USHORT ? 16 : in->BandFmt == VIPS_FORMAT_FLOAT ? 32 : 0; +} + +/* --- The channels, encoded once: rows as they come, kept compressed, written as stored ------- */ + +#define PSD_MARKER_SIZE 2 /* a stored channel's compression marker, big-endian */ +#define PSD_STORED_RAW 0 /* ... raw */ +#define PSD_STORED_RLE 1 /* ... PackBits, its rows' sizes ahead of them */ +#define PSD_STORED_ZIP 2 /* ... zlib, without prediction */ +#define PSD_STORED_ZIP_PREDICTED 3 /* ... zlib of each sample's difference from its left neighbour */ +#define PSD_COUNT_PSD 2 /* an RLE row's size: two bytes in a PSD */ +#define PSD_COUNT_PSB 4 /* ... four in a PSB */ +#define PSD_MAX_FILE INT32_MAX /* a PSD Photoshop opens: 2 GB at most, as Adobe documents it */ +#define PSD_ZIP_ROOM 65536 /* deflate's output grows by at least this */ + +/* Bytes that grow, past what guint can count. */ +typedef struct _MinipsdVipsSaveBytes { + uint8_t *data; + size_t size, room; +} MinipsdVipsSaveBytes; + +/* Room for extra bytes more: exactly, or at least doubled. */ +static int minipsd_vips_save_grow(MinipsdVipsSaveBytes *b, size_t extra, int exact) +{ + if (extra > b->room - b->size) { + const size_t room = exact ? b->size + extra : VIPS_MAX(b->room * 2, b->size + extra); + uint8_t *grown; + if (room < b->size || !(grown = (uint8_t *)g_try_realloc(b->data, room))) return -1; + b->data = grown; + b->room = room; + } + return 0; +} + +static int minipsd_vips_save_append(MinipsdVipsSaveBytes *b, const void *data, size_t size) +{ + if (minipsd_vips_save_grow(b, size, 0)) return -1; + memcpy(b->data + b->size, data, size); + b->size += size; + return 0; +} + +/* One channel: room ahead (head) for its marker and its rows' sizes, the most a PSB needs, then + its rows encoded one after another; each one's size (RLE), or its deflate (ZIP). The block a + file stores is then written in front of the rows, where they lie: never copied. */ +typedef struct _MinipsdVipsSaveChannel { + MinipsdVipsSaveBytes rows; + size_t head; + size_t *sizes; + z_stream zip; + int zipping; +} MinipsdVipsSaveChannel; + +/* One image's channels as a file stores them: a layer's, each channel its own block, or the + composite's, one block, its channels joined behind the first's head, its rows matted when the + image has alpha (as minipsd mattes a composite it is given). */ +typedef struct _MinipsdVipsSaveChannels { + MinipsdVipsSaveChannel channel[PSD_MAX_PLANES]; + unsigned planes; /* the image's bands: colour channels, then alpha */ + int compression; /* MINIPSD_RLE, _RAW, _ZIP, _ZIP_PREDICTED */ + int composite, matte; + uint32_t height; + size_t longest; /* the longest RLE row: a PSD counts to 65535 */ +} MinipsdVipsSaveChannels; + +/* One image read once, top to bottom, each row encoded into every target's channels. */ +typedef struct _MinipsdVipsSavePass { + MinipsdVipsSaveChannels **targets; + unsigned count, planes; + int depth, mode; + uint32_t width; + uint8_t *row; /* one encoded row, MINIPSD_ROW_BOUND */ + size_t row_size; + uint8_t *ink; /* one row of pixels, CMYK inverted to PSD's ink */ + uint8_t *matted; /* one row of pixels, matted for a composite */ + int failed; /* memory ran out, or deflate failed, while the bands came */ +} MinipsdVipsSavePass; + +static void minipsd_vips_save_channel_free(MinipsdVipsSaveChannel *c) +{ + g_free(c->rows.data); + g_free(c->sizes); + if (c->zipping) (void)deflateEnd(&c->zip); + memset(c, 0, sizeof *c); +} + +static void minipsd_vips_save_channels_free(MinipsdVipsSaveChannels *c) +{ + unsigned k; + for (k = 0; k < PSD_MAX_PLANES; ++k) minipsd_vips_save_channel_free(&c->channel[k]); + memset(c, 0, sizeof *c); +} + +/* A channel ready for rows: the head for the tables of channels channels (the composite's first + holds them all, its others none), raw rows' room known ahead, ZIP's deflate started. */ +static int minipsd_vips_save_channel_init(MinipsdVipsSaveChannel *c, int compression, unsigned channels, uint32_t height, + size_t row_bytes) +{ + const int rle = compression == MINIPSD_RLE, raw = compression == MINIPSD_RAW; + c->head = channels ? PSD_MARKER_SIZE + (rle ? (size_t)channels * height * PSD_COUNT_PSB : 0) : 0; + if (rle && !(c->sizes = g_try_new(size_t, height))) return -1; + if (!rle && !raw) { + if (deflateInit(&c->zip, PSD_ZIP_LEVEL) != Z_OK) return -1; + c->zipping = 1; + } + if (minipsd_vips_save_grow(&c->rows, c->head + (raw ? (size_t)(channels ? channels : 1u) * height * row_bytes : 0), 1)) + return -1; + c->rows.size = c->head; + return 0; +} + +/* An image's channels ready for its rows: a layer's (composite 0) or the composite's. */ +static int minipsd_vips_save_channels_init(MinipsdVipsSaveChannels *c, VipsImage *in, int depth, int compression, + int composite, int matte) +{ + const size_t row_bytes = (size_t)MINIPSD_ROW_BYTES((uint32_t)in->Xsize, depth); + unsigned k; + memset(c, 0, sizeof *c); + c->planes = (unsigned)in->Bands; + c->compression = compression; + c->composite = composite; + c->matte = matte; + c->height = (uint32_t)in->Ysize; + for (k = 0; k < c->planes; ++k) + if (minipsd_vips_save_channel_init(&c->channel[k], compression, composite ? (k ? 0 : c->planes) : 1, c->height, row_bytes)) + return -1; + return 0; +} + +/* data deflated onto the channel's rows; with Z_FINISH, the stream ended. */ +static int minipsd_vips_save_deflate(MinipsdVipsSaveChannel *c, const uint8_t *data, size_t size, int flush) +{ + int rc; + c->zip.next_in = data; + c->zip.avail_in = (uInt)size; /* a row: within uInt */ + do { + if (minipsd_vips_save_grow(&c->rows, PSD_ZIP_ROOM, 0)) return -1; + c->zip.next_out = c->rows.data + c->rows.size; + c->zip.avail_out = (uInt)VIPS_MIN(c->rows.room - c->rows.size, (size_t)UINT_MAX); + rc = deflate(&c->zip, flush); + c->rows.size = (size_t)(c->zip.next_out - c->rows.data); + if (rc != Z_OK && rc != Z_STREAM_END && rc != Z_BUF_ERROR) return -1; + } while (flush == Z_FINISH ? rc != Z_STREAM_END : c->zip.avail_out == 0); + return 0; +} + +/* Row y of channel k out of interleaved pixels, encoded into t's channel: PackBits, raw, or + deflated (raw or predicted rows into the channel's stream). */ +static int minipsd_vips_save_encode(MinipsdVipsSavePass *p, MinipsdVipsSaveChannels *t, const uint8_t *pixels, unsigned k, + uint32_t y) +{ + MinipsdVipsSaveChannel *c = &t->channel[k]; + const size_t sample = (size_t)(p->depth / CHAR_BIT); + size_t n = (size_t)MINIPSD_ROW_BYTES(p->width, p->depth); + const int rc = t->compression == MINIPSD_ZIP_PREDICTED + ? minipsd_predict_row(p->depth, pixels + k * sample, p->planes * sample, p->width, p->row, + p->row_size) + : minipsd_encode_row(t->compression == MINIPSD_RLE ? MINIPSD_RLE : MINIPSD_RAW, p->depth, + pixels + k * sample, p->planes * sample, p->width, p->row, + p->row_size, &n); + if (rc != MINIPSD_OK) return -1; + if (c->zipping) return minipsd_vips_save_deflate(c, p->row, n, Z_NO_FLUSH); + if (c->sizes) { + c->sizes[y] = n; + if (n > t->longest) t->longest = n; + } + return minipsd_vips_save_append(&c->rows, p->row, n); +} + +/* count rows of interleaved pixels, from row y on, stride bytes apart: CMYK inverted to PSD's ink, + then each target's channels encoded, a composite's rows matted when it asks (RGB with alpha). */ +static int minipsd_vips_save_encode_rows(MinipsdVipsSavePass *p, const uint8_t *rows, uint32_t y, uint32_t count, + size_t stride) +{ + const size_t bytes = (size_t)p->width * p->planes * (size_t)(p->depth / CHAR_BIT); + uint32_t i; + unsigned t, k; + for (i = 0; i < count; ++i, rows += stride) { + const uint8_t *pixels = rows; + int matted = 0; + if (p->mode == MINIPSD_MODE_CMYK) { + memcpy(p->ink, rows, bytes); + minipsd_vips__invert_cmyk(p->ink, p->width, p->planes, p->depth); + pixels = p->ink; + } + for (t = 0; t < p->count; ++t) { + MinipsdVipsSaveChannels *target = p->targets[t]; + if (target->matte && !matted) { /* as minipsd mattes a composite it is given (RGB; the others as they are) */ + memcpy(p->matted, pixels, bytes); + (void)minipsd_matte_row(p->mode, p->depth, p->matted, p->width); + matted = 1; + } + for (k = 0; k < p->planes; ++k) + if (minipsd_vips_save_encode(p, target, target->matte ? p->matted : pixels, k, y + i)) return -1; + } + } + return 0; +} + +/* A band of the image, as libvips hands it: once, top to bottom; the region is the band. */ +static int minipsd_vips_save_band(VipsRegion *region, VipsRect *area, void *user) +{ + MinipsdVipsSavePass *p = (MinipsdVipsSavePass *)user; + if (minipsd_vips_save_encode_rows(p, VIPS_REGION_ADDR_TOPLEFT(region), (uint32_t)area->top, (uint32_t)area->height, + (size_t)VIPS_REGION_LSKIP(region))) { + p->failed = 1; + return -1; + } + return 0; +} + +/* in read once, top to bottom, in the bands libvips hands a saver, its rows encoded into count + targets' channels (each with in's bands). -1 on failure, *memory set when memory ran out or a + deflate failed; otherwise the error is the image's own, reported where it arose. */ +static int minipsd_vips_save_pass(VipsImage *in, int depth, int mode, MinipsdVipsSaveChannels **targets, unsigned count, + int *memory) +{ + MinipsdVipsSavePass p; + const size_t pixels = (size_t)in->Xsize * (size_t)in->Bands * (size_t)(depth / CHAR_BIT); + int rc; + memset(&p, 0, sizeof p); + p.targets = targets; + p.count = count; + p.planes = (unsigned)in->Bands; + p.depth = depth; + p.mode = mode; + p.width = (uint32_t)in->Xsize; + p.row_size = (size_t)MINIPSD_ROW_BOUND(p.width, depth); + p.row = (uint8_t *)g_try_malloc(p.row_size); + p.ink = (uint8_t *)g_try_malloc(pixels); + p.matted = (uint8_t *)g_try_malloc(pixels); + rc = !p.row || !p.ink || !p.matted ? -1 : vips_sink_disc(in, minipsd_vips_save_band, &p); + *memory = rc && (!p.row || !p.ink || !p.matted || p.failed); + g_free(p.row); + g_free(p.ink); + g_free(p.matted); + return rc ? -1 : 0; +} + +/* Every row in: the deflates ended, a composite's channels joined behind the first's head (the + file stores them as one block). */ +static int minipsd_vips_save_finish(MinipsdVipsSaveChannels *c) +{ + MinipsdVipsSaveBytes *first = &c->channel[0].rows; + size_t total = first->size; + unsigned k; + for (k = 0; k < c->planes; ++k) + if (c->channel[k].zipping) { + if (minipsd_vips_save_deflate(&c->channel[k], NULL, 0, Z_FINISH)) return -1; + (void)deflateEnd(&c->channel[k].zip); + c->channel[k].zipping = 0; + } + if (!c->composite) return 0; + for (k = 1; k < c->planes; ++k) total += c->channel[k].rows.size; + if (minipsd_vips_save_grow(first, total - first->size, 1)) return -1; + for (k = 1; k < c->planes; ++k) { + memcpy(first->data + first->size, c->channel[k].rows.data, c->channel[k].rows.size); + first->size += c->channel[k].rows.size; + g_free(c->channel[k].rows.data); /* copied: the first holds it now */ + memset(&c->channel[k].rows, 0, sizeof c->channel[k].rows); + } + return 0; +} + +/* Puts n, big-endian, in size bytes. */ +static uint8_t *minipsd_vips_save_put(uint8_t *p, uint64_t n, unsigned size) +{ + unsigned i; + for (i = 0; i < size; ++i) p[i] = (uint8_t)(n >> (CHAR_BIT * (size - 1 - i))); + return p + size; +} + +/* The block a file stores for channels c[0] .. c[channels - 1], whose rows c[0] holds: the + marker, an RLE one's row sizes in count bytes each, every channel's in turn, then the rows. + Written into c[0]'s head, just ahead of the rows. */ +static minipsd_bytes minipsd_vips_save_block(MinipsdVipsSaveChannel *c, unsigned channels, uint32_t height, unsigned count, + int compression) +{ + const int rle = compression == MINIPSD_RLE; + const size_t table = rle ? (size_t)channels * height * count : 0; + const size_t start = c->head - PSD_MARKER_SIZE - table; + const uint8_t marker = rle ? PSD_STORED_RLE : compression == MINIPSD_RAW ? PSD_STORED_RAW + : compression == MINIPSD_ZIP ? PSD_STORED_ZIP : PSD_STORED_ZIP_PREDICTED; + minipsd_bytes block; + uint8_t *p = minipsd_vips_save_put(c->rows.data + start, marker, PSD_MARKER_SIZE); + unsigned k; + uint32_t y; + for (k = 0; rle && k < channels; ++k) + for (y = 0; y < height; ++y) p = minipsd_vips_save_put(p, c[k].sizes[y], count); + block.data = c->rows.data + start; + block.size = c->rows.size - start; + return block; +} + +/* A layer's channels as a PSD (count 2) or a PSB (4) stores them, into stored: the colour + channels, then transparency. */ +static void minipsd_vips_save_layer_blocks(MinipsdVipsSaveChannels *c, unsigned count, minipsd_bytes *stored) +{ + unsigned k; + for (k = 0; k < c->planes; ++k) stored[k] = minipsd_vips_save_block(&c->channel[k], 1, c->height, count, c->compression); +} + +/* The composite's channels as a PSD or a PSB stores them: one block. */ +static minipsd_bytes minipsd_vips_save_composite_block(MinipsdVipsSaveChannels *c, unsigned count) +{ + return minipsd_vips_save_block(c->channel, c->planes, c->height, count, c->compression); +} + +/* One image read once into c, encoded by compression; a composite's joined as the file stores it. + -1 on failure, *memory set when memory ran out (else the image's own error, reported). */ +static int minipsd_vips_save_encode_image(MinipsdVipsSaveChannels *c, VipsImage *in, int depth, int mode, int compression, + int composite, int matte, int *memory) +{ + *memory = 1; + if (minipsd_vips_save_channels_init(c, in, depth, compression, composite, matte)) return -1; + if (minipsd_vips_save_pass(in, depth, mode, &c, 1, memory)) return -1; + *memory = 1; + if (minipsd_vips_save_finish(c)) return -1; + *memory = 0; + return 0; +} + +/* Whether a PSD holds the document: its layer section within the 4 GiB its length counts + (minipsd refuses one past it, MINIPSD_ERR_SIZE), the file within PSD_MAX_FILE. Any other + refusal is minipsd_write's to report. */ +static int minipsd_vips_save_fits_psd(const minipsd_doc *doc) +{ + minipsd_writer *w; + void *workspace; + size_t size = 0; + int rc = minipsd_writer_workspace_size(doc, NULL, &size), fits; + if (rc != MINIPSD_OK) return rc != MINIPSD_ERR_SIZE; + if (!(workspace = g_try_malloc(size))) return 1; /* minipsd_write will not find it either */ + rc = minipsd_writer_init(&w, doc, NULL, workspace, size); + fits = rc == MINIPSD_OK ? minipsd_writer_total(w) <= PSD_MAX_FILE : rc != MINIPSD_ERR_SIZE; + g_free(workspace); + return fits; +} + +/* MINIPSD_* compression by the option: the layers'; the composite's is RLE, raw with none. */ +static int minipsd_vips_save_layer_compression(MinipsdVipsCompression compression) +{ + static const int by_option[] = { MINIPSD_RLE, MINIPSD_ZIP, MINIPSD_RAW, MINIPSD_ZIP_PREDICTED }; + return by_option[compression]; +} + +static int minipsd_vips_save_build(VipsObject *object) +{ + VipsForeignSave *save = (VipsForeignSave *)object; + MinipsdVipsSave *psd = (MinipsdVipsSave *)object; + VipsImage *in; + minipsd_resource resources[PSD_MAX_RESOURCES]; + minipsd_bytes stored[PSD_MAX_PLANES]; + minipsd_layer layer; + minipsd_doc doc; + MinipsdVipsSaveChannels layer_channels, composite_channels, *targets[2]; + const void *icc; + const char *filename; + size_t icc_size, colors = 0, count = 0; + double ratio; + unsigned n = 0, table; + int rc, mode, alpha, layered, psb, memory = 0; + + if (VIPS_OBJECT_CLASS(minipsd_vips_save_parent_class)->build(object)) return -1; + in = save->ready; + + /* The mode from the bands: grey, RGB or CMYK, each with or without alpha. */ + memset(&doc, 0, sizeof doc); + if ((mode = minipsd_vips_save_mode(in, &colors)) < 0) { + vips_error(VIPS_OBJECT_GET_CLASS(object)->nickname, "%s", "PSD takes grey, RGB or CMYK, each with or without alpha"); + return -1; + } + doc.color_mode = (uint16_t)mode; + doc.depth = (uint16_t)minipsd_vips_save_depth(in); /* format_table made it one of the three */ + alpha = (size_t)in->Bands > colors; /* transparency as the image has it: none, none added */ + layered = alpha || psd->layered; /* without alpha: flat, unless asked */ + doc.width = (uint32_t)in->Xsize; + doc.height = (uint32_t)in->Ysize; + doc.dpi = in->Xres * MM_PER_INCH; + if (!(doc.dpi > 0.0 && doc.dpi < PSD_MAX_DPI)) doc.dpi = PSD_DEFAULT_DPI; + + /* Every channel encoded once, as minipsd encodes it, while libvips hands the image over in + bands, top to bottom: memory about the file's size, never the whole image's. One pass + feeds the layer and the composite. */ + memset(&layer_channels, 0, sizeof layer_channels); + if (layered) { + memory = minipsd_vips_save_channels_init(&layer_channels, in, doc.depth, + minipsd_vips_save_layer_compression(psd->compression), 0, 0); + targets[n++] = &layer_channels; + } + if (!memory) + memory = minipsd_vips_save_channels_init(&composite_channels, in, doc.depth, + psd->compression == MINIPSD_VIPS_COMPRESSION_NONE ? MINIPSD_RAW : MINIPSD_RLE, + 1, alpha); + else + memset(&composite_channels, 0, sizeof composite_channels); + targets[n++] = &composite_channels; + if (!memory && minipsd_vips_save_pass(in, doc.depth, doc.color_mode, targets, n, &memory) && !memory) { + minipsd_vips_save_channels_free(&layer_channels); /* the image's own error, reported where it arose */ + minipsd_vips_save_channels_free(&composite_channels); + return -1; + } + if (!memory) memory = minipsd_vips_save_finish(&layer_channels) || minipsd_vips_save_finish(&composite_channels); + if (memory) { + minipsd_vips_save_channels_free(&layer_channels); + minipsd_vips_save_channels_free(&composite_channels); + vips_error(VIPS_OBJECT_GET_CLASS(object)->nickname, "%s", minipsd_error_string(MINIPSD_ERR_ALLOC)); + return -1; + } + + memset(stored, 0, sizeof stored); /* past the image's bands: none (transparency of size 0) */ + memset(&layer, 0, sizeof layer); + layer.name = psd->layer_name ? psd->layer_name : PSD_LAYER_NAME; + layer.opacity = UCHAR_MAX; + layer.no_alpha = (uint8_t)!alpha; + layer.stored = stored; /* its own rectangle, the canvas: a stored layer gives it */ + layer.bottom = (int32_t)doc.height; + layer.right = (int32_t)doc.width; + doc.layers = layered ? &layer : NULL; + doc.layer_count = layered ? 1 : 0; + doc.composite_channels = (uint16_t)in->Bands; + doc.composite_alpha = (uint8_t)alpha; + doc.compression = MINIPSD_RLE; + if (vips_image_get_typeof(in, VIPS_META_ICC_NAME) && !vips_image_get_blob(in, VIPS_META_ICC_NAME, &icc, &icc_size)) { + doc.icc = (const uint8_t *)icc; + doc.icc_size = icc_size; + } + if (vips_image_get_typeof(in, "psd-pixel-aspect-ratio") && !vips_image_get_double(in, "psd-pixel-aspect-ratio", &ratio) + && ratio > 0.0) + doc.pixel_aspect_ratio = ratio; + minipsd_vips_save_keep_blob(in, VIPS_META_EXIF_NAME, MINIPSD_RES_EXIF_DATA_1, resources, &count); + minipsd_vips_save_keep_blob(in, VIPS_META_XMP_NAME, MINIPSD_RES_XMP_METADATA, resources, &count); + minipsd_vips_save_keep_blob(in, VIPS_META_IPTC_NAME, MINIPSD_RES_IPTC_NAA, resources, &count); + doc.resources = resources; + doc.resource_count = count; + + /* PSB by the option, a file named .psb (libvips writes files through a target too), a side + past PSD's; then, the channels known, a row a PSD cannot count, a layer section past its + 4 GiB, or a file past what Photoshop opens as a PSD. */ + filename = vips_connection_filename(VIPS_CONNECTION(psd->target)); + psb = psd->psb || (filename && vips_iscasepostfix(filename, ".psb")) || in->Xsize > MINIPSD_MAX_SIDE + || in->Ysize > MINIPSD_MAX_SIDE || layer_channels.longest > UINT16_MAX || composite_channels.longest > UINT16_MAX; + for (;;) { /* the blocks for the format, again for a PSB when a PSD cannot hold them */ + table = psb ? PSD_COUNT_PSB : PSD_COUNT_PSD; + doc.format = psb ? MINIPSD_PSB : MINIPSD_PSD; + if (layered) minipsd_vips_save_layer_blocks(&layer_channels, table, stored); + doc.composite_stored = minipsd_vips_save_composite_block(&composite_channels, table); + if (psb || minipsd_vips_save_fits_psd(&doc)) break; + psb = 1; + } + + rc = minipsd_write(&doc, NULL, minipsd_vips_save_to_target, psd->target); + minipsd_vips_save_channels_free(&layer_channels); + minipsd_vips_save_channels_free(&composite_channels); + if (rc != MINIPSD_OK) { + vips_error(VIPS_OBJECT_GET_CLASS(object)->nickname, "%s", minipsd_error_string(rc)); + return -1; + } + return vips_target_end(psd->target); +} + +static void minipsd_vips_save_class_init(MinipsdVipsSaveClass *class) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS(class); + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsForeignSaveClass *save_class = (VipsForeignSaveClass *)class; + + gobject_class->dispose = minipsd_vips_save_dispose; + minipsd_vips__class_properties(class); + + object_class->nickname = "psdsave_base"; + object_class->description = "save PSD and PSB with minipsd"; + object_class->build = minipsd_vips_save_build; + + /* Any bands: the build takes grey, RGB and CMYK, with or without alpha, and refuses the rest. */ +#if VIPS_MAJOR_VERSION > 8 || (VIPS_MAJOR_VERSION == 8 && VIPS_MINOR_VERSION >= 17) + save_class->saveable = VIPS_FOREIGN_SAVEABLE_ANY; +#else + save_class->saveable = VIPS_SAVEABLE_ANY; +#endif + save_class->format_table = minipsd_vips_save_save_formats; + + PSD_ARGS_BEGIN + VIPS_ARG_BOOL(class, "psb", 20, "PSB", "Save as PSB (also for a .psb file, or a side past 30000)", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSave, psb), FALSE); + VIPS_ARG_ENUM(class, "compression", 21, "Compression", "The layer's compression: rle, zip, zip-predicted, or none (the composite too)", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSave, compression), + minipsd_vips_compression_get_type(), MINIPSD_VIPS_COMPRESSION_RLE); + VIPS_ARG_STRING(class, "layer_name", 22, "Layer name", "The layer's name, UTF-8", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSave, layer_name), PSD_LAYER_NAME); + VIPS_ARG_BOOL(class, "layered", 23, "Layered", "Without alpha, a layer too, without transparency (else flat)", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSave, layered), FALSE); + PSD_ARGS_END +} + +static void minipsd_vips_save_init(MinipsdVipsSave *psd) +{ + (void)psd; +} + +/* --- psdsave_target ------------------------------------------------------------------------ */ + +typedef struct _MinipsdVipsSaveTarget { + MinipsdVipsSave parent_object; + VipsTarget *target; +} MinipsdVipsSaveTarget; + +typedef MinipsdVipsSaveClass MinipsdVipsSaveTargetClass; + +G_DEFINE_TYPE(MinipsdVipsSaveTarget, minipsd_vips_save_target, minipsd_vips_save_get_type()) + +static int minipsd_vips_save_target_build(VipsObject *object) +{ + MinipsdVipsSave *psd = (MinipsdVipsSave *)object; + MinipsdVipsSaveTarget *target = (MinipsdVipsSaveTarget *)object; + if (target->target) { + psd->target = target->target; + g_object_ref(psd->target); + } + return VIPS_OBJECT_CLASS(minipsd_vips_save_target_parent_class)->build(object); +} + +static void minipsd_vips_save_target_class_init(MinipsdVipsSaveTargetClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsForeignClass *foreign_class = (VipsForeignClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdsave_target"; + object_class->description = "save PSD and PSB to a target with minipsd"; + object_class->build = minipsd_vips_save_target_build; + foreign_class->suffs = minipsd_vips__suffs; + + PSD_ARGS_BEGIN + VIPS_ARG_OBJECT(class, "target", 1, "Target", "Target to save to", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsSaveTarget, target), VIPS_TYPE_TARGET); + PSD_ARGS_END +} + +static void minipsd_vips_save_target_init(MinipsdVipsSaveTarget *target) +{ + (void)target; +} + +/* --- psdsave (a file) ---------------------------------------------------------------------- */ + +typedef struct _MinipsdVipsSaveFile { + MinipsdVipsSave parent_object; + char *filename; +} MinipsdVipsSaveFile; + +typedef MinipsdVipsSaveClass MinipsdVipsSaveFileClass; + +G_DEFINE_TYPE(MinipsdVipsSaveFile, minipsd_vips_save_file, minipsd_vips_save_get_type()) + +static int minipsd_vips_save_file_build(VipsObject *object) +{ + MinipsdVipsSave *psd = (MinipsdVipsSave *)object; + MinipsdVipsSaveFile *file = (MinipsdVipsSaveFile *)object; + if (file->filename && !(psd->target = vips_target_new_to_file(file->filename))) return -1; + return VIPS_OBJECT_CLASS(minipsd_vips_save_file_parent_class)->build(object); +} + +static void minipsd_vips_save_file_class_init(MinipsdVipsSaveFileClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsForeignClass *foreign_class = (VipsForeignClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdsave"; + object_class->description = "save PSD and PSB to a file with minipsd"; + object_class->build = minipsd_vips_save_file_build; + foreign_class->suffs = minipsd_vips__suffs; + + PSD_ARGS_BEGIN + VIPS_ARG_STRING(class, "filename", 1, "Filename", "Filename to save to", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsSaveFile, filename), NULL); + PSD_ARGS_END +} + +static void minipsd_vips_save_file_init(MinipsdVipsSaveFile *file) +{ + (void)file; +} + +/* --- psdsave_buffer ------------------------------------------------------------------------ */ + +typedef struct _MinipsdVipsSaveBuffer { + MinipsdVipsSave parent_object; + VipsArea *buf; +} MinipsdVipsSaveBuffer; + +typedef MinipsdVipsSaveClass MinipsdVipsSaveBufferClass; + +G_DEFINE_TYPE(MinipsdVipsSaveBuffer, minipsd_vips_save_buffer, minipsd_vips_save_get_type()) + +static int minipsd_vips_save_buffer_build(VipsObject *object) +{ + MinipsdVipsSave *psd = (MinipsdVipsSave *)object; + VipsBlob *blob; + if (!(psd->target = vips_target_new_to_memory())) return -1; + if (VIPS_OBJECT_CLASS(minipsd_vips_save_buffer_parent_class)->build(object)) return -1; + g_object_get(psd->target, "blob", &blob, NULL); + g_object_set(object, "buffer", blob, NULL); + vips_area_unref(VIPS_AREA(blob)); + return 0; +} + +static void minipsd_vips_save_buffer_class_init(MinipsdVipsSaveBufferClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsForeignClass *foreign_class = (VipsForeignClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdsave_buffer"; + object_class->description = "save PSD and PSB to a buffer with minipsd"; + object_class->build = minipsd_vips_save_buffer_build; + foreign_class->suffs = minipsd_vips__suffs; + + PSD_ARGS_BEGIN + VIPS_ARG_BOXED(class, "buffer", 1, "Buffer", "Buffer to save to", VIPS_ARGUMENT_REQUIRED_OUTPUT, + G_STRUCT_OFFSET(MinipsdVipsSaveBuffer, buf), VIPS_TYPE_BLOB); + PSD_ARGS_END +} + +static void minipsd_vips_save_buffer_init(MinipsdVipsSaveBuffer *buffer) +{ + (void)buffer; +} + +/* --- psdsave_layers: a layered document from an array of images ----------------------------- */ + +/* A saver takes one image; a layered document is several, so this is an operation over an array + of them, as vips_composite is. Each image is a layer, bottom first, its name, opacity, + visibility, blend mode and clipping in the psd-layer-* fields psdload sets, its position in + xoffset and yoffset; psd-layer-empty makes it an empty layer (no pixels), as Photoshop writes + one. The composite is given, or made by vips_composite from the visible layers. */ + +#define PSD_BLEND_KEY_SIZE 4 /* a blend mode's key: four characters, such as "mul " */ +#define PSD_COMPOSE_STEPS 9 /* the images minipsd_vips_save_compose makes for a layer */ +#define PSD_MASK_DISABLED 0x02 /* a mask's flag: disabled */ +#define PSD_DIVIDER_NAME "" /* a divider's name, as Photoshop names it */ + +typedef struct _MinipsdVipsSaveLayers { + VipsOperation parent_object; + VipsTarget *target; /* set by the subclass's build */ + VipsArrayImage *in; /* the layers, bottom first */ + VipsImage *composite; /* the option: the composite, as given */ + MinipsdVipsCompression compression; + gboolean psb; + int width, height; /* the canvas: 0, from the composite or the layers */ + int left, top; /* where the canvas lies in the layers' coordinates */ +} MinipsdVipsSaveLayers; + +typedef VipsOperationClass MinipsdVipsSaveLayersClass; + +G_DEFINE_ABSTRACT_TYPE(MinipsdVipsSaveLayers, minipsd_vips_save_layers, VIPS_TYPE_OPERATION) + +static void minipsd_vips_save_layers_dispose(GObject *gobject) +{ + MinipsdVipsSaveLayers *psd = (MinipsdVipsSaveLayers *)gobject; + VIPS_UNREF(psd->target); + G_OBJECT_CLASS(minipsd_vips_save_layers_parent_class)->dispose(gobject); +} + +/* An int field of an image, or fallback without it. */ +static int minipsd_vips_save_field(VipsImage *image, const char *name, int fallback) +{ + int value; + return vips_image_get_typeof(image, name) && !vips_image_get_int(image, name, &value) ? value : fallback; +} + +/* A record's MINIPSD_SECTION_* from its psd-layer-section: a layer without one; -1 for another + text. */ +static int minipsd_vips_save_section(VipsImage *image) +{ + const char *text; + int k; + if (!vips_image_get_typeof(image, "psd-layer-section") || vips_image_get_string(image, "psd-layer-section", &text)) + return MINIPSD_SECTION_LAYER; + for (k = MINIPSD_SECTION_GROUP; k < PSD_SECTIONS; ++k) + if (strcmp(text, minipsd_vips__sections[k]) == 0) return k; + return -1; +} + +/* No pixels: an empty layer, or a group's record. */ +static int minipsd_vips_save_empty(VipsImage *image) +{ + return minipsd_vips_save_field(image, "psd-layer-empty", 0) != 0 || minipsd_vips_save_section(image) != MINIPSD_SECTION_LAYER; +} + +/* The image's mask (a new reference), or NULL without one. */ +static VipsImage *minipsd_vips_save_mask(VipsImage *image) +{ + VipsImage *mask; + return vips_image_get_typeof(image, "psd-layer-mask") && !vips_image_get_image(image, "psd-layer-mask", &mask) ? mask : NULL; +} + +/* A PSD blend key as a libvips blend mode, as the spec's table maps them (Working with libvips); + a key libvips has no counterpart for is drawn "over", as minipsd's own composer draws it. */ +static VipsBlendMode minipsd_vips_save_blend_mode(const char key[PSD_BLEND_KEY_SIZE]) +{ + static const struct { + char key[PSD_BLEND_KEY_SIZE + 1]; + VipsBlendMode mode; + } table[] = { + { "norm", VIPS_BLEND_MODE_OVER }, { "mul ", VIPS_BLEND_MODE_MULTIPLY }, + { "scrn", VIPS_BLEND_MODE_SCREEN }, { "over", VIPS_BLEND_MODE_OVERLAY }, + { "dark", VIPS_BLEND_MODE_DARKEN }, { "lite", VIPS_BLEND_MODE_LIGHTEN }, + { "lddg", VIPS_BLEND_MODE_ADD }, { "div ", VIPS_BLEND_MODE_COLOUR_DODGE }, + { "idiv", VIPS_BLEND_MODE_COLOUR_BURN }, { "hLit", VIPS_BLEND_MODE_HARD_LIGHT }, + { "sLit", VIPS_BLEND_MODE_SOFT_LIGHT }, { "diff", VIPS_BLEND_MODE_DIFFERENCE }, + { "smud", VIPS_BLEND_MODE_EXCLUSION }, + }; + size_t k; + for (k = 0; k < sizeof table / sizeof table[0]; ++k) + if (memcmp(key, table[k].key, PSD_BLEND_KEY_SIZE) == 0) return table[k].mode; + return VIPS_BLEND_MODE_OVER; +} + +/* Each layer's visibility and opacity (0 to 1) with its groups': a hidden group hides its layers, + its opacity multiplies theirs. Walked top down: a group's top record comes before its layers, + its divider after them. */ +static void minipsd_vips_save_groups(VipsImage **in, int n, int *hidden, double *opacity) +{ + int *group_hidden = g_new0(int, (gsize)n + 1), level = 0, i; + double *group_opacity = g_new(double, (gsize)n + 1); + group_opacity[0] = 1.0; + for (i = n - 1; i >= 0; --i) { + const int section = minipsd_vips_save_section(in[i]); + const int own = minipsd_vips_save_field(in[i], "psd-layer-hidden", 0) != 0; + const double alpha = VIPS_CLIP(0, minipsd_vips_save_field(in[i], "psd-layer-opacity", UCHAR_MAX), UCHAR_MAX) / (double)UCHAR_MAX; + hidden[i] = group_hidden[level] || own; + opacity[i] = group_opacity[level] * alpha; + if (section == MINIPSD_SECTION_GROUP || section == MINIPSD_SECTION_GROUP_CLOSED) { + ++level; + group_hidden[level] = hidden[i]; + group_opacity[level] = opacity[i]; + } else if (section == MINIPSD_SECTION_GROUP_END && level > 0) { + --level; + } + } + g_free(group_hidden); + g_free(group_opacity); +} + +/* The visible layers composed by vips_composite onto a transparent canvas of width x height, at + their positions less (left, top), their opacity (with their groups') and their mask in their + alpha, their blend modes mapped; with no layer's transparency, flattened against white (as + minipsd mattes a composite without it). Its bands and format are the layers'. Clipping, a + group's blend mode and mask, and a blend mode libvips lacks are drawn as normal (pass through + for a group). NULL with an error. */ +static VipsImage *minipsd_vips_save_compose(MinipsdVipsSaveLayers *psd, VipsImage **in, int n, const VipsImage *ref, + size_t colors, int mode, int depth, int width, int height, int alpha) +{ + const double max = depth == 32 ? 1.0 : depth == 16 ? (double)UINT16_MAX : (double)UCHAR_MAX; + const int bands = (int)colors + 1; + VipsImage **t = (VipsImage **)vips_object_local_array(VIPS_OBJECT(psd), PSD_COMPOSE_STEPS * n + 3); + VipsImage **layers = (VipsImage **)vips_object_local_array(VIPS_OBJECT(psd), n + 1); /* the canvas, then each */ + int *modes = (int *)vips_malloc(VIPS_OBJECT(psd), (size_t)(n + 1) * sizeof(int)); + int *xs = (int *)vips_malloc(VIPS_OBJECT(psd), (size_t)(n + 1) * sizeof(int)); + int *ys = (int *)vips_malloc(VIPS_OBJECT(psd), (size_t)(n + 1) * sizeof(int)); + int *hidden = (int *)vips_malloc(VIPS_OBJECT(psd), (size_t)n * sizeof(int)); + double *opacity = (double *)vips_malloc(VIPS_OBJECT(psd), (size_t)n * sizeof(double)); + double white[PSD_MAX_PLANES]; + VipsImage *out, *composed = NULL; + int count = 0, i, k, rc; + + if (!modes || !xs || !ys || !hidden || !opacity) return NULL; + minipsd_vips_save_groups(in, n, hidden, opacity); + if (mode == MINIPSD_MODE_CMYK && depth != 8) { /* libvips takes CMYK's alpha as 8-bit */ + vips_error(VIPS_OBJECT_GET_CLASS(psd)->nickname, "%s", "a 16-bit CMYK document needs its composite given"); + return NULL; + } + /* The canvas: transparent, the document's size, as the layers are. */ + if (vips_black(&t[0], width, height, "bands", bands, NULL) || vips_cast(t[0], &t[1], ref->BandFmt, NULL) + || vips_copy(t[1], &layers[0], "interpretation", ref->Type, NULL)) + return NULL; + ++count; + for (i = 0; i < n; ++i) { /* bottom first, as minipsd and vips_composite both list them */ + VipsImage *layer = in[i], *alpha_band = NULL, *mask; + VipsImage **s = t + 2 + PSD_COMPOSE_STEPS * i; /* this layer's steps */ + char key[PSD_BLEND_KEY_SIZE] = { 'n', 'o', 'r', 'm' }; + const char *given; + if (minipsd_vips_save_empty(layer) || hidden[i]) continue; + if (layer->Bands == (int)colors) { /* without transparency: opaque */ + if (vips_bandjoin_const1(layer, &s[0], max, NULL)) return NULL; + layer = s[0]; + } + /* The mask over the layer's rectangle, its colour outside its own. */ + if ((mask = minipsd_vips_save_mask(in[i])) && !minipsd_vips_save_field(in[i], "psd-layer-mask-disabled", 0)) { + const double outside = minipsd_vips_save_field(in[i], "psd-layer-mask-default-color", UCHAR_MAX) ? max : 0.0; + if (minipsd_vips_save_empty(mask)) + rc = vips_black(&s[1], layer->Xsize, layer->Ysize, NULL) || vips_linear1(s[1], &s[2], 1.0, outside, NULL); + else { + VipsArrayDouble *background = vips_array_double_new(&outside, 1); + rc = vips_embed(mask, &s[2], mask->Xoffset - in[i]->Xoffset, mask->Yoffset - in[i]->Yoffset, layer->Xsize, layer->Ysize, + "extend", VIPS_EXTEND_BACKGROUND, "background", background, NULL); + vips_area_unref(VIPS_AREA(background)); + } + if (!rc) rc = vips_extract_band(layer, &s[3], (int)colors, NULL) || vips_multiply(s[3], s[2], &s[4], NULL); + g_object_unref(mask); + if (rc) return NULL; + alpha_band = s[4]; + opacity[i] /= max; /* the mask's scale */ + } else { + VIPS_UNREF(mask); + if (opacity[i] < 1.0) { + if (vips_extract_band(layer, &s[3], (int)colors, NULL)) return NULL; + alpha_band = s[3]; + } + } + if (alpha_band) { /* the opacity (and the mask) in the alpha_band */ + if (vips_linear1(alpha_band, &s[5], opacity[i], 0.0, NULL) || vips_extract_band(layer, &s[6], 0, "n", (int)colors, NULL) + || vips_bandjoin2(s[6], s[5], &s[7], NULL)) + return NULL; + layer = s[7]; + } + if (vips_copy(layer, &s[8], "interpretation", ref->Type, NULL)) return NULL; + layers[count] = s[8]; + g_object_ref(layers[count]); /* both arrays drop theirs */ + if (vips_image_get_typeof(in[i], "psd-layer-blend-mode") && !vips_image_get_string(in[i], "psd-layer-blend-mode", &given) + && strlen(given) == PSD_BLEND_KEY_SIZE) + memcpy(key, given, PSD_BLEND_KEY_SIZE); + modes[count - 1] = (int)minipsd_vips_save_blend_mode(key); + xs[count - 1] = in[i]->Xoffset - psd->left; /* where the layer goes; it may reach past the canvas */ + ys[count - 1] = in[i]->Yoffset - psd->top; + ++count; + } + if (count == 1) { /* nothing visible: the transparent canvas */ + composed = layers[0]; + g_object_ref(composed); + } else { + VipsArrayInt *x = vips_array_int_new(xs, count - 1), *y = vips_array_int_new(ys, count - 1); + rc = vips_composite(layers, &composed, count, modes, "x", x, "y", y, "compositing_space", ref->Type, NULL); + vips_area_unref(VIPS_AREA(x)); + vips_area_unref(VIPS_AREA(y)); + if (rc) return NULL; + } + if (!alpha) { /* no transparency anywhere: against white, as minipsd mattes; CMYK's white is no ink */ + VipsArrayDouble *background; + for (k = 0; k < (int)colors; ++k) white[k] = mode == MINIPSD_MODE_CMYK ? 0.0 : max; + background = vips_array_double_new(white, (int)colors); + rc = vips_flatten(composed, &out, "background", background, "max_alpha", max, NULL); + vips_area_unref(VIPS_AREA(background)); + g_object_unref(composed); + if (rc) return NULL; + composed = out; + } + rc = vips_cast(composed, &t[2 + PSD_COMPOSE_STEPS * n], ref->BandFmt, NULL); /* vips_composite may give a wider format */ + g_object_unref(composed); + if (rc || vips_copy(t[2 + PSD_COMPOSE_STEPS * n], &out, "interpretation", ref->Type, NULL)) return NULL; + return out; +} + +static int minipsd_vips_save_layers_build(VipsObject *object) +{ + MinipsdVipsSaveLayers *psd = (MinipsdVipsSaveLayers *)object; + const char *nickname = VIPS_OBJECT_GET_CLASS(object)->nickname; + VipsImage **in, *ref = NULL, *composite = NULL; + MinipsdVipsSaveChannels *channels = NULL, composite_channels; + minipsd_layer *layers = NULL; + minipsd_mask *masks = NULL; + VipsImage **mask_images = NULL; + minipsd_bytes *stored = NULL; + char **names = NULL; + minipsd_resource resources[PSD_MAX_RESOURCES]; + minipsd_doc doc; + const void *icc; + const char *filename, *error = NULL, *text; + size_t colors = 0, icc_size, count = 0, longest = 0; + unsigned table; + int n, i, mode, depth, compression, section, width, height, alpha = 0, psb, memory = 0, rc = MINIPSD_OK, + failed = 0; + + if (VIPS_OBJECT_CLASS(minipsd_vips_save_layers_parent_class)->build(object)) return -1; + in = vips_array_image_get(psd->in, &n); + if (n < 1) { + vips_error(nickname, "%s", "no layers"); + return -1; + } + + /* The mode and depth: the first layer with pixels decides, else the composite, else the first + layer; every other image must agree. */ + for (i = 0; i < n && !ref; ++i) + if (!minipsd_vips_save_empty(in[i])) ref = in[i]; + if (!ref) ref = psd->composite ? psd->composite : in[0]; + mode = minipsd_vips_save_mode(ref, &colors); + depth = minipsd_vips_save_depth(ref); + if (mode < 0 || !depth) { + vips_error(nickname, "%s", "PSD takes grey, RGB or CMYK, each with or without alpha, as uchar, ushort or float"); + return -1; + } + for (i = 0; i < n; ++i) { + if (minipsd_vips_save_section(in[i]) < 0) { + vips_error(nickname, "layer %d: psd-layer-section is not \"open\", \"closed\" or \"end\"", i); + return -1; + } + if (minipsd_vips_save_empty(in[i])) continue; + if (minipsd_vips_save_depth(in[i]) != depth || (in[i]->Bands != (int)colors && in[i]->Bands != (int)colors + 1) + || (mode == MINIPSD_MODE_CMYK) != (in[i]->Type == VIPS_INTERPRETATION_CMYK)) { + vips_error(nickname, "layer %d: not the mode or depth of the others (no layer is converted)", i); + return -1; + } + alpha |= in[i]->Bands > (int)colors; + } + + /* The canvas: the options; else the composite's size; else from (left, top), every layer in it. */ + width = psd->width; + height = psd->height; + if (width <= 0 || height <= 0) { + if (psd->composite) { + width = psd->composite->Xsize; + height = psd->composite->Ysize; + } else { + for (i = 0, width = height = 0; i < n; ++i) + if (!minipsd_vips_save_empty(in[i])) { + width = VIPS_MAX(width, in[i]->Xoffset + in[i]->Xsize - psd->left); + height = VIPS_MAX(height, in[i]->Yoffset + in[i]->Ysize - psd->top); + } + } + } + if (width <= 0 || height <= 0) { + vips_error(nickname, "%s", "no canvas: give width and height, or the composite"); + return -1; + } + if (psd->composite + && (psd->composite->Xsize != width || psd->composite->Ysize != height || minipsd_vips_save_depth(psd->composite) != depth + || (psd->composite->Bands != (int)colors && psd->composite->Bands != (int)colors + 1))) { + vips_error(nickname, "%s", "the composite is not the canvas's size, or not the layers' mode and depth"); + return -1; + } + + /* Each layer read once, its channels encoded as its rows come, then its mask; then the + composite. channels holds the layers', then the masks'. */ + memset(&composite_channels, 0, sizeof composite_channels); + compression = minipsd_vips_save_layer_compression(psd->compression); + channels = g_new0(MinipsdVipsSaveChannels, 2 * (gsize)n); + layers = g_new0(minipsd_layer, (gsize)n); + masks = g_new0(minipsd_mask, (gsize)n); + mask_images = g_new0(VipsImage *, (gsize)n); + stored = g_new0(minipsd_bytes, (gsize)n * PSD_MAX_PLANES); + names = g_new0(char *, (gsize)n); + for (i = 0; i < n && !failed; ++i) { + minipsd_layer *l = &layers[i]; + VipsImage *mask = mask_images[i] = minipsd_vips_save_mask(in[i]); + section = minipsd_vips_save_section(in[i]); + if (vips_image_get_typeof(in[i], "psd-layer-name") && !vips_image_get_string(in[i], "psd-layer-name", &text)) + l->name = text; + else + l->name = section == MINIPSD_SECTION_GROUP_END ? PSD_DIVIDER_NAME : (names[i] = g_strdup_printf("Layer %d", i + 1)); + l->section_divider_type = (uint8_t)section; + l->opacity = (uint8_t)VIPS_CLIP(0, minipsd_vips_save_field(in[i], "psd-layer-opacity", UCHAR_MAX), UCHAR_MAX); + l->hidden = (uint8_t)(minipsd_vips_save_field(in[i], "psd-layer-hidden", 0) != 0); + l->clipping = (uint8_t)(minipsd_vips_save_field(in[i], "psd-layer-clipping", 0) != 0); + if (vips_image_get_typeof(in[i], "psd-layer-blend-mode") && !vips_image_get_string(in[i], "psd-layer-blend-mode", &text)) { + if (strlen(text) != PSD_BLEND_KEY_SIZE) { + error = "a psd-layer-blend-mode is not a four-character key, such as \"mul \""; + failed = 1; + break; + } + /* A group passing through: none given, which minipsd writes as Photoshop does. */ + if (section == MINIPSD_SECTION_LAYER || section == MINIPSD_SECTION_GROUP_END || memcmp(text, "pass", PSD_BLEND_KEY_SIZE) != 0) + memcpy(l->blend_mode, text, PSD_BLEND_KEY_SIZE); + } + if (mask) { /* one band of the layers' depth, its own rectangle; encoded as the layer is */ + if (mask->Bands != 1 || minipsd_vips_save_depth(mask) != depth) { + error = "a psd-layer-mask is not one band of the layers' depth"; + failed = 1; + break; + } + masks[i].default_color = minipsd_vips_save_field(in[i], "psd-layer-mask-default-color", UCHAR_MAX) ? UCHAR_MAX : 0; + masks[i].flags = minipsd_vips_save_field(in[i], "psd-layer-mask-disabled", 0) ? PSD_MASK_DISABLED : 0; + l->mask = &masks[i]; + if (!minipsd_vips_save_empty(mask)) { + masks[i].top = mask->Yoffset - psd->top; + masks[i].left = mask->Xoffset - psd->left; + masks[i].bottom = masks[i].top + mask->Ysize; + masks[i].right = masks[i].left + mask->Xsize; + if (minipsd_vips_save_encode_image(&channels[n + i], mask, depth, MINIPSD_MODE_GREY, compression, 0, 0, &memory)) { + failed = 1; + break; + } + longest = VIPS_MAX(longest, channels[n + i].longest); + } + } + if (minipsd_vips_save_empty(in[i])) continue; /* no pixels, no rectangle: as Photoshop writes one */ + l->top = in[i]->Yoffset - psd->top; + l->left = in[i]->Xoffset - psd->left; + l->bottom = l->top + in[i]->Ysize; + l->right = l->left + in[i]->Xsize; + l->no_alpha = (uint8_t)(in[i]->Bands == (int)colors); + l->stored = &stored[(size_t)i * PSD_MAX_PLANES]; + if (minipsd_vips_save_encode_image(&channels[i], in[i], depth, mode, compression, 0, 0, &memory)) failed = 1; + longest = VIPS_MAX(longest, channels[i].longest); + } + if (!failed) { + composite = psd->composite ? psd->composite : minipsd_vips_save_compose(psd, in, n, ref, colors, mode, depth, width, height, alpha); + if (!composite) failed = 1; + } + if (!failed) { + if (minipsd_vips_save_encode_image(&composite_channels, composite, depth, mode, + psd->compression == MINIPSD_VIPS_COMPRESSION_NONE ? MINIPSD_RAW : MINIPSD_RLE, 1, + composite->Bands > (int)colors, &memory)) + failed = 1; + longest = VIPS_MAX(longest, composite_channels.longest); + } + + if (!failed) { + memset(&doc, 0, sizeof doc); + doc.color_mode = (uint16_t)mode; + doc.depth = (uint16_t)depth; + doc.width = (uint32_t)width; + doc.height = (uint32_t)height; + doc.dpi = ref->Xres * MM_PER_INCH; + if (!(doc.dpi > 0.0 && doc.dpi < PSD_MAX_DPI)) doc.dpi = PSD_DEFAULT_DPI; + doc.layers = layers; + doc.layer_count = (size_t)n; + doc.composite_channels = (uint16_t)composite->Bands; + doc.composite_alpha = (uint8_t)(composite->Bands > (int)colors); + doc.compression = MINIPSD_RLE; + if (vips_image_get_typeof(ref, VIPS_META_ICC_NAME) && !vips_image_get_blob(ref, VIPS_META_ICC_NAME, &icc, &icc_size)) { + doc.icc = (const uint8_t *)icc; + doc.icc_size = icc_size; + } + minipsd_vips_save_keep_blob(ref, VIPS_META_EXIF_NAME, MINIPSD_RES_EXIF_DATA_1, resources, &count); + minipsd_vips_save_keep_blob(ref, VIPS_META_XMP_NAME, MINIPSD_RES_XMP_METADATA, resources, &count); + minipsd_vips_save_keep_blob(ref, VIPS_META_IPTC_NAME, MINIPSD_RES_IPTC_NAA, resources, &count); + doc.resources = resources; + doc.resource_count = count; + + /* PSB as psdsave chooses it. */ + filename = vips_connection_filename(VIPS_CONNECTION(psd->target)); + psb = psd->psb || (filename && vips_iscasepostfix(filename, ".psb")) || width > MINIPSD_MAX_SIDE + || height > MINIPSD_MAX_SIDE || longest > UINT16_MAX; + for (;;) { + table = psb ? PSD_COUNT_PSB : PSD_COUNT_PSD; + doc.format = psb ? MINIPSD_PSB : MINIPSD_PSD; + for (i = 0; i < n; ++i) { + if (layers[i].stored) minipsd_vips_save_layer_blocks(&channels[i], table, stored + (size_t)i * PSD_MAX_PLANES); + if (channels[n + i].planes) minipsd_vips_save_layer_blocks(&channels[n + i], table, &masks[i].stored); + } + doc.composite_stored = minipsd_vips_save_composite_block(&composite_channels, table); + if (psb || minipsd_vips_save_fits_psd(&doc)) break; + psb = 1; + } + rc = minipsd_write(&doc, NULL, minipsd_vips_save_to_target, psd->target); + } + + for (i = 0; channels && i < 2 * n; ++i) minipsd_vips_save_channels_free(&channels[i]); + for (i = 0; mask_images && i < n; ++i) VIPS_UNREF(mask_images[i]); + g_free(mask_images); + g_free(masks); + minipsd_vips_save_channels_free(&composite_channels); + for (i = 0; names && i < n; ++i) g_free(names[i]); + g_free(names); + g_free(stored); + g_free(layers); + g_free(channels); + if (composite && composite != psd->composite) g_object_unref(composite); + if (error) vips_error(nickname, "%s", error); + else if (memory) vips_error(nickname, "%s", minipsd_error_string(MINIPSD_ERR_ALLOC)); + else if (rc != MINIPSD_OK) vips_error(nickname, "%s", minipsd_error_string(rc)); + if (failed || rc != MINIPSD_OK) return -1; /* an image's own error is reported where it arose */ + return vips_target_end(psd->target); +} + +static void minipsd_vips_save_layers_class_init(MinipsdVipsSaveLayersClass *class) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS(class); + VipsObjectClass *object_class = (VipsObjectClass *)class; + VipsOperationClass *operation_class = (VipsOperationClass *)class; + + gobject_class->dispose = minipsd_vips_save_layers_dispose; + minipsd_vips__class_properties(class); + + object_class->nickname = "psdsave_layers_base"; + object_class->description = "save images as the layers of a PSD or PSB with minipsd"; + object_class->build = minipsd_vips_save_layers_build; + operation_class->flags |= VIPS_OPERATION_NOCACHE; /* it writes: never answered from the cache */ + + PSD_ARGS_BEGIN + VIPS_ARG_BOXED(class, "in", 0, "Input", "The layers, bottom first", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsSaveLayers, in), VIPS_TYPE_ARRAY_IMAGE); + VIPS_ARG_IMAGE(class, "composite", 20, "Composite", "The composite, as given; else composed by vips_composite", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSaveLayers, composite)); + VIPS_ARG_ENUM(class, "compression", 21, "Compression", "The layers' compression: rle, zip, zip-predicted, or none (the composite too)", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSaveLayers, compression), + minipsd_vips_compression_get_type(), MINIPSD_VIPS_COMPRESSION_RLE); + VIPS_ARG_BOOL(class, "psb", 22, "PSB", "Save as PSB (also for a .psb file, or when a PSD cannot hold it)", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSaveLayers, psb), FALSE); + VIPS_ARG_INT(class, "width", 23, "Width", "The canvas's width; 0: the composite's, or every layer's", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSaveLayers, width), 0, MINIPSD_MAX_SIDE_PSB, 0); + VIPS_ARG_INT(class, "height", 24, "Height", "The canvas's height; 0: the composite's, or every layer's", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSaveLayers, height), 0, MINIPSD_MAX_SIDE_PSB, 0); + VIPS_ARG_INT(class, "left", 25, "Left", "The canvas's left edge in the layers' coordinates", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSaveLayers, left), -MINIPSD_MAX_SIDE_PSB, + MINIPSD_MAX_SIDE_PSB, 0); + VIPS_ARG_INT(class, "top", 26, "Top", "The canvas's top edge in the layers' coordinates", + VIPS_ARGUMENT_OPTIONAL_INPUT, G_STRUCT_OFFSET(MinipsdVipsSaveLayers, top), -MINIPSD_MAX_SIDE_PSB, + MINIPSD_MAX_SIDE_PSB, 0); + PSD_ARGS_END +} + +static void minipsd_vips_save_layers_init(MinipsdVipsSaveLayers *psd) +{ + (void)psd; +} + +/* --- psdsave_layers_target, psdsave_layers (a file), psdsave_layers_buffer ------------------ */ + +typedef struct _MinipsdVipsSaveLayersTarget { + MinipsdVipsSaveLayers parent_object; + VipsTarget *target; +} MinipsdVipsSaveLayersTarget; + +typedef MinipsdVipsSaveLayersClass MinipsdVipsSaveLayersTargetClass; + +G_DEFINE_TYPE(MinipsdVipsSaveLayersTarget, minipsd_vips_save_layers_target, minipsd_vips_save_layers_get_type()) + +static int minipsd_vips_save_layers_target_build(VipsObject *object) +{ + MinipsdVipsSaveLayers *psd = (MinipsdVipsSaveLayers *)object; + MinipsdVipsSaveLayersTarget *target = (MinipsdVipsSaveLayersTarget *)object; + if (target->target) { + psd->target = target->target; + g_object_ref(psd->target); + } + return VIPS_OBJECT_CLASS(minipsd_vips_save_layers_target_parent_class)->build(object); +} + +static void minipsd_vips_save_layers_target_class_init(MinipsdVipsSaveLayersTargetClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdsave_layers_target"; + object_class->description = "save images as the layers of a PSD or PSB to a target with minipsd"; + object_class->build = minipsd_vips_save_layers_target_build; + + PSD_ARGS_BEGIN + VIPS_ARG_OBJECT(class, "target", 1, "Target", "Target to save to", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsSaveLayersTarget, target), VIPS_TYPE_TARGET); + PSD_ARGS_END +} + +static void minipsd_vips_save_layers_target_init(MinipsdVipsSaveLayersTarget *target) +{ + (void)target; +} + +typedef struct _MinipsdVipsSaveLayersFile { + MinipsdVipsSaveLayers parent_object; + char *filename; +} MinipsdVipsSaveLayersFile; + +typedef MinipsdVipsSaveLayersClass MinipsdVipsSaveLayersFileClass; + +G_DEFINE_TYPE(MinipsdVipsSaveLayersFile, minipsd_vips_save_layers_file, minipsd_vips_save_layers_get_type()) + +static int minipsd_vips_save_layers_file_build(VipsObject *object) +{ + MinipsdVipsSaveLayers *psd = (MinipsdVipsSaveLayers *)object; + MinipsdVipsSaveLayersFile *file = (MinipsdVipsSaveLayersFile *)object; + if (file->filename && !(psd->target = vips_target_new_to_file(file->filename))) return -1; + return VIPS_OBJECT_CLASS(minipsd_vips_save_layers_file_parent_class)->build(object); +} + +static void minipsd_vips_save_layers_file_class_init(MinipsdVipsSaveLayersFileClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdsave_layers"; + object_class->description = "save images as the layers of a PSD or PSB to a file with minipsd"; + object_class->build = minipsd_vips_save_layers_file_build; + + PSD_ARGS_BEGIN + VIPS_ARG_STRING(class, "filename", 1, "Filename", "Filename to save to", VIPS_ARGUMENT_REQUIRED_INPUT, + G_STRUCT_OFFSET(MinipsdVipsSaveLayersFile, filename), NULL); + PSD_ARGS_END +} + +static void minipsd_vips_save_layers_file_init(MinipsdVipsSaveLayersFile *file) +{ + (void)file; +} + +typedef struct _MinipsdVipsSaveLayersBuffer { + MinipsdVipsSaveLayers parent_object; + VipsArea *buf; +} MinipsdVipsSaveLayersBuffer; + +typedef MinipsdVipsSaveLayersClass MinipsdVipsSaveLayersBufferClass; + +G_DEFINE_TYPE(MinipsdVipsSaveLayersBuffer, minipsd_vips_save_layers_buffer, minipsd_vips_save_layers_get_type()) + +static int minipsd_vips_save_layers_buffer_build(VipsObject *object) +{ + MinipsdVipsSaveLayers *psd = (MinipsdVipsSaveLayers *)object; + VipsBlob *blob; + if (!(psd->target = vips_target_new_to_memory())) return -1; + if (VIPS_OBJECT_CLASS(minipsd_vips_save_layers_buffer_parent_class)->build(object)) return -1; + g_object_get(psd->target, "blob", &blob, NULL); + g_object_set(object, "buffer", blob, NULL); + vips_area_unref(VIPS_AREA(blob)); + return 0; +} + +static void minipsd_vips_save_layers_buffer_class_init(MinipsdVipsSaveLayersBufferClass *class) +{ + VipsObjectClass *object_class = (VipsObjectClass *)class; + + minipsd_vips__class_properties(class); + object_class->nickname = "psdsave_layers_buffer"; + object_class->description = "save images as the layers of a PSD or PSB to a buffer with minipsd"; + object_class->build = minipsd_vips_save_layers_buffer_build; + + PSD_ARGS_BEGIN + VIPS_ARG_BOXED(class, "buffer", 1, "Buffer", "Buffer to save to", VIPS_ARGUMENT_REQUIRED_OUTPUT, + G_STRUCT_OFFSET(MinipsdVipsSaveLayersBuffer, buf), VIPS_TYPE_BLOB); + PSD_ARGS_END +} + +static void minipsd_vips_save_layers_buffer_init(MinipsdVipsSaveLayersBuffer *buffer) +{ + (void)buffer; +} + +/* --- The C calls, as libvips has one beside each operation ---------------------------------- */ + +int minipsd_vips_psdsave_layers(VipsImage **in, int n, const char *filename, ...) +{ + VipsArrayImage *array = vips_array_image_new(in, n); + va_list ap; + int result; + va_start(ap, filename); + result = vips_call_split("psdsave_layers", ap, array, filename); + va_end(ap); + vips_area_unref(VIPS_AREA(array)); + return result; +} + +int minipsd_vips_psdsave_layers_target(VipsImage **in, int n, VipsTarget *target, ...) +{ + VipsArrayImage *array = vips_array_image_new(in, n); + va_list ap; + int result; + va_start(ap, target); + result = vips_call_split("psdsave_layers_target", ap, array, target); + va_end(ap); + vips_area_unref(VIPS_AREA(array)); + return result; +} + +int minipsd_vips_psdsave_layers_buffer(VipsImage **in, int n, void **buf, size_t *len, ...) +{ + VipsArrayImage *array = vips_array_image_new(in, n); + VipsArea *area = NULL; + va_list ap; + int result; + va_start(ap, len); + result = vips_call_split("psdsave_layers_buffer", ap, array, &area); + va_end(ap); + vips_area_unref(VIPS_AREA(array)); + if (!result && area) { /* the bytes handed over, as libvips' own _buffer calls do */ + if (buf) { + *buf = area->data; + area->free_fn = NULL; + } + if (len) *len = area->length; + vips_area_unref(area); + } + return result; +} diff --git a/libvips-plugin/minipsd_vips.c b/libvips-plugin/minipsd_vips.c new file mode 100644 index 0000000..3b3e4c1 --- /dev/null +++ b/libvips-plugin/minipsd_vips.c @@ -0,0 +1,29 @@ +/** + * \file + * \brief minipsd_vips_init: registers psdload and psdsave with libvips. + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#include "minipsd_vips.h" + +#include "foreign/psd.h" + +/* --- Registration -------------------------------------------------------------------------- */ + +int minipsd_vips_init(void) +{ + /* Registering a type is enough: libvips finds loaders and savers among VipsForeignLoad's and + VipsForeignSave's subclasses. */ + (void)minipsd_vips_load_file_get_type(); + (void)minipsd_vips_load_buffer_get_type(); + (void)minipsd_vips_load_source_get_type(); + (void)minipsd_vips_save_file_get_type(); + (void)minipsd_vips_save_buffer_get_type(); + (void)minipsd_vips_save_target_get_type(); + (void)minipsd_vips_save_layers_file_get_type(); + (void)minipsd_vips_save_layers_buffer_get_type(); + (void)minipsd_vips_save_layers_target_get_type(); + return 0; +} diff --git a/libvips-plugin/minipsd_vips.h b/libvips-plugin/minipsd_vips.h new file mode 100644 index 0000000..9990c13 --- /dev/null +++ b/libvips-plugin/minipsd_vips.h @@ -0,0 +1,87 @@ +/** + * \file + * \brief minipsd as a libvips loader and saver of PSD and PSB: psdload, psdload_buffer, + * psdload_source; psdsave, psdsave_buffer, psdsave_target. + * + * libvips' prebuilt Windows builds load no modules, so a program registers them itself: call + * minipsd_vips_init() once, after VIPS_INIT(). vips_image_new_from_file("x.psd"), + * vips_image_write_to_file(image, "x.psd") and the other ways libvips finds a loader or a saver + * then find these. A saver writes one image; a layered document is psdsave_layers, an + * operation over an array of images, one a layer (minipsd_vips_psdsave_layers below). + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#ifndef MINIPSD_VIPS_H +#define MINIPSD_VIPS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * \brief psdsave's `compression`: of the layer's channels; the composite is RLE, or raw with none. + * + * From the command line or an option string, by its nick: `compression=zip`. + */ +typedef enum { + MINIPSD_VIPS_COMPRESSION_RLE = 0, /*!< PackBits, as Photoshop writes 8-bit layers: the default. */ + MINIPSD_VIPS_COMPRESSION_ZIP = 1, /*!< zlib, without prediction. */ + MINIPSD_VIPS_COMPRESSION_NONE = 2, /*!< Raw: the largest file. */ + MINIPSD_VIPS_COMPRESSION_ZIP_PREDICTED = 3 /*!< zlib of each sample's difference from its left + neighbour, as Photoshop writes 16-bit layers: + the smaller file at 16 and 32 bits. */ +} MinipsdVipsCompression; + +/** + * \brief Registers minipsd's operations with libvips. Safe to call more than once. + * + * \return 0. + */ +int minipsd_vips_init(void); + +/** + * \brief Saves images as the layers of one PSD or PSB (the operation psdsave_layers). + * + * in holds the layers, bottom first. Each image's psd-layer-* fields, the ones psdload sets, + * give its layer's name ("psd-layer-name", else "Layer N"), opacity ("psd-layer-opacity", 0 to + * 255), visibility ("psd-layer-hidden"), blend mode ("psd-layer-blend-mode", a four-character + * key such as "mul ") and clipping ("psd-layer-clipping"); its position is its xoffset and + * yoffset. "psd-layer-empty" set makes it an empty layer, no pixels, as Photoshop writes an + * unpainted one (the image's pixels are ignored). Every layer with pixels is of one mode and + * depth (grey, RGB or CMYK, with or without alpha; uchar, ushort or float); none is converted. + * + * A mask is an image in "psd-layer-mask": one band of the layers' depth, its own rectangle in its + * xoffset and yoffset, with "psd-layer-mask-default-color" (0 or 255, its value outside; 255 + * without it) and "psd-layer-mask-disabled". A group is records in the list, as the file has + * them: below its layers an image with "psd-layer-section" "end", above them one with "open" or + * "closed" and the group's name, visibility, opacity and blend mode ("pass", or none, passes + * through). Records have no pixels. psdload gives masks and records the same way. + * + * Options, as NULL-ended name and value pairs: "composite" (a VipsImage, the canvas's size: the + * composite as given; else vips_composite of the visible layers, blend modes mapped as the spec's + * table, masks and groups' visibility and opacity in the layers' alpha, flattened against white + * when no layer has alpha; clipping, and a group's own mode and mask, are not drawn), "compression" + * (MinipsdVipsCompression, the layers'), "psb", "width" and "height" (the canvas; 0: the + * composite's size, else every layer's rectangle from (left, top)), "left" and "top" (where the + * canvas lies in the layers' coordinates). Each image is read once for its layer, and once more + * by vips_composite unless the composite is given. + * + * \return 0, or -1 with libvips' error set. + */ +int minipsd_vips_psdsave_layers(VipsImage **in, int n, const char *filename, ...) G_GNUC_NULL_TERMINATED; + +/** \brief minipsd_vips_psdsave_layers into a target. */ +int minipsd_vips_psdsave_layers_target(VipsImage **in, int n, VipsTarget *target, ...) G_GNUC_NULL_TERMINATED; + +/** \brief minipsd_vips_psdsave_layers into a new buffer, *buf (g_free it) of *len bytes. */ +int minipsd_vips_psdsave_layers_buffer(VipsImage **in, int n, void **buf, size_t *len, ...) G_GNUC_NULL_TERMINATED; + +#ifdef __cplusplus +} +#endif + +#endif /* MINIPSD_VIPS_H */ diff --git a/libvips-plugin/release/CMakeLists.txt b/libvips-plugin/release/CMakeLists.txt new file mode 100644 index 0000000..2c471ba --- /dev/null +++ b/libvips-plugin/release/CMakeLists.txt @@ -0,0 +1,41 @@ +# --------------------------------------------------------------------------- +# minipsd's libvips plugin, as released: psdload and psdsave for a program that links libvips. +# +# add_subdirectory(minipsd-vips-) +# target_link_libraries(my-app PRIVATE minipsd::vips) +# +# then minipsd_vips_init() after VIPS_INIT() (README.md). libvips: fetched on Windows (libvips' +# official prebuilt build, pinned), found with pkg-config elsewhere; or the program's own, as the +# targets minipsd_libvips and minipsd_libvips_zlib, defined before this (cmake/minipsd_libvips.cmake). +# minipsd is compiled into the plugin from foreign/minipsd/minipsd.h, private to it: a program's +# own minipsd, of any version, is left alone. foreign/ is laid out as libvips/foreign/. +# +# minipsd::vips the plugin, a static library; its MINIPSD_SBOM_FILE, minipsd-vips.spdx.json +# minipsd-vips-example example.c: an image saved and read back through the plugin (its test) +# --------------------------------------------------------------------------- +cmake_minimum_required(VERSION 3.21) # PROJECT_IS_TOP_LEVEL +project(minipsd_vips LANGUAGES C) + +option(MINIPSD_VIPS_EXAMPLE "Build example.c, and its test" ${PROJECT_IS_TOP_LEVEL}) + +include(cmake/minipsd_libvips.cmake) + +file(GLOB _minipsd_vips_foreign CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/foreign/*.c" "${CMAKE_CURRENT_SOURCE_DIR}/foreign/*.h") +add_library(minipsd_vips STATIC minipsd_vips.c minipsd_vips.h ${_minipsd_vips_foreign}) +add_library(minipsd::vips ALIAS minipsd_vips) +target_include_directories(minipsd_vips PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/foreign/minipsd") +target_link_libraries(minipsd_vips PUBLIC minipsd_libvips PRIVATE minipsd_libvips_zlib) +set_target_properties(minipsd_vips PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) +# The SBOM fragment of this zip, found as minipsd's own: get_target_property(... MINIPSD_SBOM_FILE). +set_target_properties(minipsd_vips PROPERTIES MINIPSD_SBOM_FILE "${CMAKE_CURRENT_SOURCE_DIR}/minipsd-vips.spdx.json") + +if(MINIPSD_VIPS_EXAMPLE) + add_executable(minipsd-vips-example example.c) + target_link_libraries(minipsd-vips-example PRIVATE minipsd::vips) + set_target_properties(minipsd-vips-example PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED ON C_EXTENSIONS OFF) + enable_testing() + add_test(NAME minipsd-vips-example COMMAND minipsd-vips-example "${CMAKE_CURRENT_BINARY_DIR}/example.psd") + if(MINIPSD_LIBVIPS_ENV) # Windows: libvips' DLLs on PATH + set_tests_properties(minipsd-vips-example PROPERTIES ENVIRONMENT "${MINIPSD_LIBVIPS_ENV}") + endif() +endif() diff --git a/libvips-plugin/release/README.md b/libvips-plugin/release/README.md new file mode 100644 index 0000000..332510c --- /dev/null +++ b/libvips-plugin/release/README.md @@ -0,0 +1,107 @@ +# minipsd's libvips plugin + +minipsd as libvips' loader and saver of PSD and PSB: `vips_image_new_from_file` and +`vips_image_write_to_file` take `.psd` and `.psb` in your program. It reads a file's composite or +any of its layers, with their look, masks and groups, and writes one image, or several as the +layers of one document (`psdsave_layers`). MIT, as minipsd +(`LICENSE.MIT`). Project: ; the full description is its +manual's "PSD in libvips: the plugin". + +## What is here + +| | | +|---|---| +| `minipsd_vips.h`, `minipsd_vips.c` | the plugin's API (what a program includes) and its registration | +| `foreign/` | the loader and the saver, laid out as `libvips/foreign/` | +| `foreign/minipsd/minipsd.h` | minipsd, one file, compiled into the plugin and private to it | +| `cmake/minipsd_libvips.cmake` | how libvips is found: Windows fetches libvips' official prebuilt build (pinned), elsewhere pkg-config | +| `CMakeLists.txt` | builds `minipsd::vips`, a static library | +| `example.c` | an image saved and read back through the plugin: its test | +| `minipsd-vips.spdx.json` | the SBOM fragment (SPDX 2.3): every other file here, with its checksums; `get_target_property(sbom minipsd::vips MINIPSD_SBOM_FILE)` | + +## Build + +In your CMake project: + +```cmake +add_subdirectory(minipsd-vips-) +target_link_libraries(my-app PRIVATE minipsd::vips) +``` + +Or, with FetchContent, straight from the release: + +```cmake +include(FetchContent) +FetchContent_Declare(minipsd_vips + URL https://github.com/ShadobaDev/minipsd/releases/download//minipsd-vips-.zip + URL_HASH SHA256=.zip.sha256>) +FetchContent_MakeAvailable(minipsd_vips) +``` + +**One libvips.** Linux and macOS take the system's libvips and zlib through pkg-config. Windows +fetches libvips' prebuilt build, and zlib's headers, which that build lacks. A program that +fetches libvips' Windows build itself points the plugin at it first, so that one libvips is +fetched and the plugin is compiled against the headers of the DLLs the program ships: + +```cmake +FetchContent_GetProperties(my_vips SOURCE_DIR my_vips_dir) # vips-dev-x64-*.zip, unpacked +set(FETCHCONTENT_SOURCE_DIR_MINIPSD_LIBVIPS "${my_vips_dir}") # the plugin takes this libvips +FetchContent_MakeAvailable(minipsd_vips) +``` + +zlib's headers are still fetched, so `zlib.h` is always there. A program whose libvips comes from +anywhere else defines two targets first, and nothing is fetched or found: `minipsd_libvips` +(libvips, glib, gobject) and `minipsd_libvips_zlib` (zlib's headers and the zlib libvips links), +each an `INTERFACE` library or `IMPORTED GLOBAL`. + +On its own, with its example as the test: + +```bash +cmake -S minipsd-vips- -B build +cmake --build build +ctest --test-dir build # Windows: add -C Debug (or the configuration built) +``` + +Without CMake, compile `minipsd_vips.c` and `foreign/*.c` with the zip's root and +`foreign/minipsd/` on the include path and libvips' and zlib's flags +(`pkg-config --cflags vips zlib`), and link the objects into your program. + +## Use + +Register the plugin once, after `VIPS_INIT` (libvips' prebuilt Windows builds load no modules): + +```c +#include "minipsd_vips.h" + +if (VIPS_INIT(argv[0])) vips_error_exit(NULL); +minipsd_vips_init(); +VipsImage *page = vips_image_new_from_file("page.psd", NULL); /* the composite */ +VipsImage *third = vips_image_new_from_file("page.psd", "layer", 2, NULL); /* the third layer up */ +vips_image_write_to_file(page, "copy.psd[compression=zip]", NULL); +``` + +- **Loading** (`psdload`, `psdload_buffer`, `psdload_source`): the composite, or with `layer` one + layer, bottom first, placed by `xoffset` and `yoffset`, its look in `psd-layer-*` fields + (name, opacity, hidden, blend mode, clipping), its mask an image in `psd-layer-mask`, a + group's records marked by `psd-layer-section`, a record without pixels one transparent pixel + with `psd-layer-empty`. +- **Saving** (`psdsave`, `psdsave_buffer`, `psdsave_target`): an image with alpha as one layer and + the composite; without, flat (the composite only), or with `layered` one layer as well. Grey, + RGB or CMYK at 8 or 16 bits, grey and RGB as floats. Options: `compression` (`rle`, the + default; `zip`; `zip-predicted`; `none`), `psb`, `layer_name`, `layered`. The image is read + once, in bands: memory about the file's size. A PSB when asked, for a `.psb` file, or when a + PSD cannot hold the image. +- **Saving layers** (`psdsave_layers`, `_buffer`, `_target`; in C `minipsd_vips_psdsave_layers`, + declared in `minipsd_vips.h`): an array of images, one a layer, bottom first, each read from + the same fields psdload sets, so a file's layers loaded and saved come back. The composite is + given (`composite`), or composed by `vips_composite`. The canvas: `width`, `height`, `left`, + `top`. Text, effects, smart objects and adjustments' settings are not carried. + + ```c + VipsImage *layers[2] = { page, blank }; /* blank: any image with psd-layer-empty set */ + vips_image_set_int(blank, "psd-layer-empty", 1); + minipsd_vips_psdsave_layers(layers, 2, "template.psd", "composite", page, NULL); + ``` + +minipsd is compiled into the plugin privately, so a program that also uses minipsd itself, at +any version, keeps its own. diff --git a/libvips-plugin/release/example.c b/libvips-plugin/release/example.c new file mode 100644 index 0000000..a39e611 --- /dev/null +++ b/libvips-plugin/release/example.c @@ -0,0 +1,46 @@ +/** + * \file + * \brief minipsd's libvips plugin in a program: an RGB image with alpha saved as a PSD by psdsave, + * then read back by psdload, its size and bands as saved. + * + * minipsd-vips-example out.psd + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#include + +#include "minipsd_vips.h" + +#include +#include + +#define SIDE 64 /* pixels a side */ +#define BANDS 4 /* RGB and alpha: one layer and the composite */ + +int main(int argc, char **argv) +{ + VipsImage *image = NULL, *back = NULL; + const char *loader; + int ok; + if (argc != 2) { + fprintf(stderr, "usage: %s out.psd\n", argv[0]); + return 2; + } + if (VIPS_INIT(argv[0])) vips_error_exit(NULL); + minipsd_vips_init(); /* psdload and psdsave, after VIPS_INIT */ + + if (vips_black(&image, SIDE, SIDE, "bands", BANDS, NULL) || vips_image_write_to_file(image, argv[1], NULL) + || !(back = vips_image_new_from_file(argv[1], NULL))) + vips_error_exit(NULL); + loader = vips_foreign_find_load(argv[1]); + ok = loader && strcmp(loader, "MinipsdVipsLoadFile") == 0 && back->Xsize == SIDE && back->Ysize == SIDE + && back->Bands == BANDS; + printf("%s: %dx%d, %d bands, read by %s: %s\n", argv[1], back->Xsize, back->Ysize, back->Bands, loader ? loader : "nothing", + ok ? "ok" : "FAILED"); + g_object_unref(back); + g_object_unref(image); + vips_shutdown(); + return ok ? 0 : 1; +} diff --git a/libvips-plugin/test_vips.c b/libvips-plugin/test_vips.c new file mode 100644 index 0000000..24c592c --- /dev/null +++ b/libvips-plugin/test_vips.c @@ -0,0 +1,1296 @@ +/** + * \file + * \brief The libvips plugin's test: documents minipsd writes, loaded through libvips. + * + * Usage: minipsd-vips-test out-dir [list]. Each document is written into out-dir, then loaded as a + * file (randomly and sequentially), from a buffer and from a source; the image libvips gives must + * say what the document does, and its pixels be what minipsd decodes. Its layers, loaded with + * `layer`, must be the pixels the document gave, at their place. list names other files, one path + * a line, whose composite and every layer are loaded and compared with minipsd's decode the same + * way (or refused by both). libvips runs on several threads, so bands are decoded at once. Exits + * with 1 when anything fails. + * + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 Bartlomiej Mucha + */ + +#define MINIPSD_IMPLEMENTATION +#include "minipsd.h" + +#include "minipsd_vips.h" + +#include +#define ZLIB_CONST /* next_in const */ +#include + +#include +#include +#include +#include +#include + +#define WIDTH 301 /* odd sizes, and rows enough for several bands */ +#define HEIGHT 203 +#define PIXELS (WIDTH * HEIGHT) +#define RGBA 4 +#define MAX_PLANES 5 /* CMYK and transparency */ +#define CMYK_COLORS 4 /* CMYK's colour channels */ +#define DPI 300.0 +#define ASPECT 0.9 +#define MM_PER_INCH 25.4 +#define EPSILON 1e-9 +#define FLOAT_EPSILON 1e-6 /* a 32-bit composite's un-matting, 1 - (1 - c) / a, is off by rounding */ +#define PATH_MAX_SIZE 1024 +#define NAME_SIZE (PATH_MAX_SIZE + 64) /* a path and what it names in it */ +#define WORKSPACE_SIZE 256 +#define THREADS 4 +#define PALETTE 256 /* entries; the palette is 256 reds, 256 greens, 256 blues */ +#define TRANSPARENT 5 /* the indexed document's transparent index */ +#define HALF_OPAQUE 128 +#define ZIP_LEVEL 6 +#define COMPOSITE (-1) /* decode_image's layer: the composite */ +#define TALL_W 1024 /* the tall image: PSD's highest, 60 MB of grey and transparency; */ +#define TALL_H MINIPSD_MAX_SIDE /* libvips caches rows, so its memory follows the width */ +#define BOUND 8 /* read in bands, libvips holds less than an eighth of it */ +#define STORED_RAW 0 /* a channel's stored compression markers */ +#define STORED_RLE 1 +#define STORED_ZIP 2 +#define STORED_ZIP_PREDICTED 3 +#define WIDE_W 16400 /* 32-bit noise rows longer than a PSD's two-byte counts hold */ +#define WIDE_H 2 +#define COMPOSED_LEVELS 2 /* vips_composite's float rounding, doubled where the matte is undone */ +#define PSD_RGB_BANDS 3 +#define MASK_DISABLED 0x02 /* a mask's flag */ +#define RECORDS 5 /* the round trip's: a layer, a group (divider, two layers, top record) */ +#define PSD_VERSION_AT 5 /* the header's version, 1 PSD or 2 PSB: its low byte */ + +/* The ZIP document's two layers: one reaching past the canvas's left edge, one inside. */ +#define ZIP_W 200 +#define ZIP_H 100 +#define ZIP_PIXELS (ZIP_W * ZIP_H) + +static int failures; + +static void check(int ok, const char *what, const char *name) +{ + if (!ok) { + printf("FAIL %s: %s\n", name, what); + ++failures; + } +} + +/* A growing buffer the writer appends to. */ +typedef struct out_buffer { + uint8_t *data; + size_t size, cap; +} out_buffer; + +static int to_buffer(void *user, const void *data, size_t size) +{ + out_buffer *b = (out_buffer *)user; + if (b->size + size > b->cap) { + size_t cap = (b->size + size) * 2; + uint8_t *grow = (uint8_t *)realloc(b->data, cap); + if (!grow) return 0; + b->data = grow; + b->cap = cap; + } + memcpy(b->data + b->size, data, size); + b->size += size; + return 1; +} + +static int deflate_zlib(void *user, const uint8_t *src, size_t src_size, uint8_t *dst, size_t *dst_size) +{ + uLongf n = (uLongf)*dst_size; + int rc; + (void)user; + rc = compress2(dst, &n, src, (uLong)src_size, ZIP_LEVEL); + *dst_size = n; + return rc != Z_OK; +} + +/* zlib fed one row at a time (user: the row's bytes), as psdsave deflates rows as they come: the + stream zlib makes depends on how its input is cut. */ +static int deflate_rows(void *user, const uint8_t *src, size_t src_size, uint8_t *dst, size_t *dst_size) +{ + const size_t row = *(const size_t *)user; + z_stream z; + size_t at; + int rc = Z_OK; + memset(&z, 0, sizeof z); + if (deflateInit(&z, ZIP_LEVEL) != Z_OK) return 1; + z.next_out = dst; + z.avail_out = (uInt)*dst_size; + for (at = 0; rc == Z_OK && at < src_size; at += row) { + z.next_in = src + at; + z.avail_in = (uInt)(src_size - at < row ? src_size - at : row); + rc = deflate(&z, Z_NO_FLUSH); + } + if (rc == Z_OK) rc = deflate(&z, Z_FINISH); + *dst_size = (size_t)z.total_out; + (void)deflateEnd(&z); + return rc != Z_STREAM_END; +} + +static int inflate_zlib(void *user, const uint8_t *src, size_t src_size, uint8_t *dst, size_t dst_size) +{ + uLongf n = (uLongf)dst_size; + (void)user; + return uncompress(dst, &n, src, (uLong)src_size) != Z_OK || n != dst_size; +} + +/* CMYK's ink the other way round, in place: PSD's inverted (full: no ink) to libvips' (0: no ink), + and back. 8- or 16-bit samples, the four colour channels of each pixel. */ +static void invert_cmyk(uint8_t *pixels, size_t count, unsigned bands, size_t sample) +{ + size_t i; + unsigned c; + for (i = 0; i < count; ++i) + for (c = 0; c < CMYK_COLORS; ++c) { + uint8_t *v = pixels + (i * bands + c) * sample; + if (sample == sizeof(uint16_t)) { + uint16_t w; + memcpy(&w, v, sizeof w); + w = (uint16_t)(USHRT_MAX - w); + memcpy(v, &w, sizeof w); + } else { + *v = (uint8_t)(UCHAR_MAX - *v); + } + } +} + +/* The composite (layer -1) or a layer as minipsd decodes it, laid out as the loader gives it: + interleaved, the colour channels and transparency (a layer's always) up to MINIPSD_PLANES, + bitmap's black 0 and white 255, indexed through its palette, CMYK's ink 0 for none; an empty + layer as one pixel of zeros, as the loader gives it. NULL with the error in *rc when minipsd + refuses it. */ +static uint8_t *decode_image(const void *file, size_t size, int layer, size_t *bytes, int *rc) +{ + uint8_t workspace[WORKSPACE_SIZE], *pixels, *scratch = NULL, *p; + minipsd_reader *r; + minipsd_info info; + minipsd_layer_view view; + minipsd_resource res; + minipsd_target target; + minipsd_decode_opts opts; + size_t need, i, row; + uint32_t width, height; + unsigned planes, bands; + uint16_t index; + int transparent = -1, n, empty = 0; + if ((*rc = minipsd_reader_workspace_size(&need)) != MINIPSD_OK) return NULL; + if (need > sizeof workspace) { + *rc = MINIPSD_ERR_ARG; + return NULL; + } + memset(&view, 0, sizeof view); + if ((*rc = minipsd_reader_init(&r, file, size, workspace, sizeof workspace, NULL, &info)) != MINIPSD_OK) return NULL; + width = info.width; + height = info.height; + planes = info.color_channels + (info.composite_has_alpha ? 1u : 0u); + if (layer >= 0) { + for (n = 0; n <= layer; ++n) + if ((*rc = minipsd_reader_next_layer(r, &view)) != MINIPSD_MORE) { + if (*rc == MINIPSD_OK) *rc = MINIPSD_ERR_NOT_FOUND; + return NULL; + } + empty = view.right <= view.left || view.bottom <= view.top; /* no pixels: one, transparent */ + width = empty ? 1 : (uint32_t)(view.right - view.left); + height = empty ? 1 : (uint32_t)(view.bottom - view.top); + planes = info.color_channels; /* then transparency, when the layer has its channel */ + if (planes < MINIPSD_PLANES && view.channel[planes].size) ++planes; + } + if (planes > MINIPSD_PLANES) planes = MINIPSD_PLANES; + bands = planes; + if (info.color_mode == MINIPSD_MODE_INDEXED) { + if (minipsd_reader_find_resource(r, MINIPSD_RES_TRANSPARENCY_INDEX, &res) == MINIPSD_OK + && minipsd_resource_transparency_index(&res, &index) == MINIPSD_OK && index < PALETTE) + transparent = index; + bands = 3 + (transparent >= 0 ? 1u : 0u); + } + row = (size_t)width * bands * (info.depth == 32 ? sizeof(float) : info.depth == 16 ? sizeof(uint16_t) : 1); + *bytes = row * height; + if (!(pixels = (uint8_t *)malloc(*bytes ? *bytes : 1))) { + *rc = MINIPSD_ERR_ALLOC; + return NULL; + } + if (empty) { + memset(pixels, 0, *bytes); + *rc = MINIPSD_OK; + return pixels; + } + minipsd_target_interleaved(&target, pixels, row, planes, info.depth); + if (info.color_mode == MINIPSD_MODE_INDEXED) target.channel[0].pixel_step = (ptrdiff_t)bands; + memset(&opts, 0, sizeof opts); + opts.depth = info.depth; + if (layer < 0) { + *rc = minipsd_decode_composite(r, &target, &opts); + } else { + opts.inflate = inflate_zlib; + opts.scratch_size = (size_t)height * (((size_t)width * info.depth + 7) / 8); + opts.scratch = scratch = (uint8_t *)malloc(opts.scratch_size ? opts.scratch_size : 1); + *rc = scratch ? minipsd_decode_layer(r, &view, &target, &opts) : MINIPSD_ERR_ALLOC; + free(scratch); + } + if (*rc != MINIPSD_OK) { + free(pixels); + return NULL; + } + if (info.color_mode == MINIPSD_MODE_BITMAP) + for (i = 0; i < *bytes; ++i) pixels[i] = pixels[i] ? 0 : UCHAR_MAX; + if (info.color_mode == MINIPSD_MODE_CMYK) + invert_cmyk(pixels, (size_t)width * height, bands, info.depth == 16 ? sizeof(uint16_t) : 1); + if (info.color_mode == MINIPSD_MODE_INDEXED) + for (p = pixels, i = 0; i < (size_t)width * height; ++i, p += bands) { + const unsigned k = p[0]; + p[0] = info.color_mode_data.data[k]; + p[1] = info.color_mode_data.data[PALETTE + k]; + p[2] = info.color_mode_data.data[2 * PALETTE + k]; + if (transparent >= 0) p[3] = (int)k == transparent ? 0 : UCHAR_MAX; + } + return pixels; +} + +/* The loaded image's pixels against minipsd's own decode of the file: the same bytes, or both + refuse it. Takes the image. Returns 1 when both decoded, 0 when both refused. */ +static int check_pixels(VipsImage *image, const void *file, size_t size, int layer, const char *name) +{ + size_t bytes = 0, got_bytes = 0; + int rc, both = 0; + uint8_t *want = decode_image(file, size, layer, &bytes, &rc); + void *got = image ? vips_image_write_to_memory(image, &got_bytes) : NULL; + if (want) { + both = 1; + if (!got) printf("FAIL %s: libvips refused it: %s\n", name, vips_error_buffer()); + check(got && got_bytes == bytes && memcmp(got, want, bytes) == 0, "pixels as minipsd decodes them", name); + } else { + check(!got, "refused as minipsd refuses it", name); + } + vips_error_clear(); + g_free(got); + free(want); + if (image) g_object_unref(image); + return both; +} + +typedef struct expected { + const char *name; + int bands; + VipsBandFormat format; + VipsInterpretation interpretation; + int bits; +} expected; + +/* What libvips says of one loaded image, against the document's; then its pixels. */ +static void check_image(VipsImage *image, const minipsd_doc *doc, const expected *e, const char *how, + const out_buffer *file) +{ + char name[PATH_MAX_SIZE]; + const void *icc; + size_t icc_size; + double ratio = 0.0; + int bits = 0, layers = -1; + snprintf(name, sizeof name, "%s (%s)", e->name, how); + if (!image) { + printf("FAIL %s: not loaded: %s\n", name, vips_error_buffer()); + vips_error_clear(); + ++failures; + return; + } + check(vips_image_get_width(image) == (int)doc->width && vips_image_get_height(image) == (int)doc->height, + "size", name); + check(vips_image_get_bands(image) == e->bands, "bands", name); + check(vips_image_get_format(image) == e->format, "format", name); + check(vips_image_get_interpretation(image) == e->interpretation, "interpretation", name); + check(vips_image_get_int(image, VIPS_META_BITS_PER_SAMPLE, &bits) == 0 && bits == e->bits, "bits per sample", name); + check(vips_image_get_int(image, "psd-layer-count", &layers) == 0 && layers == (int)doc->layer_count, + "layer count", name); + check(fabs(vips_image_get_xres(image) - doc->dpi / MM_PER_INCH) < EPSILON + && fabs(vips_image_get_yres(image) - doc->dpi / MM_PER_INCH) < EPSILON, + "resolution", name); + if (doc->icc) + check(vips_image_get_blob(image, VIPS_META_ICC_NAME, &icc, &icc_size) == 0 && icc_size == doc->icc_size + && memcmp(icc, doc->icc, icc_size) == 0, + "ICC profile", name); + check(vips_image_get_double(image, "psd-pixel-aspect-ratio", &ratio) == 0 + && fabs(ratio - (doc->pixel_aspect_ratio > 0.0 ? doc->pixel_aspect_ratio : 1.0)) < EPSILON, + "pixel aspect ratio", name); + check(check_pixels(image, file->data, file->size, COMPOSITE, name), "decoded", name); +} + +/* Each layer of the document, loaded from path with `layer`: its place, its look, and its pixels + byte for byte as the document gave them (layers are not matted). sample is a sample's bytes. */ +static void check_layers(const char *path, const minipsd_doc *doc, size_t sample, const char *doc_name) +{ + char name[PATH_MAX_SIZE]; + const char *got_name; + VipsImage *image; + void *got; + size_t i, bytes, want_bytes; + int opacity = -1, hidden = -1; + for (i = 0; i < doc->layer_count; ++i) { + const minipsd_layer *l = &doc->layers[i]; + const int full = !l->top && !l->left && !l->bottom && !l->right; + const int w = full ? (int)doc->width : l->right - l->left, h = full ? (int)doc->height : l->bottom - l->top; + snprintf(name, sizeof name, "%s layer %zu", doc_name, i); + if (!(image = vips_image_new_from_file(path, "layer", (int)i, NULL))) { + printf("FAIL %s: not loaded: %s\n", name, vips_error_buffer()); + vips_error_clear(); + ++failures; + continue; + } + check(vips_image_get_width(image) == w && vips_image_get_height(image) == h, "size", name); + check(vips_image_get_xoffset(image) == l->left && vips_image_get_yoffset(image) == l->top, "offset", name); + check(vips_image_get_string(image, "psd-layer-name", &got_name) == 0 && strcmp(got_name, l->name) == 0, "name", name); + check(vips_image_get_int(image, "psd-layer-opacity", &opacity) == 0 && opacity == l->opacity, "opacity", name); + check(vips_image_get_int(image, "psd-layer-hidden", &hidden) == 0 && !hidden == !l->hidden, "hidden", name); + want_bytes = (size_t)w * (size_t)h * sample + * (doc->color_mode == MINIPSD_MODE_CMYK ? MAX_PLANES : doc->color_mode == MINIPSD_MODE_GREY ? 2u : RGBA); + got = vips_image_write_to_memory(image, &bytes); + if (got && doc->color_mode == MINIPSD_MODE_CMYK && bytes == want_bytes) /* back to PSD's ink */ + invert_cmyk((uint8_t *)got, (size_t)w * (size_t)h, CMYK_COLORS + 1, sample); + check(got && bytes == want_bytes && memcmp(got, l->pixels, bytes) == 0, "pixels as given", name); + vips_error_clear(); + g_free(got); + g_object_unref(image); + } + /* One past the last: no such layer. */ + image = vips_image_new_from_file(path, "layer", (int)doc->layer_count, NULL); + got = image ? vips_image_write_to_memory(image, &bytes) : NULL; + check(!got, "no layer past the last", doc_name); + vips_error_clear(); + g_free(got); + if (image) g_object_unref(image); +} + +/* Writes doc into dir, then loads it the four ways; path receives the file's, for more checks. */ +static void check_document(const minipsd_doc *doc, const minipsd_write_opts *opts, const expected *e, const char *dir, + char *path, size_t path_size) +{ + out_buffer b = { NULL, 0, 0 }; + FILE *f; + const char *loader; + VipsSource *source; + int rc = minipsd_write(doc, opts, to_buffer, &b); + if (rc != MINIPSD_OK) { + printf("FAIL %s: not written: %s\n", e->name, minipsd_error_string(rc)); + ++failures; + free(b.data); + return; + } + snprintf(path, path_size, "%s/%s.%s", dir, e->name, doc->format == MINIPSD_PSB ? "psb" : "psd"); + f = fopen(path, "wb"); + check(f && fwrite(b.data, 1, b.size, f) == b.size, "file written", e->name); + if (f) fclose(f); + + loader = vips_foreign_find_load(path); + check(loader && strcmp(loader, "MinipsdVipsLoadFile") == 0, "found as psdload", e->name); + vips_error_clear(); + check_image(vips_image_new_from_file(path, NULL), doc, e, "file", &b); + check_image(vips_image_new_from_file(path, "access", VIPS_ACCESS_SEQUENTIAL, NULL), doc, e, "file, sequential", &b); + check_image(vips_image_new_from_buffer(b.data, b.size, "", NULL), doc, e, "buffer", &b); + source = vips_source_new_from_memory(b.data, b.size); + check_image(source ? vips_image_new_from_source(source, "", NULL) : NULL, doc, e, "source", &b); + if (source) g_object_unref(source); + free(b.data); +} + +/* An indexed document's image against its palette, worked out here: each pixel the palette's RGB, + alpha 0 at the transparent index when there is one. */ +static void check_indexed(const char *path, const uint8_t *indices, const uint8_t *palette, int transparent) +{ + const unsigned bands = transparent >= 0 ? RGBA : 3; + VipsImage *image = vips_image_new_from_file(path, NULL); + size_t bytes = 0, i; + uint8_t *got = image ? (uint8_t *)vips_image_write_to_memory(image, &bytes) : NULL; + int same = got && bytes == (size_t)PIXELS * bands; + for (i = 0; same && i < PIXELS; ++i) { + const uint8_t *p = got + i * bands; + same = p[0] == palette[indices[i]] && p[1] == palette[PALETTE + indices[i]] && p[2] == palette[2 * PALETTE + indices[i]] + && (transparent < 0 || p[3] == (indices[i] == transparent ? 0 : UCHAR_MAX)); + } + check(same, "the palette's colours", path); + vips_error_clear(); + g_free(got); + if (image) g_object_unref(image); +} + +static uint8_t *read_all(const char *path, size_t *size); +static size_t layer_count(const void *file, size_t size); + +/* The same samples: bytes, or floats within FLOAT_EPSILON. */ +static int same_samples(const void *got, const void *want, size_t bytes, int floats) +{ + const float *g = (const float *)got, *w = (const float *)want; + size_t i; + if (!floats) return memcmp(got, want, bytes) == 0; + for (i = 0; i < bytes / sizeof(float); ++i) + if ((double)fabsf(g[i] - w[i]) > FLOAT_EPSILON) return 0; + return 1; +} + +/* An image over memory, with its interpretation and the test's resolution. */ +static VipsImage *make_image(const void *data, int bands, VipsBandFormat format, VipsInterpretation interpretation) +{ + const size_t bytes = (size_t)PIXELS * (size_t)bands * vips_format_sizeof(format); + VipsImage *memory = vips_image_new_from_memory(data, bytes, WIDTH, HEIGHT, bands, format), *image = NULL; + if (memory) { + vips_copy(memory, &image, "interpretation", interpretation, "xres", DPI / MM_PER_INCH, "yres", DPI / MM_PER_INCH, + NULL); + g_object_unref(memory); + } + return image; +} + +/* What minipsd_write makes of the image from its pixels, as psdsave wrote it before it encoded the + channels itself: one layer when layered (without transparency when the image has no alpha), + the composite, CMYK as PSD's inverted ink (8-bit, as the tests' images are), the image's + resolution, the EXIF, XMP and IPTC libvips gave psdsave (taken from its file), its format as + psdsave chose it (the file's); a "compression" option as psdsave takes it, ZIP deflated a row at + a time as psdsave deflates it. */ +static int from_pixels(VipsImage *image, unsigned colors, int layered, const char *option, int value, const uint8_t *file, + size_t file_size, out_buffer *b) +{ + static const uint16_t kept[] = { MINIPSD_RES_EXIF_DATA_1, MINIPSD_RES_XMP_METADATA, MINIPSD_RES_IPTC_NAA }; + uint8_t workspace[WORKSPACE_SIZE]; + minipsd_reader *r; + minipsd_info info; + minipsd_resource resources[sizeof kept / sizeof kept[0]]; + minipsd_layer layer; + minipsd_doc doc; + minipsd_write_opts opts; + size_t bytes = 0, i, count = 0; + uint8_t *pixels = (uint8_t *)vips_image_write_to_memory(image, &bytes); + const int alpha = (unsigned)image->Bands > colors; + size_t row = (size_t)image->Xsize * vips_format_sizeof(image->BandFmt); /* a channel's row */ + int rc; + if (!pixels) return MINIPSD_ERR_ALLOC; + if (image->Type == VIPS_INTERPRETATION_CMYK && image->BandFmt == VIPS_FORMAT_UCHAR) + for (i = 0; i < bytes; ++i) + if (i % (size_t)image->Bands < CMYK_COLORS) pixels[i] = (uint8_t)(UCHAR_MAX - pixels[i]); + memset(&layer, 0, sizeof layer); + memset(&doc, 0, sizeof doc); + memset(&opts, 0, sizeof opts); + layer.name = "Layer 1"; + layer.pixels = pixels; + layer.opacity = UCHAR_MAX; + layer.no_alpha = (uint8_t)!alpha; + doc.width = (uint32_t)image->Xsize; + doc.height = (uint32_t)image->Ysize; + doc.dpi = image->Xres * MM_PER_INCH; + doc.color_mode = image->Type == VIPS_INTERPRETATION_CMYK ? MINIPSD_MODE_CMYK : colors == 3 ? MINIPSD_MODE_RGB : MINIPSD_MODE_GREY; + doc.depth = image->BandFmt == VIPS_FORMAT_FLOAT ? 32 : image->BandFmt == VIPS_FORMAT_USHORT ? 16 : 8; + doc.layers = layered ? &layer : NULL; + doc.layer_count = layered ? 1 : 0; + doc.composite = pixels; + doc.composite_alpha = (uint8_t)alpha; + doc.compression = !option || strcmp(option, "compression") != 0 ? MINIPSD_RLE + : value == MINIPSD_VIPS_COMPRESSION_ZIP ? MINIPSD_ZIP + : value == MINIPSD_VIPS_COMPRESSION_NONE ? MINIPSD_RAW + : value == MINIPSD_VIPS_COMPRESSION_ZIP_PREDICTED ? MINIPSD_ZIP_PREDICTED + : MINIPSD_RLE; + doc.format = file_size > PSD_VERSION_AT && file[PSD_VERSION_AT] == MINIPSD_PSB ? MINIPSD_PSB : MINIPSD_PSD; + opts.deflate = deflate_rows; + opts.deflate_user = &row; + if (minipsd_reader_init(&r, file, file_size, workspace, sizeof workspace, NULL, &info) == MINIPSD_OK) + for (i = 0; i < sizeof kept / sizeof kept[0]; ++i) /* in psdsave's order */ + if (minipsd_reader_find_resource(r, kept[i], &resources[count]) == MINIPSD_OK) { + resources[count].name.data = NULL; + resources[count].name.size = 0; + ++count; + } + doc.resources = resources; + doc.resource_count = count; + rc = minipsd_write(&doc, &opts, to_buffer, b); + g_free(pixels); + return rc; +} + +/* An image saved through psdsave as a file, into a buffer and into a target, the same bytes each + time, then read back through psdload as it was, its bands too: with alpha one layer, and the + composite when alpha is full (the matte undone exactly); without, a flat file, the composite + only, or with `layered` a layer without transparency as well. options are a psdsave option and + its value, or NULL. */ +static void check_saved(VipsImage *image, const char *name, const char *dir, int full_alpha, const char *option, + int value) +{ + char path[PATH_MAX_SIZE]; + VipsImage *want_image = NULL, *back; + VipsTarget *target; + VipsBlob *blob = NULL; + void *buffer = NULL, *want = NULL, *got; + uint8_t *file; + size_t file_size = 0, buffer_size = 0, want_bytes = 0, got_bytes = 0, target_size = 0; + const void *target_data = NULL; + const char *saver; + int colors, saved, layer, layered; + if (!image) { + check(0, "image made", name); + return; + } + snprintf(path, sizeof path, "%s/%s", dir, name); + saved = option ? vips_image_write_to_file(image, path, option, value, NULL) : vips_image_write_to_file(image, path, NULL); + if (saved) { + printf("FAIL %s: not saved: %s\n", name, vips_error_buffer()); + vips_error_clear(); + ++failures; + g_object_unref(image); + return; + } + saver = vips_foreign_find_save(path); + check(saver && strcmp(saver, "MinipsdVipsSaveFile") == 0, "found as psdsave", name); + + /* The same bytes three ways. */ + file = read_all(path, &file_size); + target = vips_target_new_to_memory(); + if (option) { + vips_image_write_to_buffer(image, ".psd", &buffer, &buffer_size, option, value, NULL); + if (target) vips_image_write_to_target(image, ".psd", target, option, value, NULL); + } else { + vips_image_write_to_buffer(image, ".psd", &buffer, &buffer_size, NULL); + if (target) vips_image_write_to_target(image, ".psd", target, NULL); + } + if (target) g_object_get(target, "blob", &blob, NULL); + if (blob) target_data = vips_blob_get(blob, &target_size); + check(file && buffer && buffer_size == file_size && memcmp(buffer, file, file_size) == 0, "buffer as the file", name); + check(file && target_data && target_size == file_size && memcmp(target_data, file, file_size) == 0, "target as the file", + name); + vips_error_clear(); + + /* Read back: the layer, when there is one, then the composite; the image as it is. */ + colors = image->Type == VIPS_INTERPRETATION_CMYK ? CMYK_COLORS : image->Bands >= 3 ? 3 : 1; + layered = image->Bands > colors || (option && strcmp(option, "layered") == 0 && value); + check(file && layer_count(file, file_size) == (layered ? 1u : 0u), layered ? "one layer" : "flat: no layer", name); + { /* encoded once by psdsave, the bytes minipsd_write makes from the pixels */ + out_buffer old = { NULL, 0, 0 }; + check(file && from_pixels(image, (unsigned)colors, layered, option, value, file, file_size, &old) == MINIPSD_OK + && old.size == file_size + && memcmp(old.data, file, file_size) == 0, + "the bytes minipsd_write makes from the pixels", name); + free(old.data); + } + want_image = image; + g_object_ref(want_image); + want = vips_image_write_to_memory(want_image, &want_bytes); + for (layer = layered ? 0 : COMPOSITE; layer >= COMPOSITE; --layer) { + if (layer == COMPOSITE && !full_alpha) break; + back = vips_image_new_from_file(path, "layer", layer, NULL); + got = back ? vips_image_write_to_memory(back, &got_bytes) : NULL; + check(want && got && got_bytes == want_bytes + && same_samples(got, want, want_bytes, layer == COMPOSITE && image->BandFmt == VIPS_FORMAT_FLOAT), + layer == COMPOSITE ? "composite read back as the image" : "layer read back as the image", name); + if (back && (layer == 0 || !layered)) + check(fabs(vips_image_get_xres(back) - DPI / MM_PER_INCH) < EPSILON, "resolution read back", name); + vips_error_clear(); + g_free(got); + if (back) g_object_unref(back); + } + g_free(want); + if (want_image) g_object_unref(want_image); + if (blob) vips_area_unref(VIPS_AREA(blob)); + if (target) g_object_unref(target); + g_free(buffer); + free(file); + g_object_unref(image); +} + +/* The whole file in a new buffer; NULL when it cannot be read. */ +static uint8_t *read_all(const char *path, size_t *size) +{ + uint8_t *data = NULL; + long n; + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + if (fseek(f, 0, SEEK_END) == 0 && (n = ftell(f)) >= 0 && fseek(f, 0, SEEK_SET) == 0) { + *size = (size_t)n; + data = (uint8_t *)malloc(*size ? *size : 1); + if (data && fread(data, 1, *size, f) != *size) { + free(data); + data = NULL; + } + } + fclose(f); + return data; +} + +/* The file's layer count, 0 when minipsd does not read it. */ +static size_t layer_count(const void *file, size_t size) +{ + uint8_t workspace[WORKSPACE_SIZE]; + minipsd_reader *r; + minipsd_info info; + return minipsd_reader_init(&r, file, size, workspace, sizeof workspace, NULL, &info) == MINIPSD_OK ? info.layer_count : 0; +} + +/* A tall image read sequentially is decoded in bands: libvips' tracked memory, its regions and + caches, stays far below the image's size (the file is mapped, which libvips does not count). + Run first, before anything else raises the high-water mark. */ +static void check_bounded(const char *dir) +{ + char path[PATH_MAX_SIZE]; + const size_t bytes = (size_t)TALL_W * TALL_H * 2; /* grey and transparency */ + uint8_t *composite = (uint8_t *)malloc(bytes); + out_buffer b = { NULL, 0, 0 }; + minipsd_doc doc; + VipsImage *image; + FILE *f; + double mean = 0.0; + size_t i; + if (!composite) { + check(0, "memory for the tall image", "bounded"); + return; + } + for (i = 0; i < bytes; ++i) composite[i] = (uint8_t)(i % 2 ? UCHAR_MAX : i / 2 % TALL_W); + memset(&doc, 0, sizeof doc); + doc.width = TALL_W, doc.height = TALL_H, doc.dpi = DPI; + doc.color_mode = MINIPSD_MODE_GREY; + doc.composite = composite; + doc.composite_alpha = 1; /* grey and transparency */ + check(minipsd_write(&doc, NULL, to_buffer, &b) == MINIPSD_OK, "tall image written", "bounded"); + free(composite); + snprintf(path, sizeof path, "%s/tall.psd", dir); + f = fopen(path, "wb"); + check(f && b.data && fwrite(b.data, 1, b.size, f) == b.size, "file written", path); + if (f) fclose(f); + free(b.data); + + image = vips_image_new_from_file(path, "access", VIPS_ACCESS_SEQUENTIAL, NULL); + check(image && vips_avg(image, &mean, NULL) == 0, "read through", path); + printf("tall image: %zu bytes decoded, libvips held at most %zu\n", bytes, vips_tracked_get_mem_highwater()); + check(vips_tracked_get_mem_highwater() < bytes / BOUND, "memory bounded by bands, not by the image", path); + vips_error_clear(); + if (image) g_object_unref(image); +} + +/* The composite's first pixel's sample in channel k, as the file stores it (minipsd alone, 8-bit); + -1 when it does not decode. */ +static int stored_sample(const char *path, unsigned k) +{ + uint8_t workspace[WORKSPACE_SIZE], *data, *pixels = NULL; + minipsd_reader *r; + minipsd_info info; + minipsd_target target; + minipsd_decode_opts opts; + size_t size = 0; + int sample = -1; + if (!(data = read_all(path, &size))) return -1; + if (minipsd_reader_init(&r, data, size, workspace, sizeof workspace, NULL, &info) == MINIPSD_OK && info.depth == 8 + && (pixels = (uint8_t *)malloc((size_t)info.width * info.height * MINIPSD_PLANES))) { + minipsd_target_interleaved(&target, pixels, (size_t)info.width * MINIPSD_PLANES, MINIPSD_PLANES, 8); + memset(&opts, 0, sizeof opts); + opts.depth = 8; + opts.y_count = 1; + if (minipsd_decode_composite(r, &target, &opts) == MINIPSD_OK) sample = pixels[k]; + } + free(pixels); + free(data); + return sample; +} + +/* The stored compression marker of the file's first layer's first channel; -1 without one. */ +static int first_layer_compression(const char *path) +{ + uint8_t workspace[WORKSPACE_SIZE], *data; + minipsd_reader *r; + minipsd_info info; + minipsd_layer_view view; + size_t size = 0; + int marker = -1; + if (!(data = read_all(path, &size))) return -1; + if (minipsd_reader_init(&r, data, size, workspace, sizeof workspace, NULL, &info) == MINIPSD_OK + && minipsd_reader_next_layer(r, &view) == MINIPSD_MORE && view.channel[0].data && view.channel[0].size >= 2) + marker = view.channel[0].data[0] << 8 | view.channel[0].data[1]; + free(data); + return marker; +} + +/* Each file the list names, its composite and every layer, loaded through libvips and compared + with minipsd's decode. */ +static void check_list(const char *list) +{ + char path[PATH_MAX_SIZE], name[NAME_SIZE]; + size_t size, length, files = 0, decoded = 0, layers = 0, layers_decoded = 0, i, count; + uint8_t *data; + FILE *f = fopen(list, "r"); + if (!f) { + check(0, "list read", list); + return; + } + while (fgets(path, sizeof path, f)) { + length = strcspn(path, "\r\n"); + path[length] = 0; + if (!length) continue; + if (!(data = read_all(path, &size))) { + check(0, "file read", path); + continue; + } + ++files; + decoded += (size_t)check_pixels(vips_image_new_from_file(path, NULL), data, size, COMPOSITE, path); + for (count = layer_count(data, size), i = 0; i < count; ++i, ++layers) { + snprintf(name, sizeof name, "%s layer %zu", path, i); + layers_decoded += (size_t)check_pixels(vips_image_new_from_file(path, "layer", (int)i, NULL), data, size, + (int)i, name); + } + free(data); + } + fclose(f); + printf("%zu files: %zu composites and %zu of %zu layers decoded as minipsd decodes them, the rest refused by both\n", + files, decoded, layers_decoded, layers); +} + +/* An image's pixels whole, or NULL. */ +static uint8_t *pixels_of(VipsImage *image, size_t *bytes) +{ + return image ? (uint8_t *)vips_image_write_to_memory(image, bytes) : NULL; +} + +/* The bytes of a file, as written by minipsd_write; 0 on failure. */ +static int write_file(const minipsd_doc *doc, const char *path) +{ + out_buffer b = { NULL, 0, 0 }; + FILE *f = NULL; + int ok = minipsd_write(doc, NULL, to_buffer, &b) == MINIPSD_OK && (f = fopen(path, "wb")) != NULL; + if (ok) ok = fwrite(b.data, 1, b.size, f) == b.size; + if (f && fclose(f) != 0) ok = 0; + free(b.data); + return ok; +} + +/* Two files the same, byte for byte. */ +static int same_files(const char *a, const char *b) +{ + size_t na = 0, nb = 0; + uint8_t *fa = read_all(a, &na), *fb = read_all(b, &nb); + const int same = fa && fb && na == nb && memcmp(fa, fb, na) == 0; + free(fa); + free(fb); + return same; +} + +/* An empty layer as psdsave_layers takes it, and psdload gives it: one pixel, psd-layer-empty. */ +static VipsImage *empty_layer(const char *name) +{ + VipsImage *black, *one = NULL; + if (vips_black(&black, 1, 1, "bands", RGBA, NULL)) return NULL; + if (vips_copy(black, &one, "interpretation", VIPS_INTERPRETATION_sRGB, NULL)) one = NULL; + g_object_unref(black); + if (one) { + vips_image_set_int(one, "psd-layer-empty", 1); + vips_image_set_string(one, "psd-layer-name", name); + } + return one; +} + +/* The composite's pixel (x, y) in a file: its first n bands into px; 0 on failure. */ +static int composite_pixel(const char *path, int x, int y, double *px, int n) +{ + VipsImage *image = vips_image_new_from_file(path, NULL); + double *values = NULL; + int bands = 0; + const int ok = image && !vips_getpoint(image, &values, &bands, x, y, NULL) && bands >= n; + if (ok) memcpy(px, values, (size_t)n * sizeof *values); + g_free(values); + VIPS_UNREF(image); + return ok; +} + +/* Within the composite's rounding of each other. */ +static int near(double a, double b) +{ + return fabs(a - b) <= COMPOSED_LEVELS; +} + +/* psdsave_layers: a layered document from images, each a layer. Platemaker's template (a layer + and an empty one above) byte for byte as minipsd_write makes it from the same layers; its + composite made by libvips, the layer's own; a document minipsd wrote, every layer loaded with + psdload and saved, the same bytes back (position, opacity, blend mode, clipping, an empty + layer, a mask, a group); masks and groups in the composite libvips makes; the canvas placed by left and top, or sized from the layers; what it refuses. */ +static void check_layers_saved(const uint8_t *rgba, const char *dir) +{ + char path[PATH_MAX_SIZE], again[PATH_MAX_SIZE]; + VipsImage *template_image = make_image(rgba, RGBA, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB), *empty = empty_layer("Layer"); + VipsImage *pair[2], *back = NULL, *given = NULL, *made = NULL; + minipsd_layer layers[RECORDS]; + minipsd_doc doc; + size_t given_bytes = 0, made_bytes = 0, i; + uint8_t *a, *b; + int k; + + if (!template_image || !empty) { + check(0, "images made", "psdsave_layers"); + return; + } + vips_image_set_string(template_image, "psd-layer-name", "Template"); + pair[0] = template_image; + pair[1] = empty; + + /* Platemaker's template, its composite given (the template itself: the layer above is empty). */ + snprintf(path, sizeof path, "%s/layers-template.psd", dir); + check(minipsd_vips_psdsave_layers(pair, 2, path, "composite", template_image, NULL) == 0, "saved", path); + memset(layers, 0, sizeof layers); + memset(&doc, 0, sizeof doc); + layers[0].name = "Template"; + layers[0].pixels = rgba; + layers[0].opacity = UCHAR_MAX; + layers[1].name = "Layer"; + layers[1].opacity = UCHAR_MAX; + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.layers = layers, doc.layer_count = 2, doc.composite = rgba, doc.composite_alpha = 1; + snprintf(again, sizeof again, "%s/layers-template-minipsd.psd", dir); + check(write_file(&doc, again) && same_files(path, again), "the bytes minipsd_write makes from the same layers", path); + + /* Its composite made by libvips from the layers: the template's own pixels. */ + snprintf(again, sizeof again, "%s/layers-template-composed.psd", dir); + check(minipsd_vips_psdsave_layers(pair, 2, again, NULL) == 0, "saved, composite composed", again); + given = vips_image_new_from_file(path, NULL); + made = vips_image_new_from_file(again, NULL); + a = pixels_of(given, &given_bytes); + b = pixels_of(made, &made_bytes); + { /* libvips composes in floats: exact where alpha is full; within a level, doubled by the + matte undone, where it is partial */ + size_t j, off = 0; + for (j = 0; a && b && given_bytes == made_bytes && j < given_bytes; ++j) { + const unsigned alpha = a[j - j % RGBA + 3], d = (unsigned)abs((int)a[j] - (int)b[j]); + if (alpha == UCHAR_MAX ? d != 0 : d > COMPOSED_LEVELS) ++off; + } + check(a && b && given_bytes == made_bytes && off == 0, "composite composed by libvips: the layer's", again); + } + g_free(a); + g_free(b); + VIPS_UNREF(given); + VIPS_UNREF(made); + + /* The empty layer loaded: one transparent pixel, psd-layer-empty, its name. */ + back = vips_image_new_from_file(path, "layer", 1, NULL); + { + const char *name = NULL; + check(back && back->Xsize == 1 && back->Ysize == 1 && vips_image_get_int(back, "psd-layer-empty", &k) == 0 && k == 1 + && vips_image_get_string(back, "psd-layer-name", &name) == 0 && name && strcmp(name, "Layer") == 0, + "an empty layer loads as one pixel, psd-layer-empty", path); + } + VIPS_UNREF(back); + + /* A document minipsd writes: an opaque base; a smaller layer placed, half opaque, multiplied, + clipped to it, with a disabled mask of its own rectangle; a group (passing through, opacity + 200) holding a hidden empty layer; each record loaded with psdload and saved back: the same + bytes. */ + { + enum { SMALL_W = 50, SMALL_H = 40, SMALL_X = 30, SMALL_Y = 20, MASK_W = 20, MASK_H = 10, MASK_X = 35, MASK_Y = 25, + GROUP_OPACITY = 200 }; + uint8_t *base = (uint8_t *)malloc(PIXELS * RGBA), *small = (uint8_t *)malloc(SMALL_W * SMALL_H * RGBA); + uint8_t mask_pixels[MASK_W * MASK_H]; + minipsd_mask mask; + VipsImage *loaded[RECORDS] = { NULL }, *composite = NULL; + int all = 1; + memset(layers, 0, sizeof layers); + memset(&doc, 0, sizeof doc); + memset(&mask, 0, sizeof mask); + if (base && small) { + memcpy(base, rgba, PIXELS * RGBA); + for (i = 0; i < PIXELS; ++i) base[i * RGBA + 3] = UCHAR_MAX; /* opaque: the composite comes back exactly */ + for (i = 0; i < SMALL_W * SMALL_H * RGBA; ++i) small[i] = (uint8_t)(i * 11 + 3); + for (i = 0; i < MASK_W * MASK_H; ++i) mask_pixels[i] = (uint8_t)(i * 7); + mask.top = MASK_Y, mask.left = MASK_X, mask.bottom = MASK_Y + MASK_H, mask.right = MASK_X + MASK_W; + mask.pixels = mask_pixels, mask.flags = MASK_DISABLED; + layers[0].name = "Base", layers[0].pixels = base, layers[0].opacity = UCHAR_MAX; + layers[1].name = "Clip", layers[1].pixels = small, layers[1].opacity = HALF_OPAQUE, layers[1].clipping = 1; + layers[1].top = SMALL_Y, layers[1].left = SMALL_X, layers[1].bottom = SMALL_Y + SMALL_H, layers[1].right = SMALL_X + SMALL_W; + layers[1].mask = &mask; + memcpy(layers[1].blend_mode, "mul ", 4); + layers[2].name = "", layers[2].opacity = UCHAR_MAX, layers[2].section_divider_type = MINIPSD_SECTION_GROUP_END; + layers[3].name = "Empty", layers[3].opacity = UCHAR_MAX, layers[3].hidden = 1; + layers[4].name = "Group", layers[4].opacity = GROUP_OPACITY, layers[4].section_divider_type = MINIPSD_SECTION_GROUP; + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.layers = layers, doc.layer_count = RECORDS, doc.composite_alpha = 1; + snprintf(path, sizeof path, "%s/layers-minipsd.psd", dir); + check(write_file(&doc, path), "written by minipsd", path); + for (k = 0; k < RECORDS; ++k) all &= (loaded[k] = vips_image_new_from_file(path, "layer", k, NULL)) != NULL; + composite = vips_image_new_from_file(path, NULL); + snprintf(again, sizeof again, "%s/layers-resaved.psd", dir); + check(all && composite && minipsd_vips_psdsave_layers(loaded, RECORDS, again, "composite", composite, NULL) == 0, + "every record loaded and saved", again); + check(same_files(path, again), + "the same bytes back: names, position, opacity, blend mode, clipping, empty, mask, group", again); + vips_error_clear(); + } + for (k = 0; k < RECORDS; ++k) VIPS_UNREF(loaded[k]); + VIPS_UNREF(composite); + free(base); + free(small); + } + + /* Masks and groups in the composite libvips makes, no layer with alpha (so against white): a + mask's hole (0 inside, 255 outside) shows white; a hidden group hides its red layer; shown + at half opacity, the red is half over what is beneath; the mask disabled, no hole. */ + { + enum { HOLE_SIDE = 10, HOLE_AT = 5, INSIDE = 6 }; + static const double none[PSD_RGB_BANDS] = { 0.0, 0.0, 0.0 }, ink[PSD_RGB_BANDS] = { UCHAR_MAX, 0.0, 0.0 }; + VipsImage *rgb = NULL, *black = NULL, *hole = NULL, *flat = NULL, *lit = NULL, *red8 = NULL, *red = NULL; + VipsImage *divider = empty_layer(""), *group = empty_layer("Group"), *records[4]; + double at_corner[PSD_RGB_BANDS], in_hole[PSD_RGB_BANDS]; + const double half = HALF_OPAQUE / (double)UCHAR_MAX; + const int ready = divider && group && !vips_extract_band(template_image, &rgb, 0, "n", PSD_RGB_BANDS, NULL) + && !vips_black(&black, HOLE_SIDE, HOLE_SIDE, NULL) + && !vips_copy(black, &hole, "xoffset", HOLE_AT, "yoffset", HOLE_AT, NULL) + && !vips_black(&flat, WIDTH, HEIGHT, "bands", PSD_RGB_BANDS, NULL) + && !vips_linear(flat, &lit, none, ink, PSD_RGB_BANDS, NULL) && !vips_cast(lit, &red8, VIPS_FORMAT_UCHAR, NULL) + && !vips_copy(red8, &red, "interpretation", VIPS_INTERPRETATION_sRGB, NULL); + check(ready, "images made", "masks and groups"); + if (ready) { + vips_image_set_image(rgb, "psd-layer-mask", hole); + vips_image_set_int(rgb, "psd-layer-mask-default-color", UCHAR_MAX); + vips_image_set_string(divider, "psd-layer-section", "end"); + vips_image_set_string(group, "psd-layer-section", "open"); + vips_image_set_int(group, "psd-layer-hidden", 1); + records[0] = rgb, records[1] = divider, records[2] = red, records[3] = group; + snprintf(path, sizeof path, "%s/layers-masked.psd", dir); + check(minipsd_vips_psdsave_layers(records, 4, path, NULL) == 0 && composite_pixel(path, 0, 0, at_corner, PSD_RGB_BANDS) + && composite_pixel(path, INSIDE, INSIDE, in_hole, PSD_RGB_BANDS), + "saved, composite composed", path); + check(near(at_corner[0], rgba[0]) && near(at_corner[1], rgba[1]) && near(at_corner[2], rgba[2]), + "a hidden group's layer not drawn", path); + check(near(in_hole[0], UCHAR_MAX) && near(in_hole[1], UCHAR_MAX) && near(in_hole[2], UCHAR_MAX), + "the mask's hole shows white", path); + vips_image_set_int(group, "psd-layer-hidden", 0); + vips_image_set_int(group, "psd-layer-opacity", HALF_OPAQUE); + snprintf(path, sizeof path, "%s/layers-grouped.psd", dir); + check(minipsd_vips_psdsave_layers(records, 4, path, NULL) == 0 && composite_pixel(path, 0, 0, at_corner, PSD_RGB_BANDS) + && composite_pixel(path, INSIDE, INSIDE, in_hole, PSD_RGB_BANDS), + "saved, composite composed", path); + check(near(at_corner[0], UCHAR_MAX * half + rgba[0] * (1.0 - half)) && near(at_corner[1], rgba[1] * (1.0 - half)) + && near(in_hole[0], UCHAR_MAX) && near(in_hole[1], UCHAR_MAX * (1.0 - half)), + "a group's opacity multiplies its layer's", path); + vips_image_set_int(group, "psd-layer-hidden", 1); + vips_image_set_int(rgb, "psd-layer-mask-disabled", 1); + snprintf(path, sizeof path, "%s/layers-unmasked.psd", dir); + i = ((size_t)INSIDE * WIDTH + INSIDE) * RGBA; + check(minipsd_vips_psdsave_layers(records, 4, path, NULL) == 0 && composite_pixel(path, INSIDE, INSIDE, in_hole, PSD_RGB_BANDS) + && near(in_hole[0], rgba[i]) && near(in_hole[1], rgba[i + 1]) && near(in_hole[2], rgba[i + 2]), + "a disabled mask hides nothing", path); + } + VIPS_UNREF(rgb); + VIPS_UNREF(black); + VIPS_UNREF(hole); + VIPS_UNREF(flat); + VIPS_UNREF(lit); + VIPS_UNREF(red8); + VIPS_UNREF(red); + VIPS_UNREF(divider); + VIPS_UNREF(group); + } + + /* The canvas: placed by left and top, sized from the layers without width and height. */ + { + enum { AT_X = 7, AT_Y = 3 }; + VipsImage *moved = NULL, *one[1]; + uint8_t workspace[WORKSPACE_SIZE], *file; + size_t size = 0; + minipsd_reader *r; + minipsd_info info; + minipsd_layer_view view; + if (vips_copy(template_image, &moved, "xoffset", AT_X, "yoffset", AT_Y, NULL)) moved = NULL; + one[0] = moved; + snprintf(path, sizeof path, "%s/layers-canvas.psd", dir); + check(moved && minipsd_vips_psdsave_layers(one, 1, path, "left", AT_X, "top", AT_Y, NULL) == 0, "saved, canvas placed", path); + file = read_all(path, &size); + check(file && minipsd_reader_init(&r, file, size, workspace, sizeof workspace, NULL, &info) == MINIPSD_OK + && info.width == WIDTH && info.height == HEIGHT && minipsd_reader_next_layer(r, &view) == MINIPSD_MORE + && view.left == 0 && view.top == 0, + "the canvas at (left, top): the layer at its corner", path); + free(file); + snprintf(path, sizeof path, "%s/layers-grown.psd", dir); + check(moved && minipsd_vips_psdsave_layers(one, 1, path, NULL) == 0, "saved, canvas from the layers", path); + file = read_all(path, &size); + check(file && minipsd_reader_init(&r, file, size, workspace, sizeof workspace, NULL, &info) == MINIPSD_OK + && info.width == WIDTH + AT_X && info.height == HEIGHT + AT_Y, + "the canvas from (0, 0), every layer in it", path); + free(file); + VIPS_UNREF(moved); + } + + /* Refused: no layers; a layer of another depth; a blend mode that is not a key. */ + { + VipsImage *deep = NULL, *mixed[2]; + snprintf(path, sizeof path, "%s/layers-refused.psd", dir); + check(minipsd_vips_psdsave_layers(pair, 0, path, NULL) != 0, "no layers refused", path); + if (vips_cast(template_image, &deep, VIPS_FORMAT_USHORT, NULL)) deep = NULL; + mixed[0] = template_image; + mixed[1] = deep; + check(deep && minipsd_vips_psdsave_layers(mixed, 2, path, NULL) != 0, "a layer of another depth refused", path); + vips_image_set_string(template_image, "psd-layer-blend-mode", "mul"); + check(minipsd_vips_psdsave_layers(pair, 2, path, NULL) != 0, "a blend mode of three characters refused", path); + vips_error_clear(); + VIPS_UNREF(deep); + } + g_object_unref(template_image); + g_object_unref(empty); +} + +int main(int argc, char **argv) +{ + static const uint8_t icc[] = "not a real ICC profile"; + static uint8_t rgba[PIXELS * RGBA], cmyka[PIXELS * MAX_PLANES], bits[PIXELS], indices[PIXELS]; + static uint8_t palette[3 * PALETTE], zip_a[ZIP_PIXELS * RGBA], zip_b[ZIP_PIXELS * RGBA]; + static uint16_t grey16[PIXELS * 2], rgba16[PIXELS * RGBA]; + static uint8_t rgb8[PIXELS * 3]; + static float rgbf[PIXELS * 3]; + static uint8_t cyan[PIXELS * CMYK_COLORS]; /* libvips' ink: full cyan, nothing else */ + static const char junk[] = "8BPX, not a PSD"; + char path[PATH_MAX_SIZE]; + uint8_t transparency[MINIPSD_TRANSPARENCY_INDEX_SIZE]; + minipsd_layer layer, zip_layers[2]; + minipsd_doc doc; + minipsd_write_opts opts; + minipsd_resource res; + const char *loader; + size_t i; + + if (argc != 2 && argc != 3) { + fprintf(stderr, "usage: %s out-dir [list]\n", argv[0]); + return 2; + } + if (VIPS_INIT(argv[0])) vips_error_exit(NULL); + vips_concurrency_set(THREADS); + minipsd_vips_init(); + minipsd_vips_init(); /* twice is harmless */ + check_bounded(argv[1]); + + for (i = 0; i < sizeof rgba; ++i) rgba[i] = (uint8_t)(i * 7); + for (i = 0; i < sizeof cmyka; ++i) cmyka[i] = (uint8_t)(i * 5); + for (i = 0; i < PIXELS * 2; ++i) grey16[i] = (uint16_t)(i * 4099); + for (i = 0; i < PIXELS * RGBA; ++i) rgba16[i] = (uint16_t)(i * 2731); + for (i = 0; i < PIXELS; ++i) bits[i] = (uint8_t)(i % 3 == 0); + for (i = 0; i < PIXELS; ++i) indices[i] = (uint8_t)(i * 13); + for (i = 0; i < sizeof palette; ++i) palette[i] = (uint8_t)(i * 11 + i / PALETTE * 85 + 3); /* R, G, B differ */ + for (i = 0; i < sizeof zip_a; ++i) zip_a[i] = (uint8_t)(i * 3); + for (i = 0; i < sizeof zip_b; ++i) zip_b[i] = (uint8_t)(i / 97); + for (i = 0; i < sizeof rgb8; ++i) rgb8[i] = (uint8_t)(i * 17 + i / 255); + for (i = 0; i < PIXELS * 3; ++i) rgbf[i] = (float)(i % 1001) / 1000.0f; + for (i = 0; i < PIXELS; ++i) cyan[i * CMYK_COLORS] = UCHAR_MAX; + + { /* 8-bit RGB with a layer, a profile and non-square pixels; as PSD and as PSB */ + static const expected psd = { "rgb8", 4, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB, 8 }; + static const expected psb = { "rgb8-psb", 4, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB, 8 }; + memset(&layer, 0, sizeof layer); + layer.name = "Layer"; + layer.pixels = rgba; + layer.opacity = UCHAR_MAX; + memset(&doc, 0, sizeof doc); + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.layers = &layer, doc.layer_count = 1, doc.composite_alpha = 1; /* RGBA, as the layer */ + doc.icc = icc, doc.icc_size = sizeof icc; + doc.pixel_aspect_ratio = ASPECT; + check_document(&doc, NULL, &psd, argv[1], path, sizeof path); + check_layers(path, &doc, 1, psd.name); + doc.format = MINIPSD_PSB; + check_document(&doc, NULL, &psb, argv[1], path, sizeof path); + check_layers(path, &doc, 1, psb.name); + } + { /* 16-bit RGB, its composite composed by the writer */ + static const expected e = { "rgb16", 4, VIPS_FORMAT_USHORT, VIPS_INTERPRETATION_RGB16, 16 }; + layer.pixels = (const uint8_t *)rgba16; + memset(&doc, 0, sizeof doc); + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.layers = &layer, doc.layer_count = 1, doc.composite_alpha = 1; /* RGBA, as the layer */ + doc.depth = 16; + check_document(&doc, NULL, &e, argv[1], path, sizeof path); + check_layers(path, &doc, sizeof(uint16_t), e.name); + } + /* Grey and CMYK: a layer and its composite (the writer composes RGB only); with a layer, the + negative layer count says the composite's transparency is there. */ + { /* 16-bit grey */ + static const expected e = { "grey16", 2, VIPS_FORMAT_USHORT, VIPS_INTERPRETATION_GREY16, 16 }; + layer.pixels = (const uint8_t *)grey16; + memset(&doc, 0, sizeof doc); + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.layers = &layer, doc.layer_count = 1, doc.composite_alpha = 1; /* RGBA, as the layer */ + doc.color_mode = MINIPSD_MODE_GREY, doc.depth = 16; + doc.composite = (const uint8_t *)grey16; + check_document(&doc, NULL, &e, argv[1], path, sizeof path); + check_layers(path, &doc, sizeof(uint16_t), e.name); + } + { /* 8-bit CMYK */ + static const expected e = { "cmyk8", 5, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_CMYK, 8 }; + layer.pixels = cmyka; + memset(&doc, 0, sizeof doc); + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.layers = &layer, doc.layer_count = 1, doc.composite_alpha = 1; /* RGBA, as the layer */ + doc.color_mode = MINIPSD_MODE_CMYK; + doc.composite = cmyka; + check_document(&doc, NULL, &e, argv[1], path, sizeof path); + check_layers(path, &doc, 1, e.name); + } + { /* bitmap: one band, no transparency, 1 bit a sample */ + static const expected e = { "bitmap", 1, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_B_W, 1 }; + memset(&doc, 0, sizeof doc); + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.color_mode = MINIPSD_MODE_BITMAP, doc.depth = 1; + doc.composite = bits; + check_document(&doc, NULL, &e, argv[1], path, sizeof path); + check_layers(path, &doc, 1, e.name); + } + { /* indexed: through the palette, RGB, then RGBA with a transparent index (resource 1047) */ + static const expected rgb = { "indexed", 3, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB, 8 }; + static const expected alpha = { "indexed-1047", 4, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB, 8 }; + memset(&doc, 0, sizeof doc); + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.color_mode = MINIPSD_MODE_INDEXED; + doc.color_mode_data.data = palette, doc.color_mode_data.size = sizeof palette; + doc.composite = indices; + check_document(&doc, NULL, &rgb, argv[1], path, sizeof path); + check_indexed(path, indices, palette, -1); + check(minipsd_resource_make_transparency_index(TRANSPARENT, transparency, sizeof transparency, NULL) == MINIPSD_OK, + "transparency index made", alpha.name); + memset(&res, 0, sizeof res); + res.id = MINIPSD_RES_TRANSPARENCY_INDEX, res.data.data = transparency, res.data.size = sizeof transparency; + doc.resources = &res, doc.resource_count = 1; + check_document(&doc, NULL, &alpha, argv[1], path, sizeof path); + check_indexed(path, indices, palette, TRANSPARENT); + } + { /* ZIP layers, with and without prediction: one past the canvas's left edge, half opaque, + one hidden inside it */ + static const expected zip = { "zip", 4, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB, 8 }; + static const expected predicted = { "zip-predicted", 4, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB, 8 }; + memset(zip_layers, 0, sizeof zip_layers); + zip_layers[0].name = "Past the edge", zip_layers[0].pixels = zip_a, zip_layers[0].opacity = HALF_OPAQUE; + zip_layers[0].top = 10, zip_layers[0].left = -50; + zip_layers[1].name = "Hidden", zip_layers[1].pixels = zip_b, zip_layers[1].opacity = UCHAR_MAX, zip_layers[1].hidden = 1; + zip_layers[1].top = 60, zip_layers[1].left = 70; + for (i = 0; i < 2; ++i) { + zip_layers[i].bottom = zip_layers[i].top + ZIP_H; + zip_layers[i].right = zip_layers[i].left + ZIP_W; + } + memset(&doc, 0, sizeof doc); + doc.width = WIDTH, doc.height = HEIGHT, doc.dpi = DPI; + doc.layers = zip_layers, doc.layer_count = 2, doc.composite_alpha = 1; /* composed with their transparency */ + doc.compression = MINIPSD_ZIP; + memset(&opts, 0, sizeof opts); + opts.deflate = deflate_zlib; + check_document(&doc, &opts, &zip, argv[1], path, sizeof path); + check_layers(path, &doc, 1, zip.name); + doc.compression = MINIPSD_ZIP_PREDICTED; + check_document(&doc, &opts, &predicted, argv[1], path, sizeof path); + check_layers(path, &doc, 1, predicted.name); + } + + { /* The saver: grey, RGB and CMYK at 8 and 16 bits, RGB as floats; ZIP; PSB by option and by + suffix; a layer name and a profile kept; what PSD cannot hold refused. */ + VipsImage *image, *back; + uint8_t *file; + size_t size = 0; + const char *got_name; + const void *got_icc, *want_icc = NULL; + size_t want_icc_size = 0; + VipsBlob *profile = NULL; + check_saved(make_image(rgb8, 3, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB), "save-rgb8.psd", argv[1], 1, NULL, 0); + check_saved(make_image(rgba, RGBA, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB), "save-rgba8.psd", argv[1], 0, NULL, 0); + check_saved(make_image(rgba16, 3, VIPS_FORMAT_USHORT, VIPS_INTERPRETATION_RGB16), "save-rgb16.psd", argv[1], 1, NULL, 0); + check_saved(make_image(indices, 1, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_B_W), "save-grey8.psd", argv[1], 1, NULL, 0); + check_saved(make_image(grey16, 2, VIPS_FORMAT_USHORT, VIPS_INTERPRETATION_GREY16), "save-greya16.psd", argv[1], 0, NULL, 0); + check_saved(make_image(cmyka, 4, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_CMYK), "save-cmyk8.psd", argv[1], 1, NULL, 0); + check_saved(make_image(cyan, CMYK_COLORS, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_CMYK), "save-cyan.psd", argv[1], 1, NULL, 0); + snprintf(path, sizeof path, "%s/save-cyan.psd", argv[1]); + check(stored_sample(path, 0) == 0 && stored_sample(path, 1) == UCHAR_MAX, "full cyan stored as 0, no magenta as 255", path); + snprintf(path, sizeof path, "%s/save-cmyk8.psd", argv[1]); + check(stored_sample(path, 0) == UCHAR_MAX - cmyka[0] && stored_sample(path, 3) == UCHAR_MAX - cmyka[3], + "CMYK stored as PSD's inverted ink", path); + check_saved(make_image(rgbf, 3, VIPS_FORMAT_FLOAT, VIPS_INTERPRETATION_scRGB), "save-float.psd", argv[1], 1, NULL, 0); + check_saved(make_image(rgb8, 3, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB), "save-layered.psd", argv[1], 1, "layered", TRUE); + check_saved(make_image(rgba, RGBA, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB), "save-zip.psd", argv[1], 0, "compression", + MINIPSD_VIPS_COMPRESSION_ZIP); + check_saved(make_image(rgba, RGBA, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB), "save-raw.psd", argv[1], 0, "compression", + MINIPSD_VIPS_COMPRESSION_NONE); + check_saved(make_image(grey16, 2, VIPS_FORMAT_USHORT, VIPS_INTERPRETATION_GREY16), "save-predicted.psd", argv[1], 0, + "compression", MINIPSD_VIPS_COMPRESSION_ZIP_PREDICTED); + snprintf(path, sizeof path, "%s/save-predicted.psd", argv[1]); + check(first_layer_compression(path) == STORED_ZIP_PREDICTED, "ZIP with prediction by option", path); + { /* 32-bit noise, rows a PSD cannot count: a PSB, unasked */ + const size_t samples = (size_t)WIDE_W * WIDE_H * PSD_RGB_BANDS; + float *noise = g_new(float, samples); + guint32 seed = 1; + VipsImage *memory; + size_t s; + for (s = 0; s < samples; ++s) noise[s] = (float)((seed = seed * 1664525u + 1013904223u) >> CHAR_BIT) / (float)(1u << 24); + memory = vips_image_new_from_memory_copy(noise, samples * sizeof *noise, WIDE_W, WIDE_H, PSD_RGB_BANDS, VIPS_FORMAT_FLOAT); + g_free(noise); + image = NULL; + if (memory) { + vips_copy(memory, &image, "interpretation", VIPS_INTERPRETATION_scRGB, "xres", DPI / MM_PER_INCH, "yres", + DPI / MM_PER_INCH, NULL); + g_object_unref(memory); + } + check_saved(image, "save-wide.psd", argv[1], 1, NULL, 0); + snprintf(path, sizeof path, "%s/save-wide.psd", argv[1]); + file = read_all(path, &size); + check(file && size > PSD_VERSION_AT && file[PSD_VERSION_AT] == MINIPSD_PSB, "PSB when a PSD cannot count its rows", path); + free(file); + } + snprintf(path, sizeof path, "%s/save-raw.psd", argv[1]); + check(first_layer_compression(path) == STORED_RAW, "raw by option", path); + image = make_image(rgba, RGBA, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB); /* by its nick, in an option string */ + snprintf(path, sizeof path, "%s/save-nick.psd[compression=zip]", argv[1]); + check(image && vips_image_write_to_file(image, path, NULL) == 0, "saved with compression=zip", path); + if (image) g_object_unref(image); + snprintf(path, sizeof path, "%s/save-nick.psd", argv[1]); + check(first_layer_compression(path) == STORED_ZIP, "zip by its nick", path); + snprintf(path, sizeof path, "%s/save-zip.psd", argv[1]); + check(first_layer_compression(path) == STORED_ZIP, "ZIP by option", path); + snprintf(path, sizeof path, "%s/save-rgba8.psd", argv[1]); + check(first_layer_compression(path) == STORED_RLE, "RLE by default", path); + check_saved(make_image(rgb8, 3, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB), "save-psb.psd", argv[1], 1, "psb", TRUE); + snprintf(path, sizeof path, "%s/save-psb.psd", argv[1]); + file = read_all(path, &size); + check(file && size > PSD_VERSION_AT && file[PSD_VERSION_AT] == MINIPSD_PSB, "PSB by option", path); + free(file); + /* A file named .psb is a PSB; a buffer or a target in memory has no name: psb says it. */ + snprintf(path, sizeof path, "%s/save-suffix.psb", argv[1]); + image = make_image(rgb8, 3, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB); + check(image && vips_image_write_to_file(image, path, NULL) == 0, "saved", path); + if (image) g_object_unref(image); + back = vips_image_new_from_file(path, NULL); + check(back != NULL, "read back", path); + if (back) g_object_unref(back); + file = read_all(path, &size); + check(file && size > PSD_VERSION_AT && file[PSD_VERSION_AT] == MINIPSD_PSB, "PSB by suffix", path); + free(file); + + snprintf(path, sizeof path, "%s/save-named.psd", argv[1]); + image = make_image(rgb8, 3, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_sRGB); + /* A real profile: libvips drops one it cannot read when it saves. */ + if (vips_profile_load("srgb", &profile, NULL) == 0) { + want_icc = vips_blob_get(profile, &want_icc_size); + if (image) vips_image_set_blob_copy(image, VIPS_META_ICC_NAME, want_icc, want_icc_size); + } + check(image && vips_call("psdsave", image, path, "layer_name", "Ink", "layered", TRUE, NULL) == 0, "saved by psdsave", + path); + back = vips_image_new_from_file(path, "layer", 0, NULL); + check(back && vips_image_get_string(back, "psd-layer-name", &got_name) == 0 && strcmp(got_name, "Ink") == 0, + "layer name kept", path); + check(back && want_icc && vips_image_get_blob(back, VIPS_META_ICC_NAME, &got_icc, &size) == 0 + && size == want_icc_size && memcmp(got_icc, want_icc, size) == 0, + "ICC profile kept", path); + if (profile) vips_area_unref(VIPS_AREA(profile)); + if (back) g_object_unref(back); + if (image) g_object_unref(image); + + snprintf(path, sizeof path, "%s/save-refused.psd", argv[1]); + image = make_image(rgba16, 6, VIPS_FORMAT_UCHAR, VIPS_INTERPRETATION_MULTIBAND); + check(image && vips_image_write_to_file(image, path, NULL) != 0, "six bands refused", path); + vips_error_clear(); + if (image) g_object_unref(image); + } + + check_layers_saved(rgba, argv[1]); + + /* Not a PSD: psdload does not claim it. */ + loader = vips_foreign_find_load_buffer(junk, sizeof junk); + check(!loader || strcmp(loader, "MinipsdVipsLoadBuffer") != 0, "junk not claimed", "junk"); + vips_error_clear(); + + if (argc == 3) check_list(argv[2]); + + vips_shutdown(); + printf(failures ? "%d failed\n" : "vips plugin: all passed\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7b97054..ca63b82 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,7 @@ # example. each program in examples/, its output checked against the golden bytes # fuzz.replay. the reader's fuzz target on each input in fuzz/regressions/ (see fuzz/) # consumer- a tiny project using minipsd each supported way (see consumer/) +# vips. the libvips plugin (libvips-plugin/), with MINIPSD_VIPS_PLUGIN # --------------------------------------------------------------------------- # GoogleTest: a system install if there is one, otherwise fetched. @@ -216,9 +217,21 @@ endforeach() add_test(NAME fuzz.replay.example COMMAND minipsd-fuzz-replay "${PROJECT_BINARY_DIR}/examples/out-write_memory/out.psd") set_tests_properties(fuzz.replay.example PROPERTIES FIXTURES_REQUIRED minipsd-example-psd) +# libvips, for what uses it (cmake/minipsd_libvips.cmake): both off by default, as libvips is +# fetched (Windows) or must be installed (pkg-config). +option(MINIPSD_VIPS_EXAMPLE "Build and run examples/vips_composite.c against libvips (fetched on Windows)" OFF) +if(MINIPSD_VIPS_EXAMPLE OR MINIPSD_VIPS_PLUGIN) + include("${PROJECT_SOURCE_DIR}/cmake/minipsd_libvips.cmake") +endif() + # The examples (examples/), built with the same warnings and run as tests. add_subdirectory("${PROJECT_SOURCE_DIR}/examples" "${PROJECT_BINARY_DIR}/examples") +# The libvips plugin: psdload and psdsave registered by the program that links it. +if(MINIPSD_VIPS_PLUGIN) + add_subdirectory("${PROJECT_SOURCE_DIR}/libvips-plugin" "${PROJECT_BINARY_DIR}/libvips-plugin") +endif() + # clang-tidy (the linux-tidy preset) on our own targets only, not on a fetched GoogleTest. # Checks and options come from .clang-tidy at the repository root. set(MINIPSD_CLANG_TIDY "" CACHE STRING "clang-tidy command to run on the test sources; empty = off") diff --git a/tests/minipsd_c_smoke.c b/tests/minipsd_c_smoke.c index 005ce48..52bd096 100644 --- a/tests/minipsd_c_smoke.c +++ b/tests/minipsd_c_smoke.c @@ -45,7 +45,7 @@ int minipsd_c_smoke(unsigned char *out, size_t capacity, size_t *size) layers[1].name = "Layer"; layers[1].pixels = NULL; layers[1].opacity = 255; layers[1].hidden = 0; layers[1].blending_ranges = &blend_if; layers[1].blending_range_count = 1; memset(&doc, 0, sizeof doc); - doc.width = 3; doc.height = 2; doc.dpi = 300.0; doc.layers = layers; doc.layer_count = 2; + doc.width = 3; doc.height = 2; doc.dpi = 300.0; doc.layers = layers; doc.layer_count = 2; doc.composite_alpha = 1; /* Every function, so a static build has none unused. */ if (!minipsd_error_string(MINIPSD_OK)[0] || minipsd_doc_check(&doc, NULL) != MINIPSD_OK) @@ -75,6 +75,16 @@ int minipsd_c_smoke(unsigned char *out, size_t capacity, size_t *size) if (rc != MINIPSD_OK || pulled != b.size || memcmp(again, b.data, b.size) != 0) return -102; + /* A row encoded, a row matted and a row predicted by the caller, as the writer does them. */ + { + uint8_t row[MINIPSD_ROW_BOUND(3, 8)], copy[sizeof px]; + memcpy(copy, px, sizeof copy); + if (minipsd_encode_row(MINIPSD_RLE, 8, px, 4, 3, row, sizeof row, &n) != MINIPSD_OK || !n + || minipsd_matte_row(MINIPSD_MODE_RGB, 8, copy, 3) != MINIPSD_OK + || minipsd_predict_row(8, px, 4, 3, row, sizeof row) != MINIPSD_OK) + return -126; + } + /* The reader, over the bytes just written: the file, its resources, its layers, their pixels and their blocks, the composite. */ { diff --git a/tests/psd_tools/roundtrip.py b/tests/psd_tools/roundtrip.py index 5003108..237657e 100644 --- a/tests/psd_tools/roundtrip.py +++ b/tests/psd_tools/roundtrip.py @@ -34,7 +34,8 @@ from psd_tools import PSDImage from psd_tools.constants import Compression, Resource as ResourceId -MINIPSD_OK, MINIPSD_RLE, MINIPSD_RAW, MINIPSD_ZIP, MINIPSD_ZIP_PREDICTED = 0, 0, 1, 2, 3 +MINIPSD_OK, MINIPSD_RLE, MINIPSD_RAW, MINIPSD_ZIP, MINIPSD_ZIP_PREDICTED = 0, 1, 2, 3, 4 +MARKER = {MINIPSD_RAW: 0, MINIPSD_RLE: 1, MINIPSD_ZIP: 2, MINIPSD_ZIP_PREDICTED: 3} # the file's numbers MINIPSD_ERR_UNSUPPORTED = -7 WRITE_FN = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t) INFLATE_FN = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, @@ -113,7 +114,8 @@ class Layer(ctypes.Structure): ("additional_info", Bytes), ("stored", ctypes.c_void_p), ("section_divider_type", ctypes.c_uint8), ("mask", ctypes.POINTER(Mask)), ("blending_ranges", ctypes.POINTER(BlendingRange)), ("blending_range_count", ctypes.c_uint32), - ("name_pascal", Bytes)] + ("name_pascal", Bytes), ("no_alpha", ctypes.c_uint8), + ("compression", ctypes.c_int)] class Doc(ctypes.Structure): @@ -323,6 +325,7 @@ def write(lib, path, w, h, dpi, compression, layers, fmt=0): layer[5] if len(layer) > 5 else b"\0\0\0\0") doc = Doc(w, h, dpi, records, len(layers), None, 0, compression) doc.format = fmt + doc.composite_alpha = 1 # composed with the layers' transparency out = bytearray() def collect(_user, data, size): @@ -441,6 +444,7 @@ def pixels(lw, lh, colour): records[i] = Layer(name=name.encode(), pixels=rgba, opacity=255, blend_mode=blend, section_divider_type=kind, mask=m) records[2].blending_ranges, records[2].blending_range_count = ranges, len(BLEND_IF) doc = Doc(w, h, 72.0, records, len(spec), None, 0, MINIPSD_RLE) + doc.composite_alpha = 1 out = bytearray() def collect(_user, data, size): @@ -504,7 +508,7 @@ def samples(values): records[1] = Layer(name=b"Masked", pixels=samples(top), opacity=255, top=5, left=10, bottom=5 + lh, right=10 + lw, mask=ctypes.pointer(mask)) doc = Doc(w, h, 72.0, records, 2, None, 0, compression) - doc.depth = 16 + doc.depth, doc.composite_alpha = 16, 1 out = bytearray() def collect(_user, data, size): @@ -596,6 +600,7 @@ def image(width, height, seed, alpha): records[1] = Layer(name=b"Top", pixels=samples(top), opacity=255, top=3, left=4, bottom=3 + lh, right=4 + lw) doc = Doc(w, h, 72.0, records, 2, None, 0, compression) doc.composite, doc.depth, doc.color_mode = samples(flat), depth, mode + doc.composite_alpha = 1 # the colour channels, then transparency if mode == MODE_DUOTONE: data = ctypes.create_string_buffer(DUOTONE_DATA, len(DUOTONE_DATA)) keep.append(data) @@ -636,6 +641,11 @@ def plane(values, k): if depth == 32: # in 'Lr32', as Photoshop keeps them, the layer info empty; 'Mt32': the composite's alpha expect("layer info", li.layer_count if li else 0, 0) expect("blocks", [k for k in (b"Mt32", b"Lr32") if lmi.tagged_blocks.get_data(k) is not None], [b"Mt32", b"Lr32"]) + # the layer count cannot say it, so the channel is named as Photoshop's transparentbg.psd + # names it, which GIMP reads it by + names = psd.image_resources + expect("alpha names", tuple(list(names.get_data(k) or []) for k in (ResourceId.ALPHA_NAMES_PASCAL, + ResourceId.ALPHA_NAMES_UNICODE, ResourceId.ALPHA_IDENTIFIERS)), (["Transparency"], ["Transparency\x00"], [0])) li = lmi.tagged_blocks.get_data(b"Lr32") expect("layers", len(li.layer_records), 2) for record, channels, values, (rw, rh) in zip(li.layer_records, li.channel_image_data, (base, top), ((w, h), (lw, lh))): @@ -650,9 +660,12 @@ def plane(values, k): MODE_BITMAP, MODE_INDEXED, MODE_MULTICHANNEL = 0, 2, 7 # The flat modes: no layers, no transparency. Bitmap's composite is a byte a pixel, packed by the -# writer; indexed's one palette entry a pixel; multichannel's only copied, as a file stores it. +# writer; indexed's one palette entry a pixel; multichannel's only copied, as a file stores it. And +# RGB and grey without transparency (composite_alpha 0): flat too, as Photoshop saves an image +# without layers, the composite supplied as the mode's channels only. FLAT = [(MODE_BITMAP, 1, MINIPSD_RLE), (MODE_BITMAP, 1, MINIPSD_RAW), (MODE_INDEXED, 8, MINIPSD_RLE), - (MODE_MULTICHANNEL, 8, MINIPSD_RLE), (MODE_MULTICHANNEL, 16, MINIPSD_RLE)] + (MODE_MULTICHANNEL, 8, MINIPSD_RLE), (MODE_MULTICHANNEL, 16, MINIPSD_RLE), + (MODE_RGB, 8, MINIPSD_RLE), (MODE_GREY, 16, MINIPSD_RLE)] PALETTE = bytes(i * 5 % 256 for i in range(768)) MULTICHANNELS = 3 @@ -686,10 +699,15 @@ def buffer(data): want = [bytes(values)] else: spread = ((1 << depth) - 1) // 255 # the 8-bit pattern over the depth's whole range - want = [b"".join(((c * 61 + i * 13) % 256 * spread).to_bytes(sample, "big") for i in range(w * h)) - for c in range(MULTICHANNELS)] - stored = buffer(bytes(2) + b"".join(want)) # the file's marker 0: raw - doc.composite_stored, doc.composite_channels = Bytes(ctypes.cast(stored, ctypes.c_void_p), len(stored)), MULTICHANNELS + channels = MODE_COLORS.get(mode, MULTICHANNELS) + values = [[(c * 61 + i * 13) % 256 * spread for i in range(w * h)] for c in range(channels)] + want = [b"".join(v.to_bytes(sample, "big") for v in plane) for plane in values] + if mode == MODE_MULTICHANNEL: + stored = buffer(bytes(2) + b"".join(want)) # the file's marker 0: raw + doc.composite_stored, doc.composite_channels = Bytes(ctypes.cast(stored, ctypes.c_void_p), len(stored)), channels + else: # supplied, interleaved, in host order: the colour channels and nothing after them + doc.composite = ctypes.cast(buffer(b"".join(values[c][i].to_bytes(sample, sys.byteorder) + for i in range(w * h) for c in range(channels))), ctypes.c_void_p) out = bytearray() def collect(_user, data, size): @@ -782,6 +800,7 @@ def made(fn, *args): layer[0] = Layer(name=b"Layer", pixels=ctypes.cast(pixels, ctypes.c_void_p), opacity=255, additional_info=made(lib.minipsd_layer_make_block_settings, None, 0, ctypes.byref(settings))) doc = Doc(2, 2, 72.0, layer, 1, None, 0, MINIPSD_RLE) + doc.composite_alpha = 1 doc.resources, doc.resource_count = ctypes.cast(resources, ctypes.c_void_p), len(resources) doc.pixel_aspect_ratio = SETTER_ASPECT out = bytearray() @@ -985,9 +1004,8 @@ def data(rec, c, d, version): same(f"layer {i} blocks", blocks(b.tagged_blocks), blocks(a.tagged_blocks)) same(f"layer {i} mask data", b.mask_data.tobytes() if b.mask_data else None, a.mask_data.tobytes() if a.mask_data else None) same(f"layer {i} blending ranges", b.blending_ranges.tobytes(), a.blending_ranges.tobytes()) - ids = [c.id.value for c in b.channel_info] - added = [] if -1 in ids or stored else [-1] # decoded, a layer without transparency gets an opaque one - same(f"layer {i} channels", sorted(added + ids), sorted(c.id.value for c in a.channel_info)) + ids = [c.id.value for c in b.channel_info] # a layer without transparency stays without, decoded too + same(f"layer {i} channels", sorted(ids), sorted(c.id.value for c in a.channel_info)) same(f"layer {i} rectangle", rect(b), rect(a)) same(f"layer {i} names", (b.name, blocks(b.tagged_blocks).get(b"luni")), (a.name, blocks(a.tagged_blocks).get(b"luni"))) same(f"layer {i} opacity", b.opacity, a.opacity) @@ -1292,7 +1310,7 @@ def main(): found = check(name, psd, w, h, dpi, layers) if psd.version != (2 if fmt else 1): found.append(f"{name}: version {psd.version}") - if compression >= MINIPSD_ZIP and compressions(psd) != {Compression(compression)}: + if compression >= MINIPSD_ZIP and compressions(psd) != {Compression(MARKER[compression])}: found.append(f"{name}: layer channels compressed as {compressions(psd)}") errors += found print(f"{name}: {'FAILED' if found else 'ok'}") @@ -1314,7 +1332,7 @@ def main(): path = out / f"{name}.psd" rc, expected = deep(lib, path, compression) found = [f"{name}: minipsd_write returned {rc}"] if rc != MINIPSD_OK else check_deep(PSDImage.open(path), expected) - if rc == MINIPSD_OK and compression and compressions(PSDImage.open(path)) != {Compression(compression)}: + if rc == MINIPSD_OK and compression >= MINIPSD_ZIP and compressions(PSDImage.open(path)) != {Compression(MARKER[compression])}: found.append(f"{name}: layer channels compressed as {compressions(PSDImage.open(path))}") errors += found print(f"{name}: {'FAILED' if found else 'ok'}") diff --git a/tests/support.h b/tests/support.h index b96f592..4544b70 100644 --- a/tests/support.h +++ b/tests/support.h @@ -97,6 +97,9 @@ Bytes unpack(Reader &r, std::size_t packed, std::size_t expected) return out; } +// The compression markers a file stores (the format's numbers, not the API's MINIPSD_*). +constexpr int RAW_MARKER = 0, RLE_MARKER = 1, ZIP_MARKER = 2, ZIP_PREDICTED_MARKER = 3; + // One channel's rows (raw or RLE, counts read by the caller), scattered into interleaved RGBA. void readPlane(Reader &r, int compression, const std::vector &counts, std::uint32_t w, std::uint32_t h, Bytes &rgba, int offset) @@ -232,6 +235,7 @@ minipsd_doc docOf(std::uint32_t w, std::uint32_t h, const minipsd_layer *layers, { minipsd_doc d{}; d.width = w; d.height = h; d.dpi = 300.0; d.layers = layers; d.layer_count = n; + d.composite_alpha = n != 0; // with layers: composed with their transparency, as most tests want return d; } diff --git a/tests/test_golden.cpp b/tests/test_golden.cpp index bdcc861..b19ed08 100644 --- a/tests/test_golden.cpp +++ b/tests/test_golden.cpp @@ -136,8 +136,10 @@ TEST(MinipsdGoldenTest, OutputMatchesTheBytesOf010) for (const Case &t : cases) { SCOPED_TRACE(t.golden.name); + minipsd_doc doc = t.doc; + doc.composite_alpha = 1; // 0.1.0 always composed the composite with transparency Bytes out; - const int rc = minipsd_write(&t.doc, nullptr, collect, &out); + const int rc = minipsd_write(&doc, nullptr, collect, &out); ASSERT_EQ(rc, MINIPSD_OK) << minipsd_error_string(rc); EXPECT_EQ(sha256(out), t.golden.sha256) << "the bytes of '" << t.golden.name << "' changed"; // encode(decode(x)) == x, through minipsd_read; the file's buffer is freed before writing. @@ -153,9 +155,9 @@ TEST(MinipsdGoldenTest, OutputMatchesTheBytesOf010) ASSERT_EQ(minipsd_read(out.data(), out.size(), nullptr, &stored, &back, nullptr), MINIPSD_OK); EXPECT_EQ(write(*back), out) << "channels copied as stored: the same bytes"; minipsd_doc_free(back); - EXPECT_EQ(pullTotal(t.doc), out.size()) << "the pull writer knows the size before writing"; + EXPECT_EQ(pullTotal(doc), out.size()) << "the pull writer knows the size before writing"; for (std::size_t cap : {std::size_t(1), std::size_t(7), std::size_t(4096), out.size(), std::size_t(0)}) - EXPECT_EQ(pull(t.doc, cap), out) << "pulled " << (cap ? std::to_string(cap) : "random") << " bytes at a time"; + EXPECT_EQ(pull(doc, cap), out) << "pulled " << (cap ? std::to_string(cap) : "random") << " bytes at a time"; } } diff --git a/tests/test_reader.cpp b/tests/test_reader.cpp index 9a3a5c1..86ac883 100644 --- a/tests/test_reader.cpp +++ b/tests/test_reader.cpp @@ -2423,24 +2423,33 @@ TEST(MinipsdReadTest, ItRefusesWhatTheReaderRefusesAndWhatItCannotHold) s.count = 1; readDoc(build(s).bytes, &lost, MINIPSD_ERR_UNSUPPORTED); - // A layer the writer could not take back: a side above MINIPSD_MAX_SIDE, wide or tall. - for (const bool wide : {true, false}) { + // A layer or a mask the writer could not take back: a side above MINIPSD_MAX_SIDE, wide or tall. + for (const bool wide : {true, false}) + for (const bool masked : {false, true}) { + SCOPED_TRACE(testing::Message() << "wide " << wide << ", masked " << masked); Spec big; Rec r = simpleRec(); - (wide ? r.right : r.bottom) = MINIPSD_MAX_SIDE + 1; + if (masked) { + const std::uint32_t right = wide ? MINIPSD_MAX_SIDE + 1 : 1, bottom = wide ? 1 : MINIPSD_MAX_SIDE + 1; + r.mask = Out().u32(0).u32(0).u32(bottom).u32(right).u8(0).u8(0).zeros(2).b; + r.channels.push_back(chan(-2, 6, 0)); + } else { + (wide ? r.right : r.bottom) = MINIPSD_MAX_SIDE + 1; + } big.layers = {r}; big.count = -1; big.image = rawImage(4, 2, 2, 0); readDoc(build(big).bytes, &lost, MINIPSD_ERR_UNSUPPORTED); } - // A flat file over the limit: its image counts once as the composite, once as its layer. + // A flat file over the limit: its image counts once as the composite, once as its layer, each + // R, G, B: it has no transparency. Spec flat; flat.channels = 3; flat.count = 0; flat.image = rawImage(3, 2, 2, 0); minipsd_limits small{}; - small.max_decoded_bytes = 2 * 2 * 4 * 2 - 1; + small.max_decoded_bytes = 2 * 2 * 3 * 2 - 1; readDoc(build(flat).bytes, &lost, MINIPSD_ERR_LIMIT, &small); // A file cut inside its layer tail: everything before it is read, then the composite is short. @@ -2636,9 +2645,17 @@ TEST(MinipsdReadTest, AFlatFileBecomesOneBackgroundLayer) EXPECT_DOUBLE_EQ(d->dpi, 72.0) << "no resolution stored"; ASSERT_EQ(d->layer_count, 1u); EXPECT_STREQ(d->layers[0].name, "Background"); - EXPECT_EQ(Bytes(d->layers[0].pixels, d->layers[0].pixels + 16), (Bytes{0x40, 0x40, 0x40, 255, 0x40, 0x40, 0x40, 255, - 0x40, 0x40, 0x40, 255, 0x40, 0x40, 0x40, 255})); + EXPECT_EQ(d->layers[0].no_alpha, 1) << "the file has no transparency, nor has its layer"; + EXPECT_EQ(Bytes(d->layers[0].pixels, d->layers[0].pixels + 12), Bytes(12, 0x40)) << "R, G, B"; + EXPECT_EQ(d->composite_alpha, 0); EXPECT_EQ(minipsd_doc_check(d.get(), nullptr), MINIPSD_OK) << "the writer takes it as it is"; + { // written back: still without transparency, the layer and the composite R, G, B + const Psd p = read(write(*d)); + EXPECT_EQ(p.channels, 3) << "the composite"; + EXPECT_EQ(p.rawLayerCount, 1) << "positive: no transparency"; + ASSERT_EQ(p.layers.size(), 1u); + EXPECT_EQ(p.layers[0].channelIds, (std::vector{0, 1, 2})) << "no channel -1"; + } // Stored: flat as the file is, no layer, its image the composite, copied. With a layer and // mask section or without one (its length 0), it is written with minipsd's empty one. @@ -2664,8 +2681,8 @@ TEST(MinipsdReadTest, AFlatFileBecomesOneBackgroundLayer) } } -// A flat 16-bit file: its one layer decoded at 16 bits, opaque as 65535. -TEST(MinipsdReadTest, AFlat16BitFileIsOpaqueAt65535) +// A flat 16-bit file: its one layer decoded at 16 bits, R, G, B without transparency. +TEST(MinipsdReadTest, AFlat16BitFileDecodesAt16BitsWithoutTransparency) { Spec s; s.channels = 3; @@ -2675,10 +2692,10 @@ TEST(MinipsdReadTest, AFlat16BitFileIsOpaqueAt65535) const Doc d = readDoc(build(s).bytes, nullptr); ASSERT_TRUE(d); EXPECT_EQ(d->depth, 16); - std::uint16_t px[4]; + EXPECT_EQ(d->layers[0].no_alpha, 1); + std::uint16_t px[3 * 4]; // four pixels of R, G, B std::memcpy(px, d->layers[0].pixels, sizeof px); - EXPECT_EQ(px[0], 0x4040); - EXPECT_EQ(px[3], 65535); + for (const std::uint16_t v : px) EXPECT_EQ(v, 0x4040); } // Modes beyond 8- and 16-bit RGB come back as written: a 32-bit document's layers in 'Lr32', its @@ -2707,6 +2724,104 @@ TEST(MinipsdReadTest, EveryModeComesBackAsWritten) ASSERT_TRUE(g); EXPECT_EQ(write(*g), again) << "a second pass changes no byte"; + // Where the layer count cannot say the composite has transparency ('Lr32' holds a positive one; + // a document with no layers has 0), the writer names the channel as Photoshop does, which GIMP + // reads it by: 1006, 1045 and 1053, once, kept as they are on the next pass; a document with no + // layers also gets an empty 'Mtrn' ('Mt16', 'Mt32') after its tail, which the reader honours. + const Bytes pascal = {12, 'T', 'r', 'a', 'n', 's', 'p', 'a', 'r', 'e', 'n', 'c', 'y'}; + const auto names = [&](const Bytes &file) { + Open o(file); + EXPECT_EQ(o.rc, MINIPSD_OK); + std::vector ids; + minipsd_resource res{}; + for (std::size_t at = 0; minipsd_reader_next_resource(o.r, &at, &res) == MINIPSD_MORE;) + if (res.id == MINIPSD_RES_ALPHA_NAMES || res.id == MINIPSD_RES_UNICODE_ALPHA_NAMES || res.id == MINIPSD_RES_ALPHA_IDENTIFIERS) { + ids.push_back(res.id); + if (res.id == MINIPSD_RES_ALPHA_NAMES) { EXPECT_EQ(Bytes(res.data.data, res.data.data + res.data.size), pascal); } + } + EXPECT_NE(o.info.composite_has_alpha, 0); + return ids; + }; + const std::vector photoshops = {MINIPSD_RES_ALPHA_NAMES, MINIPSD_RES_UNICODE_ALPHA_NAMES, MINIPSD_RES_ALPHA_IDENTIFIERS}; + EXPECT_EQ(names(floats), photoshops) << "'Lr32'"; + EXPECT_EQ(names(again), photoshops) << "kept, not written twice"; + const std::vector greyAlpha = {10, 255, 20, 128}; // 2 x 1, grey and transparency + minipsd_doc flat = docOf(2, 1, nullptr, 0); + flat.color_mode = MINIPSD_MODE_GREY; + flat.composite = greyAlpha.data(); + flat.composite_alpha = 1; + const Bytes noLayers = write(flat); + EXPECT_EQ(names(noLayers), photoshops) << "no layers"; + const Bytes mtrn = {'8', 'B', 'I', 'M', 'M', 't', 'r', 'n', 0, 0, 0, 0}; + const auto block = std::search(noLayers.begin(), noLayers.end(), mtrn.begin(), mtrn.end()); + ASSERT_NE(block, noLayers.end()) << "an empty 'Mtrn'"; + EXPECT_EQ(std::search(block + 1, noLayers.end(), mtrn.begin(), mtrn.end()), noLayers.end()) << "one"; + const Doc h = readDoc(noLayers, nullptr); + ASSERT_TRUE(h); + const Bytes once = write(*h); + const Doc k = readDoc(once, nullptr); + ASSERT_TRUE(k); + EXPECT_EQ(write(*k), once) << "a second pass changes no byte"; + EXPECT_EQ(names(once), photoshops) << "read and written: kept, not written twice"; + const Bytes other = {7, 'O', 'u', 'r', ' ', 'o', 'w', 'n'}; + const minipsd_resource own{MINIPSD_RES_ALPHA_NAMES, {other.data(), other.size()}, {}}; + flat.resources = &own; flat.resource_count = 1; + { + Open o(write(flat)); + minipsd_resource res{}; + ASSERT_EQ(minipsd_reader_find_resource(o.r, MINIPSD_RES_ALPHA_NAMES, &res), MINIPSD_OK); + EXPECT_EQ(Bytes(res.data.data, res.data.data + res.data.size), other) << "the document's own names win"; + EXPECT_EQ(minipsd_reader_find_resource(o.r, MINIPSD_RES_ALPHA_IDENTIFIERS, &res), MINIPSD_ERR_NOT_FOUND) << "none of the writer's"; + } + + // Without transparency (composite_alpha 0): the mode's channels only, nothing named, no 'Mtrn', + // as Photoshop saves an image without layers. Read, its one layer has none either; stored, it + // is written back the same bytes. + const std::vector grey = {10, 20}; // 2 x 1, grey only + minipsd_doc plain = docOf(2, 1, nullptr, 0); + plain.color_mode = MINIPSD_MODE_GREY; + plain.composite = grey.data(); + const Bytes noAlpha = write(plain); + { + Open o(noAlpha); + ASSERT_EQ(o.rc, MINIPSD_OK); + EXPECT_EQ(o.info.channels, 1); + EXPECT_EQ(o.info.composite_has_alpha, 0); + minipsd_resource res{}; + EXPECT_EQ(minipsd_reader_find_resource(o.r, MINIPSD_RES_ALPHA_NAMES, &res), MINIPSD_ERR_NOT_FOUND) << "nothing to name"; + } + EXPECT_EQ(std::search(noAlpha.begin(), noAlpha.end(), mtrn.begin(), mtrn.end()), noAlpha.end()) << "no 'Mtrn'"; + const Doc pd = readDoc(noAlpha, nullptr); + ASSERT_TRUE(pd); + EXPECT_EQ(pd->composite_alpha, 0); + ASSERT_EQ(pd->layer_count, 1u); + EXPECT_EQ(pd->layers[0].no_alpha, 1); + EXPECT_EQ(Bytes(pd->layers[0].pixels, pd->layers[0].pixels + grey.size()), Bytes(grey.begin(), grey.end())); + const minipsd_read_opts keep{1}; + const Doc ps = readDoc(noAlpha, nullptr, MINIPSD_OK, nullptr, &keep); + ASSERT_TRUE(ps); + EXPECT_EQ(write(*ps), noAlpha) << "stored: the same bytes"; + + // A layer without transparency (no_alpha), the composite composed without it: R, G, B in both, + // the count positive; read back without it, and a second pass the same bytes. + const Bytes rgb = {200, 100, 50, 10, 20, 30}; // 2 x 1 + const minipsd_layer bg{.name = "Background", .pixels = rgb.data(), .opacity = 255, .no_alpha = 1}; + minipsd_doc layered = docOf(2, 1, &bg, 1); + layered.composite_alpha = 0; + const Bytes opaqueLayer = write(layered); + const Psd q = read(opaqueLayer); + EXPECT_EQ(q.channels, 3); + EXPECT_EQ(q.rawLayerCount, 1); + ASSERT_EQ(q.layers.size(), 1u); + EXPECT_EQ(q.layers[0].channelIds, (std::vector{0, 1, 2})) << "no channel -1"; + for (std::size_t i = 0; i < 2; ++i) + for (std::size_t c = 0; c < 3; ++c) EXPECT_EQ(q.composite[i * 4 + c], rgb[i * 3 + c]) << "opaque: composed as given"; + const Doc lb = readDoc(opaqueLayer, nullptr); + ASSERT_TRUE(lb); + EXPECT_EQ(lb->layers[0].no_alpha, 1); + EXPECT_EQ(Bytes(lb->layers[0].pixels, lb->layers[0].pixels + rgb.size()), rgb); + EXPECT_EQ(write(*lb), opaqueLayer) << "read and written: the same bytes"; + const Bytes bits = {0, 1, 0, 0, 7, 0, 0, 0, 255, 1}; // 10 x 1: 0 white, any other black minipsd_doc b = docOf(10, 1, nullptr, 0); b.depth = 1; @@ -2828,6 +2943,17 @@ TEST(MinipsdReaderTest, MaskAndKindAtTheEdges) const minipsd_plane p{plane.data(), 1, 2}; EXPECT_EQ(minipsd_decode_mask(b.o.r, &b.v, &p, nullptr), MINIPSD_ERR_FORMAT); + // A mask's rectangle is the file's: a side above either format's is refused before its plane + // is sized. 2^31 x 2^31 at 32 bits is a plane of 2^64 bytes, which wrapped to 0 and passed. + constexpr std::int32_t half = std::int32_t(1) << 30; + Rec wrapping = simpleRec(); + wrapping.mask = maskData(-half, -half, half, half); + wrapping.channels.push_back(chan(-2, 6, 0)); + OneLayer c(wrapping, 32); + minipsd_decode_opts deep{}; + deep.depth = 32; + EXPECT_EQ(minipsd_decode_mask(c.o.r, &c.v, &p, &deep), MINIPSD_ERR_UNSUPPORTED); + auto lsct = [](std::uint32_t type) { return block("lsct", Out().u32(type).b); }; Rec unknown = simpleRec(); unknown.blocks = Out().raw(block("lsct", {})).raw(lsct(7)).b; @@ -3171,9 +3297,31 @@ void expectConvertsBack(const minipsd_doc &d) EXPECT_EQ(write(*c, MINIPSD_OK, &back), as) << "into the other format and back: the same bytes"; } +// A document's pixels of `planes` samples, `bytes` each, as RGBA: opaque where they have no +// transparency, as the reader's RGBA targets decode a file without it. +Bytes asRgba(const std::uint8_t *p, std::size_t count, unsigned planes, std::size_t bytes) +{ + Bytes out(count * 4 * bytes, 0xFF); + for (std::size_t i = 0; i < count; ++i) + std::copy(p + i * planes * bytes, p + (i + 1) * planes * bytes, out.begin() + static_cast(i * 4 * bytes)); + return out; +} + +// A document's composite as RGBA: its colour, and its transparency when it has one. +Bytes compositeOf(const minipsd_doc &d, std::size_t bytes) +{ + return asRgba(d.composite, std::size_t(d.width) * d.height, 3u + d.composite_alpha, bytes); +} + +// A layer's pixels as RGBA. +Bytes pixelsOf(const minipsd_layer &l, std::size_t bytes) +{ + return asRgba(l.pixels, std::size_t(l.right - l.left) * std::size_t(l.bottom - l.top), 3u + !l.no_alpha, bytes); +} + // A document read with stored: its composite copied as the file ends with it, not decoded, when // it is R, G, B and at most transparency; otherwise decoded, as stored (matted). -void expectStoredComposite(const minipsd_doc &s, const Bytes &file, std::size_t size, const Bytes &stored) +void expectStoredComposite(const minipsd_doc &s, const Bytes &file, std::size_t bytes, const Bytes &stored) { if (s.composite_stored.size) { EXPECT_EQ(s.composite, nullptr) << "copied, not decoded"; @@ -3181,7 +3329,7 @@ void expectStoredComposite(const minipsd_doc &s, const Bytes &file, std::size_t EXPECT_EQ(Bytes(s.composite_stored.data, s.composite_stored.data + s.composite_stored.size), Bytes(file.end() - static_cast(s.composite_stored.size), file.end())); } else { - EXPECT_EQ(Bytes(s.composite, s.composite + size), stored); + EXPECT_EQ(compositeOf(s, bytes), stored); } } @@ -3262,7 +3410,7 @@ TEST(MinipsdReaderTest, FilesFromOtherProgramsAreReadWholeAndInParts) ASSERT_TRUE(s); expectConvertsBack(*s); EXPECT_EQ(s->depth, depth); - expectStoredComposite(*s, file, composite.size(), stored); + expectStoredComposite(*s, file, bytes, stored); const Bytes again = write(*s); expectKeptAlike(file, again); const Doc f = readDoc(again, &lostAgain, MINIPSD_OK, nullptr, &keep); @@ -3288,7 +3436,7 @@ TEST(MinipsdReaderTest, FilesFromOtherProgramsAreReadWholeAndInParts) const Doc d = readDoc(file, &lost); ASSERT_TRUE(d); if (otherChannels) { EXPECT_NE(lost & MINIPSD_LOST_CHANNELS, 0u) << "lost " << lost; } - EXPECT_EQ(Bytes(d->composite, d->composite + composite.size()), stored); + EXPECT_EQ(compositeOf(*d, bytes), stored); EXPECT_EQ(d->depth, depth); const Bytes again = write(*d); std::uint32_t lostAgain = 99; @@ -3302,11 +3450,12 @@ TEST(MinipsdReaderTest, FilesFromOtherProgramsAreReadWholeAndInParts) EXPECT_STREQ(a.name, b.name); EXPECT_EQ(std::tie(a.opacity, a.hidden, a.top, a.left, a.bottom, a.right), std::tie(b.opacity, b.hidden, b.top, b.left, b.bottom, b.right)); - const std::size_t n = std::size_t(a.right - a.left) * std::size_t(a.bottom - a.top) * 4 * bytes; EXPECT_EQ(a.pixels == nullptr, b.pixels == nullptr); - if (a.pixels && b.pixels) { EXPECT_TRUE(std::equal(a.pixels, a.pixels + n, b.pixels)); } + EXPECT_EQ(a.no_alpha, b.no_alpha) << "without transparency, written and read back without"; + if (a.pixels && b.pixels) { EXPECT_EQ(pixelsOf(a, bytes), pixelsOf(b, bytes)); } } - EXPECT_TRUE(std::equal(d->composite, d->composite + composite.size(), e->composite)); + EXPECT_EQ(d->composite_alpha, e->composite_alpha); + EXPECT_EQ(compositeOf(*d, bytes), compositeOf(*e, bytes)); expectKeptAlike(file, again); // Stored: no layer decoded, yet the file written from it decodes to the same pixels. @@ -3314,17 +3463,16 @@ TEST(MinipsdReaderTest, FilesFromOtherProgramsAreReadWholeAndInParts) const Doc s = readDoc(file, &lost, MINIPSD_OK, nullptr, &keep); ASSERT_TRUE(s); expectConvertsBack(*s); - expectStoredComposite(*s, file, composite.size(), stored); + expectStoredComposite(*s, file, bytes, stored); const Doc f = readDoc(write(*s), &lostAgain); ASSERT_TRUE(f); - EXPECT_EQ(Bytes(f->composite, f->composite + composite.size()), stored) << "written back, the same composite"; + EXPECT_EQ(compositeOf(*f, bytes), stored) << "written back, the same composite"; ASSERT_EQ(f->layer_count, d->layer_count); for (std::size_t i = 0; i < d->layer_count; ++i) { const minipsd_layer &a = d->layers[i], &b = f->layers[i]; SCOPED_TRACE(i); - const std::size_t n = std::size_t(a.right - a.left) * std::size_t(a.bottom - a.top) * 4 * bytes; EXPECT_EQ(a.pixels == nullptr, b.pixels == nullptr); - if (a.pixels && b.pixels) { EXPECT_TRUE(std::equal(a.pixels, a.pixels + n, b.pixels)); } + if (a.pixels && b.pixels) { EXPECT_EQ(pixelsOf(a, bytes), pixelsOf(b, bytes)); } } } diff --git a/tests/test_writer.cpp b/tests/test_writer.cpp index 8cee7f8..b7ecc1a 100644 --- a/tests/test_writer.cpp +++ b/tests/test_writer.cpp @@ -28,6 +28,7 @@ static std::set encodedLayers; #include #include +#include extern "C" int minipsd_c_smoke(unsigned char *out, size_t capacity, size_t *size); // minipsd_c_smoke.c @@ -164,6 +165,47 @@ TEST(MinipsdWriterTest, ASuppliedCompositeIsMattedAndWrittenWithoutComposing) EXPECT_EQ(pullTotal(d), write(d).size()); EXPECT_EQ(composedRows, 0); + // The caller's own encoding, each row matted (minipsd_matte_row) then each channel's rows + // encoded (minipsd_encode_row), given as stored: the bytes minipsd writes from the pixels. + if (compression == MINIPSD_RLE) { + minipsd_doc own = d; + own.composite_matted = 0; + const Bytes fromPixels = write(own); + Bytes px2 = other; + for (std::uint32_t y = 0; y < 5; ++y) + ASSERT_EQ(minipsd_matte_row(MINIPSD_MODE_RGB, 8, px2.data() + std::size_t(y) * 37 * 4, 37), MINIPSD_OK); + EXPECT_EQ(px2, matted(other)) << "matted as the writer mattes"; + Bytes counts{0, 1}, rows; // PackBits: the marker, every channel's row sizes, the rows + Bytes row(MINIPSD_ROW_BOUND(37, 8)); + for (std::size_t k = 0; k < 4; ++k) + for (std::uint32_t y = 0; y < 5; ++y) { + std::size_t n = 0; + ASSERT_EQ(minipsd_encode_row(MINIPSD_RLE, 8, px2.data() + std::size_t(y) * 37 * 4 + k, 4, 37, row.data(), row.size(), &n), + MINIPSD_OK); + counts.push_back(static_cast(n >> 8)); + counts.push_back(static_cast(n)); + rows.insert(rows.end(), row.begin(), row.begin() + static_cast(n)); + } + Bytes block = counts; + block.insert(block.end(), rows.begin(), rows.end()); + own.composite = nullptr; + own.composite_stored = {block.data(), block.size()}; + own.composite_channels = 4; + EXPECT_EQ(write(own), fromPixels) << "encoded by the caller, the same bytes"; + + std::size_t n = 0; + EXPECT_EQ(minipsd_encode_row(MINIPSD_RLE, 8, px2.data(), 4, 37, row.data(), 1, &n), MINIPSD_ERR_SIZE); + EXPECT_GT(n, 1u) << "the bytes it needs"; + EXPECT_EQ(minipsd_encode_row(MINIPSD_ZIP, 8, px2.data(), 4, 37, row.data(), row.size(), &n), MINIPSD_ERR_ARG); + EXPECT_EQ(minipsd_encode_row(MINIPSD_RLE, 12, px2.data(), 4, 37, row.data(), row.size(), &n), MINIPSD_ERR_ARG); + ASSERT_EQ(minipsd_encode_row(MINIPSD_RAW, 8, px2.data(), 4, 37, row.data(), row.size(), &n), MINIPSD_OK); + EXPECT_EQ(n, 37u) << "raw: the samples"; + Bytes cmyk = other; + EXPECT_EQ(minipsd_matte_row(MINIPSD_MODE_CMYK, 8, cmyk.data(), 37), MINIPSD_OK); + EXPECT_EQ(cmyk, other) << "only RGB is matted"; + EXPECT_EQ(minipsd_matte_row(MINIPSD_MODE_RGB, 1, cmyk.data(), 37), MINIPSD_ERR_ARG); + } + // Stored: R, G, B as a file stores them (raw), copied, never encoded; three channels, the // layer count positive. A supplied composite wins over it. Bytes planes{0, 0}; @@ -173,7 +215,8 @@ TEST(MinipsdWriterTest, ASuppliedCompositeIsMattedAndWrittenWithoutComposing) d.composite_stored = {planes.data(), planes.size()}; d.composite_channels = 3; EXPECT_EQ(write(d), withComposite) << "the supplied composite wins"; - d.composite = nullptr; + d.composite = nullptr; + d.composite_alpha = 0; // what composite_alpha says is now the stored one's: R, G, B const Bytes copied = write(d); EXPECT_EQ(composedRows, 0) << "nothing composed"; EXPECT_EQ(Bytes(copied.end() - static_cast(planes.size()), copied.end()), planes) << "copied as it is"; @@ -575,9 +618,10 @@ TEST(MinipsdWriterTest, SixteenBitDocumentsAreWrittenAndReadBack) ASSERT_EQ(info.layer_count, 2u); minipsd_layer_view v; ASSERT_EQ(minipsd_reader_next_layer(r, &v), MINIPSD_MORE); - if (f == file && compression >= MINIPSD_ZIP) { // RLE and RAW markers are 1 and 0 - EXPECT_EQ(v.channel[0].data[1], compression) << "the layer's marker"; - EXPECT_EQ(v.mask.stored.data[1], compression) << "the mask's"; + if (f == file && compression >= MINIPSD_ZIP) { // the file's markers: 2 ZIP, 3 predicted + const int marker = compression == MINIPSD_ZIP ? ZIP_MARKER : ZIP_PREDICTED_MARKER; + EXPECT_EQ(v.channel[0].data[1], marker) << "the layer's marker"; + EXPECT_EQ(v.mask.stored.data[1], marker) << "the mask's"; } std::vector got(px.size()), gotMask(ramp.size()); minipsd_target t; @@ -607,7 +651,7 @@ TEST(MinipsdWriterTest, SixteenBitDocumentsAreWrittenAndReadBack) check(file); // As a document, decoded or stored: the same bytes. A stored layer without transparency - // gets an opaque one. + // reads back without one: R, G, B. const minipsd_read_opts inflating{0, zlib_stored_inflate, nullptr}; for (int keep : {0, 1}) { const minipsd_read_opts opts{keep, zlib_stored_inflate, nullptr}; @@ -629,8 +673,10 @@ TEST(MinipsdWriterTest, SixteenBitDocumentsAreWrittenAndReadBack) minipsd_doc *opaque = nullptr; const Bytes f = write(e, MINIPSD_OK, &zipped); ASSERT_EQ(minipsd_read(f.data(), f.size(), nullptr, &inflating, &opaque, nullptr), MINIPSD_OK); + EXPECT_EQ(opaque->layers[0].no_alpha, 1); const auto *got = reinterpret_cast(opaque->layers[0].pixels); - for (std::size_t i = 0; i < px.size(); ++i) ASSERT_EQ(got[i], i % 4 == 3 ? 65535 : px[i]) << i; + for (std::size_t i = 0; i < px.size() / 4; ++i) + for (std::size_t k = 0; k < 3; ++k) ASSERT_EQ(got[i * 3 + k], px[i * 4 + k]) << i; minipsd_doc_free(opaque); } minipsd_doc_free(back); @@ -1086,7 +1132,8 @@ TEST(MinipsdWriterTest, RefusesBeforeWritingAnything) d = docOf(2, 2, nullptr, 1); EXPECT_EQ(minipsd_doc_check(&d, nullptr), MINIPSD_ERR_ARG) << "a layer count, no layers"; d = docOf(2, 2, &layer, 0); - d.composite = px.data(); + d.composite = px.data(); + d.composite_alpha = 1; // RGBA EXPECT_EQ(minipsd_doc_check(&d, nullptr), MINIPSD_OK) << "no layers, a composite: flat"; { const Psd flat = read(write(d)); @@ -1118,6 +1165,32 @@ TEST(MinipsdWriterTest, RefusesBeforeWritingAnything) EXPECT_EQ(minipsd_writer_init(&w, &d, nullptr, workspace.data(), size), MINIPSD_ERR_SIZE) << "init sizes it too"; EXPECT_EQ(w, nullptr) << "no writer for a refused document"; + // A PSD counts an RLE row's bytes in 16 bits: a 32-bit row of noise wider than about 16 380 px + // does not fit, the layer's or the composite's. A PSB counts in 32. + { + constexpr std::uint32_t wide = 16400, samples = wide * 4; + std::vector noise(samples), zeros(samples); // zeros: a composite that packs, every byte 0 + std::uint32_t seed = 1; + for (float &f : noise) f = float((seed = seed * 1664525u + 1013904223u) >> 8) / float(1u << 24); + minipsd_layer l{"N", reinterpret_cast(noise.data()), 255, 0}; + l.right = wide; l.bottom = 1; + minipsd_doc deep = docOf(wide, 1, &l, 1); + deep.depth = 32; + deep.composite = reinterpret_cast(zeros.data()); // at 32 bits supplied, never composed + deep.composite_alpha = 0; // with it, matted 1.0f: 3F 80 00 00 again and again, which PackBits cannot pack + EXPECT_EQ(minipsd_write(&deep, nullptr, collect, &out), MINIPSD_ERR_SIZE) << "the layer's rows"; + minipsd_layer raw = l; + raw.compression = MINIPSD_RAW; + deep.layers = &raw; + deep.composite = l.pixels; + EXPECT_EQ(minipsd_write(&deep, nullptr, collect, &out), MINIPSD_ERR_SIZE) << "the composite's rows"; + minipsd_write_opts psb{}; + psb.format = MINIPSD_PSB; + Bytes big; + deep.layers = &l; + EXPECT_EQ(minipsd_write(&deep, &psb, collect, &big), MINIPSD_OK) << "a PSB holds them"; + } + EXPECT_TRUE(out.empty()) << "nothing is written for a refused document"; } @@ -1236,18 +1309,32 @@ TEST(MinipsdWriterTest, WriteFailureIsReported) const Bytes px = testImage(128, 64); // several 4 KiB callbacks const minipsd_layer layer{"L", px.data(), 255, 0}; const minipsd_doc d = docOf(128, 64, &layer, 1); - struct Calls { int made, failAt; }; - const auto failing = [](void *user, const void *, size_t) -> int { + struct Calls { int made, failAt; size_t largest; }; + const auto failing = [](void *user, const void *, size_t size) -> int { auto *c = static_cast(user); + c->largest = std::max(c->largest, size); return ++c->made != c->failAt; }; - Calls all{0, 0}; // never fails + Calls all{0, 0, 0}; // never fails ASSERT_EQ(minipsd_write(&d, nullptr, failing, &all), MINIPSD_OK); ASSERT_GE(all.made, 3); + EXPECT_LE(all.largest, size_t(MPSD__CHUNK)) << "pieces of 4 KiB: no row is wider"; + + // A mask wider than the canvas and every layer: the pieces grow to hold its row. + constexpr uint32_t kWide = 5000; + const Bytes wide(kWide, 0x80); + const minipsd_mask mask{.bottom = 1, .right = static_cast(kWide), .default_color = 255, .pixels = wide.data()}; + minipsd_layer masked = layer; + masked.mask = &mask; + const minipsd_doc withMask = docOf(128, 64, &masked, 1); + Calls widest{0, 0, 0}; + ASSERT_EQ(minipsd_write(&withMask, nullptr, failing, &widest), MINIPSD_OK); + EXPECT_GT(widest.largest, size_t(MPSD__CHUNK)) << "the mask's row"; + EXPECT_LE(widest.largest, size_t(MINIPSD_ROW_BOUND(kWide, mpsd__doc_depth(&withMask)))); // The first piece, one in the middle, and the last. for (int failAt : {1, 2, all.made}) { - Calls c{0, failAt}; + Calls c{0, failAt, 0}; EXPECT_EQ(minipsd_write(&d, nullptr, failing, &c), MINIPSD_ERR_WRITE) << "failing call " << failAt; EXPECT_EQ(c.made, failAt) << "the writer stops calling back after the first failure"; } @@ -1283,19 +1370,94 @@ TEST(MinipsdWriterTest, ZipChannelsGoThroughTheCallersDeflate) ASSERT_EQ(minipsd_reader_init(&r, file.data(), file.size(), workspace.data(), workspace.size(), nullptr, &info), MINIPSD_OK); minipsd_layer_view v; ASSERT_EQ(minipsd_reader_next_layer(r, &v), MINIPSD_MORE); - EXPECT_EQ(v.channel[MINIPSD_CH_R].data[1], MINIPSD_ZIP_PREDICTED); + EXPECT_EQ(v.channel[MINIPSD_CH_R].data[1], ZIP_PREDICTED_MARKER); minipsd_target t; minipsd_target_rgba(&t, out.data(), std::size_t(w) * 4); const minipsd_decode_opts zip{0, 0, 0, 8, zlib_stored_inflate, nullptr, scratch.data(), scratch.size()}; ASSERT_EQ(minipsd_decode_layer(r, &v, &t, &zip), MINIPSD_OK); EXPECT_EQ(out, px); - // Refused: no deflate, a compression that is none of the four. + // A layer's own compression: ZIP in an RLE document, and RLE in a ZIP one; the composite RLE + // in both, the deflate asked for whenever a layer deflates. + { + minipsd_layer zipped = layer; + zipped.compression = MINIPSD_ZIP; + minipsd_doc rle = docOf(w, h, &zipped, 1); + EXPECT_EQ(minipsd_doc_check(&rle, nullptr), MINIPSD_ERR_ARG) << "a ZIP layer without deflate"; + const minipsd_write_opts stored{0, zlib_stored_compress, nullptr}; + const Bytes zf = write(rle, MINIPSD_OK, &stored); + Bytes zws(256); + minipsd_reader *zr = nullptr; + minipsd_info zi{}; + ASSERT_EQ(minipsd_reader_init(&zr, zf.data(), zf.size(), zws.data(), zws.size(), nullptr, &zi), MINIPSD_OK); + minipsd_layer_view zv; + ASSERT_EQ(minipsd_reader_next_layer(zr, &zv), MINIPSD_MORE); + for (int k = 0; k < 4; ++k) EXPECT_EQ(zv.channel[k].data[1], ZIP_MARKER) << "the layer's own, channel " << k; + minipsd_layer plain = layer; + plain.compression = MINIPSD_RLE; + minipsd_doc zipDoc = docOf(w, h, &plain, 1); + zipDoc.compression = MINIPSD_ZIP; + const Psd r2 = read(write(zipDoc, MINIPSD_OK, &stored)); + EXPECT_EQ(r2.layers[0].compression, (std::vector(4, RLE_MARKER))) << "the layer's own, in a ZIP document"; + EXPECT_EQ(r2.layers[0].pixels, px); + EXPECT_EQ(r2.compositeCompression, RLE_MARKER) << "the composite: RLE in a ZIP document"; + } + + // minipsd_predict_row: the rows the writer deflates for ZIP_PREDICTED, at each depth (shuffled + // at 32); after the marker, deflated, the channel as the file has it. + for (const int depth : {8, 16, 32}) { + SCOPED_TRACE(depth); + const std::size_t sample = std::size_t(depth) / 8, pixel = 4 * sample, row = std::size_t(w) * sample; + Bytes pixels(std::size_t(w) * h * pixel); + for (std::size_t i = 0; i < std::size_t(w) * h * 4; ++i) { // a sample each, as its depth holds it + const std::uint16_t s16 = std::uint16_t(i * 997); + const float s32 = float(i % 101) / 100.0f; + std::memcpy(pixels.data() + i * sample, depth == 8 ? static_cast(&px[i % px.size()]) + : depth == 16 ? static_cast(&s16) : static_cast(&s32), + sample); + } + minipsd_layer l{"P", pixels.data(), 255, 0}; + minipsd_doc pd = docOf(w, h, &l, 1); + pd.depth = std::uint16_t(depth); + pd.composite = pixels.data(); // at 32 bits supplied, never composed + pd.compression = MINIPSD_ZIP_PREDICTED; + const minipsd_write_opts stored{0, zlib_stored_compress, nullptr}; + const Bytes pf = write(pd, MINIPSD_OK, &stored); + Bytes pws(256); + minipsd_reader *pr = nullptr; + minipsd_info pi{}; + ASSERT_EQ(minipsd_reader_init(&pr, pf.data(), pf.size(), pws.data(), pws.size(), nullptr, &pi), MINIPSD_OK); + minipsd_layer_view pv; + ASSERT_EQ(minipsd_reader_next_layer(pr, &pv), MINIPSD_MORE); + Bytes rows(row * h), stream(rows.size() * 2 + 64); + for (std::uint32_t y = 0; y < h; ++y) // red: the pixel's first sample + ASSERT_EQ(minipsd_predict_row(depth, pixels.data() + std::size_t(y) * w * pixel, pixel, w, rows.data() + y * row, row), MINIPSD_OK); + std::size_t n = stream.size(); + ASSERT_EQ(zlib_stored_compress(nullptr, rows.data(), rows.size(), stream.data(), &n), 0); + Bytes want{0, ZIP_PREDICTED_MARKER}; + want.insert(want.end(), stream.begin(), stream.begin() + std::ptrdiff_t(n)); + EXPECT_EQ(Bytes(pv.channel[MINIPSD_CH_R].data, pv.channel[MINIPSD_CH_R].data + pv.channel[MINIPSD_CH_R].size), want); + } + { + std::uint8_t one[4] = {}; + EXPECT_EQ(minipsd_predict_row(8, px.data(), 4, 4, one, sizeof one), MINIPSD_OK); + EXPECT_EQ(minipsd_predict_row(8, px.data(), 4, 4, one, sizeof one - 1), MINIPSD_ERR_SIZE) << "a byte short"; + EXPECT_EQ(minipsd_predict_row(1, px.data(), 1, 8, one, sizeof one), MINIPSD_ERR_ARG) << "bits are never predicted"; + EXPECT_EQ(minipsd_predict_row(12, px.data(), 4, 4, one, sizeof one), MINIPSD_ERR_ARG); + EXPECT_EQ(minipsd_predict_row(8, nullptr, 4, 4, one, sizeof one), MINIPSD_ERR_ARG); + EXPECT_EQ(minipsd_predict_row(8, px.data(), 4, 4, nullptr, sizeof one), MINIPSD_ERR_ARG); + } + + // Refused: no deflate, a compression that is none of the four, the document's or a layer's. EXPECT_EQ(minipsd_doc_check(&d, nullptr), MINIPSD_ERR_ARG); - for (int bad : {-1, 4}) { + for (int bad : {-1, MINIPSD_ZIP_PREDICTED + 1}) { minipsd_doc b = d; b.compression = bad; EXPECT_EQ(minipsd_doc_check(&b, &opts), MINIPSD_ERR_ARG) << bad; + minipsd_layer l = layer; + l.compression = bad; + b = docOf(w, h, &l, 1); + EXPECT_EQ(minipsd_doc_check(&b, &opts), MINIPSD_ERR_ARG) << "a layer's " << bad; } // A failing deflate, while sizing or while writing; one whose second stream is longer. diff --git a/tools/check_spdx.cmake b/tools/check_spdx.cmake new file mode 100644 index 0000000..6664e87 --- /dev/null +++ b/tools/check_spdx.cmake @@ -0,0 +1,37 @@ +# Checks an SPDX fragment against the files it describes: each listed file's SHA-256 as on disk, +# and with COMPLETE every file under BASE listed (but the fragment itself). +# +# cmake -DSPDX= -DBASE= [-DCOMPLETE=ON] -P tools/check_spdx.cmake +cmake_minimum_required(VERSION 3.21) + +if(NOT SPDX OR NOT BASE) + message(FATAL_ERROR "usage: cmake -DSPDX= -DBASE= [-DCOMPLETE=ON] -P tools/check_spdx.cmake") +endif() +file(READ "${SPDX}" _doc) +string(JSON _count LENGTH "${_doc}" files) +math(EXPR _last "${_count} - 1") +set(_listed "") +foreach(_i RANGE ${_last}) + string(JSON _name GET "${_doc}" files ${_i} fileName) + string(REGEX REPLACE "^\\./" "" _name "${_name}") + string(JSON _sha256 GET "${_doc}" files ${_i} checksums 1 checksumValue) + if(NOT EXISTS "${BASE}/${_name}") + message(FATAL_ERROR "${SPDX}: ${_name} is listed but missing") + endif() + file(SHA256 "${BASE}/${_name}" _actual) + if(NOT _actual STREQUAL _sha256) + message(FATAL_ERROR "${SPDX}: ${_name} differs from its checksum") + endif() + list(APPEND _listed "${_name}") +endforeach() +if(COMPLETE) + get_filename_component(_self "${SPDX}" ABSOLUTE) + file(GLOB_RECURSE _files RELATIVE "${BASE}" "${BASE}/*") + foreach(_file IN LISTS _files) + get_filename_component(_abs "${BASE}/${_file}" ABSOLUTE) + if(NOT _abs STREQUAL _self AND NOT _file IN_LIST _listed) + message(FATAL_ERROR "${SPDX}: ${_file} is not listed") + endif() + endforeach() +endif() +message(STATUS "${SPDX}: ${_count} files, checksums as on disk") diff --git a/tools/package_vips.cmake b/tools/package_vips.cmake new file mode 100644 index 0000000..e00a5af --- /dev/null +++ b/tools/package_vips.cmake @@ -0,0 +1,70 @@ +# Packs the libvips plugin as released: /minipsd-vips-.zip, a CMake project +# that builds as it is (libvips-plugin/release/README.md). Its tree: +# +# CMakeLists.txt, README.md, example.c libvips-plugin/release/ +# minipsd_vips.h, minipsd_vips.c the plugin's own: its API, its registration +# foreign/ libvips-plugin/foreign/: what libvips/foreign/ would hold +# foreign/minipsd/minipsd.h include/ joined by tools/amalgamate.cmake, carried as +# libvips carries libnsgif and qoi +# cmake/minipsd_libvips.cmake as the repository finds libvips +# LICENSE.MIT +# minipsd-vips.spdx.json the SBOM fragment: every other file, with its checksums +# +# cmake -DOUTPUT_DIR= -P tools/package_vips.cmake +cmake_minimum_required(VERSION 3.21) + +if(NOT OUTPUT_DIR) + message(FATAL_ERROR "usage: cmake -DOUTPUT_DIR= -P tools/package_vips.cmake") +endif() +get_filename_component(_root "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) +get_filename_component(_out "${OUTPUT_DIR}" ABSOLUTE) + +# The version, as the project reads it: from minipsd.h. +file(STRINGS "${_root}/include/minipsd.h" _lines REGEX "^#define MINIPSD_VERSION_(MAJOR|MINOR|PATCH) +[0-9]+$") +set(_version "") +foreach(_part MAJOR MINOR PATCH) + if(NOT _lines MATCHES "MINIPSD_VERSION_${_part} +([0-9]+)") + message(FATAL_ERROR "minipsd.h does not define MINIPSD_VERSION_${_part}") + endif() + list(APPEND _version "${CMAKE_MATCH_1}") +endforeach() +list(JOIN _version "." _version) +file(STRINGS "${_root}/CMakeLists.txt" _homepage REGEX "HOMEPAGE_URL +\"[^\"]+\"") +if(NOT _homepage MATCHES "\"([^\"]+)\"") + message(FATAL_ERROR "CMakeLists.txt names no HOMEPAGE_URL") +endif() +set(_homepage "${CMAKE_MATCH_1}") + +set(_name "minipsd-vips-${_version}") +set(_stage "${_out}/${_name}") +file(REMOVE_RECURSE "${_stage}") +file(REMOVE "${_out}/${_name}.zip") + +file(GLOB _foreign "${_root}/libvips-plugin/foreign/*.c" "${_root}/libvips-plugin/foreign/*.h") +file(COPY ${_foreign} DESTINATION "${_stage}/foreign") +file(COPY "${_root}/libvips-plugin/minipsd_vips.c" "${_root}/libvips-plugin/minipsd_vips.h" DESTINATION "${_stage}") +file(COPY "${_root}/libvips-plugin/release/CMakeLists.txt" "${_root}/libvips-plugin/release/README.md" + "${_root}/libvips-plugin/release/example.c" "${_root}/LICENSE.MIT" DESTINATION "${_stage}") +file(COPY "${_root}/cmake/minipsd_libvips.cmake" DESTINATION "${_stage}/cmake") +execute_process(COMMAND "${CMAKE_COMMAND}" "-DOUTPUT=${_stage}/foreign/minipsd/minipsd.h" -P "${_root}/tools/amalgamate.cmake" + RESULT_VARIABLE _rc) +if(_rc) + message(FATAL_ERROR "tools/amalgamate.cmake failed") +endif() + +# The SBOM fragment: the zip's every file, as minipsd's own fragment lists its headers. +include("${_root}/cmake/minipsd_spdx.cmake") +file(GLOB_RECURSE _files RELATIVE "${_stage}" "${_stage}/*") +list(SORT _files) +minipsd_spdx(OUTPUT "${_stage}/minipsd-vips.spdx.json" PACKAGE minipsd-vips VERSION "${_version}" HOMEPAGE "${_homepage}" + DOWNLOAD "${_homepage}/releases/download/${_version}/${_name}.zip" + SUMMARY "minipsd's libvips plugin: psdload, psdsave and psdsave_layers, as a CMake project" + COMMENT "The files below are the whole zip but this document; foreign/minipsd/minipsd.h is minipsd ${_version}, one file, compiled into the plugin." + BASE "${_stage}" FILES ${_files}) + +execute_process(COMMAND "${CMAKE_COMMAND}" -E tar cf "${_name}.zip" --format=zip "${_name}" + WORKING_DIRECTORY "${_out}" RESULT_VARIABLE _rc) +if(_rc) + message(FATAL_ERROR "zipping ${_stage} failed") +endif() +message(STATUS "${_out}/${_name}.zip")