diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7fabd4..5754e4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,24 +234,36 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Check if tag already exists + id: check_tag + run: | + TAG=$(cat VERSION) + if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Tag $TAG already exists, skipping release" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Download artifacts + if: steps.check_tag.outputs.skip != 'true' uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - name: Create release tag + if: steps.check_tag.outputs.skip != 'true' id: release_tag run: echo "tag=$(cat VERSION)" >> "$GITHUB_OUTPUT" - name: Publish GitHub Release + if: steps.check_tag.outputs.skip != 'true' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.release_tag.outputs.tag }} name: Release ${{ steps.release_tag.outputs.tag }} - # Do not specify target_commitish if the release already exists to avoid "immutable" error - # Or ensure we are using the existing tag's commit - # Removing it is usually safer for existing tags + target_commitish: ${{ github.sha }} generate_release_notes: true files: | dist/*.tar.gz diff --git a/.gitignore b/.gitignore index eb872a8..9f289c8 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ spratunpack spratframes README-assets/* !README-assets/*.png +**/_deps # Exclude temporary documentation snapshots README-assets/snapshot_*.png test_background/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 0819b14..564ab58 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -117,6 +117,7 @@ endfunction() ensure_stb_header(stb_image.h) ensure_stb_header(stb_image_write.h) +ensure_stb_header(stb_image_resize2.h) option(SPRAT_ENABLE_NLS "Enable gettext-based translations when available" ON) option(SPRAT_REQUIRE_GETTEXT "Fail configure if gettext support is requested but unavailable" OFF) @@ -245,6 +246,160 @@ if(NOT SQUISH_FOUND) endif() endif() +# Jsonnet dependency +include(FetchContent) +FetchContent_Declare( + jsonnet + GIT_REPOSITORY https://github.com/google/jsonnet.git + GIT_TAG f45e01d632b29e4c0757ec7ba188ce759298e6d3 # v0.20.0 +) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +# Under Emscripten the SHARED→STATIC patch already makes libjsonnet[++] static, +# so building the _static variants too would produce two targets with the same +# OUTPUT_NAME (both → libjsonnet.a / libjsonnet++.a) and cause a parallel-link +# race condition. Only enable BUILD_STATIC_LIBS on non-Emscripten platforms. +if(EMSCRIPTEN) + set(BUILD_STATIC_LIBS OFF CACHE BOOL "" FORCE) +else() + set(BUILD_STATIC_LIBS ON CACHE BOOL "" FORCE) +endif() +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +# Jsonnet's CMakeLists.txt uses cmake_minimum_required < 3.5; suppress the error +set(CMAKE_POLICY_VERSION_MINIMUM "3.5" CACHE INTERNAL "") +FetchContent_GetProperties(jsonnet) +if(NOT jsonnet_POPULATED) + # Silence FetchContent_Populate() deprecation warning on CMake >= 3.30. + if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(jsonnet) + # jsonnet hardcodes CMAKE_CXX_STANDARD 11; its headers use nested namespaces + # (a C++17 feature), causing -Wc++17-extensions warnings on GCC/Clang and + # build failures on MSVC. Raise the standard to 17 unconditionally. + file(READ "${jsonnet_SOURCE_DIR}/CMakeLists.txt" _jcml) + string(REPLACE + "set(CMAKE_CXX_STANDARD 11)" + "set(CMAKE_CXX_STANDARD 17)" + _jcml "${_jcml}") + if(MSVC) + # jsonnet v0.20.0 rejects non-GCC/Clang compilers with a FATAL_ERROR in + # the else() branch of its compiler-flags check (CMakeLists.txt:43). + # Downgrade it to STATUS so MSVC can configure and build with defaults. + string(REGEX REPLACE + "message[(]FATAL_ERROR \"Compiler [^\"]+not supported\"[)]" + "message(STATUS \"MSVC detected; using default compiler flags\")" + _jcml "${_jcml}") + endif() + file(WRITE "${jsonnet_SOURCE_DIR}/CMakeLists.txt" "${_jcml}") + if(MSVC) + # jsonnet's stdlib/CMakeLists.txt invokes to_c_array via + # ${GLOBAL_OUTPUT_PATH}/to_c_array, which is wrong for MSVC multi-config + # generators: those place the executable in a per-config subdirectory + # (e.g. Release/) that the plain path doesn't include. + # Replace it with $, a generator expression that + # resolves to the correct full path (config subdir + .exe) at build time. + file(READ "${jsonnet_SOURCE_DIR}/stdlib/CMakeLists.txt" _jstdlib) + string(REGEX REPLACE + "\\$\\{GLOBAL_OUTPUT_PATH\\}/to_c_array" + "$" + _jstdlib "${_jstdlib}") + file(WRITE "${jsonnet_SOURCE_DIR}/stdlib/CMakeLists.txt" "${_jstdlib}") + endif() + if(EMSCRIPTEN) + # c4core's cpu.hpp doesn't recognize WebAssembly; add WASM32/64 support. + set(_c4cpu "${jsonnet_SOURCE_DIR}/third_party/rapidyaml/rapidyaml/ext/c4core/src/c4/cpu.hpp") + if(EXISTS "${_c4cpu}") + file(READ "${_c4cpu}" _c4cpu_content) + string(REPLACE +[[#elif defined(SWIG) +#else +# error "unknown CPU architecture"]] +[[#elif defined(SWIG) +#elif defined(__wasm__) +# if defined(__wasm64__) +# define C4_CPU_WASM64 +# define C4_WORDSIZE 8 +# else +# define C4_CPU_WASM32 +# define C4_WORDSIZE 4 +# endif +# define C4_BYTE_ORDER _C4EL +#else +# error "unknown CPU architecture"]] + _c4cpu_content "${_c4cpu_content}") + file(WRITE "${_c4cpu}" "${_c4cpu_content}") + endif() + # fast_float doesn't recognize WebAssembly; Clang/Emscripten supports + # __uint128_t so treating WASM as a 64-bit platform is correct here. + set(_ff "${jsonnet_SOURCE_DIR}/third_party/rapidyaml/rapidyaml/ext/c4core/src/c4/ext/fast_float/include/fast_float/float_common.h") + if(EXISTS "${_ff}") + file(READ "${_ff}" _ff_content) + string(REPLACE + "|| defined(__s390x__)" + "|| defined(__s390x__) || defined(__wasm__)" + _ff_content "${_ff_content}") + file(WRITE "${_ff}" "${_ff_content}") + endif() + # libjsonnet and libjsonnet++ are hardcoded as SHARED in their own + # CMakeLists.txt files; under Emscripten a SHARED build produces a full + # WASM bundle that wasm-ld cannot consume as a link input, so force STATIC. + foreach(_jlib + "${jsonnet_SOURCE_DIR}/core/CMakeLists.txt" + "${jsonnet_SOURCE_DIR}/cpp/CMakeLists.txt") + file(READ "${_jlib}" _jlib_content) + string(REPLACE "SHARED" "STATIC" _jlib_content "${_jlib_content}") + file(WRITE "${_jlib}" "${_jlib_content}") + endforeach() + # to_c_array is compiled by Emscripten to .js and cannot do real file I/O + # in Node (Emscripten uses a virtual filesystem). Replace the COMMAND with + # a Python script that has the same positional-argument interface. Python + # is a hard dependency of Emscripten so it is always present. + find_program(_PYTHON3 NAMES python3 python REQUIRED) + set(_gen_py "${jsonnet_BINARY_DIR}/gen_std_h.py") + file(WRITE "${_gen_py}" [[ +import sys +data = open(sys.argv[1], 'rb').read() +with open(sys.argv[2], 'w') as f: + f.write(','.join(str(b) for b in data) + ',0') +]]) + file(READ "${jsonnet_SOURCE_DIR}/stdlib/CMakeLists.txt" _jstdlib) + string(REPLACE + [[${GLOBAL_OUTPUT_PATH}/to_c_array]] + "\"${_PYTHON3}\" \"${_gen_py}\"" + _jstdlib "${_jstdlib}") + file(WRITE "${jsonnet_SOURCE_DIR}/stdlib/CMakeLists.txt" "${_jstdlib}") + endif() + add_subdirectory("${jsonnet_SOURCE_DIR}" "${jsonnet_BINARY_DIR}") +endif() + +# jsonnet v0.20.0 builds libjsonnet++ (shared) without declaring its dependency +# on libjsonnet, causing undefined-symbol errors on macOS where dylibs must have +# all symbols resolved at link time. Patch the target here when it is shared. +if(TARGET libjsonnet++ AND TARGET libjsonnet) + get_target_property(_ljnpp_type libjsonnet++ TYPE) + if(_ljnpp_type STREQUAL "SHARED_LIBRARY") + target_link_libraries(libjsonnet++ PRIVATE libjsonnet) + endif() +endif() + +# Suppress common MSVC warnings in jsonnet targets (written for GCC/Clang). +if(MSVC) + foreach(_jt libjsonnet_static libjsonnet++_static) + if(TARGET ${_jt}) + target_compile_options(${_jt} PRIVATE + /wd4100 # unreferenced formal parameter + /wd4127 # conditional expression is constant + /wd4244 # conversion, possible loss of data + /wd4267 # conversion from size_t + /wd4702 # unreachable code + /wd4706 # assignment within conditional expression + /wd4996 # deprecated CRT function + ) + target_compile_definitions(${_jt} PRIVATE _CRT_SECURE_NO_WARNINGS) + endif() + endforeach() +endif() + # Target definitions add_library(spratcore STATIC src/core/cli_parse.cpp @@ -263,6 +418,13 @@ target_include_directories(spratcore PUBLIC src) target_include_directories(spratcore SYSTEM PRIVATE ${STB_DIR}) target_include_directories(spratcore PRIVATE ${LIBARCHIVE_INCLUDE_DIRS}) target_link_libraries(spratcore PRIVATE ${LIBARCHIVE_LIBRARIES}) +# Under Emscripten BUILD_STATIC_LIBS is OFF (see above), so the _static alias +# targets are not defined; use the main (already-STATIC) targets instead. +if(EMSCRIPTEN) + target_link_libraries(spratcore PRIVATE libjsonnet++ libjsonnet) +else() + target_link_libraries(spratcore PRIVATE libjsonnet++_static libjsonnet_static) +endif() if(SPRAT_GETTEXT_AVAILABLE) target_compile_definitions(spratcore PUBLIC SPRAT_ENABLE_GETTEXT) target_link_libraries(spratcore PUBLIC ${SPRAT_INTL_LIBRARIES}) @@ -311,6 +473,28 @@ target_compile_definitions(spratlayout PRIVATE # Keep a default profile config beside built binaries (e.g. build-win/) so # spratlayout can resolve it via exe-directory lookup without extra setup. +# Use a single custom target for the transforms copy to prevent parallel copy races +# when multiple executables complete simultaneously in a parallel build. +# NOTE: Use a static path instead of $ to avoid a +# cyclic dependency (sprat_copy_transforms -> spratlayout -> sprat_copy_transforms). +# On multi-config generators (MSVC, Xcode) binaries land in a per-config subdir +# (e.g. build/Release/), so append /$ to match $. +if(CMAKE_RUNTIME_OUTPUT_DIRECTORY) + set(_sprat_bin_dir ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) +else() + set(_sprat_bin_dir ${CMAKE_CURRENT_BINARY_DIR}) +endif() +get_property(_sprat_multi_config GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(_sprat_multi_config) + string(APPEND _sprat_bin_dir "/$") +endif() +add_custom_target(sprat_copy_transforms ALL + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/transforms + ${_sprat_bin_dir}/transforms + COMMENT "Copying transforms/ to ${_sprat_bin_dir}" + VERBATIM) + foreach(target spratlayout spratpack spratconvert spratframes spratunpack) add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different @@ -318,12 +502,7 @@ foreach(target spratlayout spratpack spratconvert spratframes spratunpack) $/spratprofiles.cfg COMMENT "Copying spratprofiles.cfg to $" VERBATIM) - add_custom_command(TARGET ${target} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_directory - ${CMAKE_CURRENT_SOURCE_DIR}/transforms - $/transforms - COMMENT "Copying transforms/ to $" - VERBATIM) + add_dependencies(${target} sprat_copy_transforms) if(WIN32) if(SPRAT_STATIC) @@ -363,6 +542,16 @@ install(TARGETS spratlayout spratpack spratconvert spratframes spratunpack RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(FILES spratprofiles.cfg DESTINATION ${CMAKE_INSTALL_DATADIR}/sprat) +install(CODE " + # Remove any stale compiled .transform files left by older installations; + # the current format is .jsonnet, which carries richer metadata (icons, etc.). + file(GLOB _old_transforms + \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/sprat/transforms/*.transform\") + foreach(_f IN LISTS _old_transforms) + message(STATUS \"Removing obsolete transform file: \${_f}\") + file(REMOVE \"\${_f}\") + endforeach() +") install(DIRECTORY transforms/ DESTINATION ${CMAKE_INSTALL_DATADIR}/sprat/transforms) install(FILES man/sprat-cli.1 diff --git a/COMPACT_PERFORMANCE.md b/COMPACT_PERFORMANCE.md new file mode 100644 index 0000000..a2dd14c --- /dev/null +++ b/COMPACT_PERFORMANCE.md @@ -0,0 +1,53 @@ +● Based on the analysis, here's what the situation looks like and where there's room to improve: + + Already parallelized: + The guided and shelf passes both fan out across threads, dividing width candidates into ranges. So the outer search loop + is already threaded. + + The real bottlenecks: + + 1. Static range division → load imbalance + The current pattern divides [0, width_candidates.size()) evenly across threads. But different widths take different + amounts of time (a narrow atlas packs quickly; a near-square one generates many free-rect fragments and runs longer). A + slow thread stalls the join. Replacing the static split with an atomic work index (each thread atomically claims the + next unclaimed width) would eliminate this with minimal code change. + + 2. Two separate thread fan-out/join cycles + Guided and shelf are sequentially phased — spawn threads, join, spawn again, join. Merging them into one pass (each + worker does both MaxRects and shelf for its assigned widths) halves the thread lifecycle overhead and improves cache + locality since the same width's sprite list is touched once. + + 3. O(n²) free-rect pruning inside pack_compact_maxrects + Every time a sprite is placed, prune_free_rects does an O(n²) containment scan to discard dominated rectangles. For + large sprite sets the free-rect list can grow significantly. This is the core per-packing cost and runs entirely on a + single thread — no parallelism is possible inside it without a fundamentally different data structure (e.g. a spatial + index). This is the hardest win but potentially the biggest one. + + 4. No pruning across (sort × heuristic) combos within a width + For a given width, all 12 combinations (4 sorts × 3 heuristics) are always attempted. If the first combo already + produces a result that can't beat the global best, the remaining 11 could be skipped. The candidate-level pruning exists + before entering the width, but not within it. + + 5. Candidate count + ~20–60 candidates × 16 packing attempts each. The offset step grid ({0, ±1, ±2, ±4, ±8, ±12, ±16, ±20} × 4 anchors) is + generous. With a warm seed cache the search could be narrowed to a smaller window around the known-good width rather + than the full grid. + + Summary by effort vs. impact: + + ┌────────────────────────────────────────────────────┬────────┬───────────────────────────────────────┐ + │ Change │ Effort │ Impact │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Atomic work index (dynamic scheduling) │ Low │ Medium — eliminates load imbalance │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Merge guided + shelf into one pass │ Low │ Small — saves two thread cycles │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Per-width early exit across (sort × heuristic) │ Medium │ Small-medium │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Narrower candidate window when seed is warm │ Medium │ Medium — fewer packing runs │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Replace O(n²) free-rect pruning with spatial index │ High │ Potentially large for big sprite sets │ + └────────────────────────────────────────────────────┴────────┴───────────────────────────────────────┘ + + The atomic work index is the most straightforward improvement given the existing code structure — want me to implement + it? diff --git a/README.md b/README.md index 7d2acb2..fb47ff6 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,11 @@ Profiles are named rule sets that group packing options (mode, padding, scale, e Profile definitions are searched in: 1. `--profiles-config PATH` (CLI override) -2. User config: - - Linux/macOS: `~/.config/sprat/spratprofiles.cfg` +2. `{exe_dir}/spratprofiles.cfg` (beside the executable, portable install) +3. User config: + - Linux: `$XDG_CONFIG_HOME/sprat/spratprofiles.cfg` (default `~/.config/sprat/`) + - macOS: `~/Library/Application Support/sprat/spratprofiles.cfg` - Windows: `%APPDATA%\sprat\spratprofiles.cfg` -3. `./spratprofiles.cfg` (current working directory) 4. `/usr/local/share/sprat/spratprofiles.cfg` (Global) ### Spratlayout Options @@ -228,6 +229,7 @@ Profile definitions are searched in: - `--scale F`: Pre-scale images (0.0 to 1.0). - `--threads N`: Parallelize the packing search. - `--debug`: Enable detailed error reporting and debug visualization. +- Directory inputs honor `.spratlayoutignore`; list files may include `exclude "path"` entries. ### Layout Caching `spratlayout` automatically caches image metadata in the system temp directory. If your source images haven't changed, subsequent runs will be nearly instantaneous. Entries older than one hour are pruned automatically. @@ -268,7 +270,7 @@ The layout file will contain multiple `atlas` lines. `spratpack` can then genera ```sh # Output atlas_0.png, atlas_1.png, etc. -./build/spratpack -o atlas_%d.png < layout.txt +./build/spratpack -a atlas_%d.png < layout.txt ``` You can also extract a specific atlas index: @@ -455,7 +457,7 @@ Example layout line: ## Layout transforms (`spratconvert`) `spratconvert` reads layout text from stdin and writes transformed output to stdout. -The term `transform` is used because conversion is template-driven and data-oriented. +Transforms are [Jsonnet](https://jsonnet.org/) files that receive the full layout data and produce any text format for your game engine or pipeline. List built-in transforms: @@ -469,10 +471,10 @@ Use a built-in transform: ./build/spratconvert --transform json < layout.txt > layout.json ``` -If your template uses `{{atlas_path}}`/`{{atlas_index}}`, provide `--output` so paths are deterministic: +Provide `--atlas` so atlas paths are deterministic in multi-atlas layouts: ```sh -./build/spratconvert --transform json --output atlas_%d.png < layout.txt > layout.json +./build/spratconvert --transform json --atlas atlas_%d.png < layout.txt > layout.json ``` ### Automatic Animations @@ -482,10 +484,11 @@ Automatically group sprites into animations based on their filenames (e.g., `her ``` ### Pivot Points -You can define pivot points (anchors) for your sprites using markers. +Define pivot points (anchors) for sprites using markers. 1. **Per-sprite pivot**: Add a marker named `pivot` of type `point` to a specific sprite. -2. **Global pivot**: Add a marker named `pivot` of type `point` without a `path` (or at the top level). -`spratconvert` will automatically populate `{{pivot_x}}` and `{{pivot_y}}` placeholders using these markers. +2. **Global pivot**: Add a marker named `pivot` of type `point` without a `path`. + +`spratconvert` resolves pivot positions and exposes them as `pivot_x`, `pivot_y`, `pivot_x_norm`, `pivot_y_norm`, and `pivot_y_norm_raw` on each sprite object. Example `markers.txt`: ```txt @@ -503,58 +506,159 @@ Optional extra data files: ./build/spratconvert --transform json --markers markers.txt --animations animations.txt < layout.txt > layout.json ``` -Built-in transform files live in `transforms/`: - -- `transforms/json.transform` -- `transforms/csv.transform` -- `transforms/xml.transform` -- `transforms/css.transform` - -Each transform is section-based. You can use explicit open/close tags (e.g., `[meta]` ... `[/meta]`) or the modern line-based DSL (e.g., `meta`, `header`, `sprites`, `- sprite`). - -- `[meta]` / `meta`: metadata like `name`, `description`, `extension` -- `[header]` / `header`: printed once before sprites -- `[if_markers]` / `[if_no_markers]` conditional blocks based on marker items -- `[markers_header]`, `[markers]`, `[marker]`, `[markers_separator]`, `[markers_footer]` marker loop sections -- `[sprites]` / `sprites`: container with `[sprite]` / `- sprite` item template repeated for each sprite (required) -- `[separator]` / `separator`: inserted between sprite entries -- `[if_animations]` / `[if_no_animations]` conditional blocks based on animation items -- `[animations_header]`, `[animations]`, `[animation]`, `[animations_separator]`, `[animations_footer]` animation loop sections -- `[footer]` / `footer`: printed once after sprites - -Common placeholders: - -- `{{atlas_width}}`, `{{atlas_height}}`, `{{scale}}`, `{{sprite_count}}` -- `{{index}}`, `{{name}}`, `{{path}}`, `{{x}}`, `{{y}}`, `{{w}}`, `{{h}}` -- `{{pivot_x}}`, `{{pivot_y}}` (resolved from "pivot" markers) -- `{{src_x}}`, `{{src_y}}`, `{{trim_left}}`, `{{trim_top}}`, `{{trim_right}}`, `{{trim_bottom}}` -- `{{rotation}}` (numeric degrees; `0` when unrotated, `90` when rotated clockwise; built-in transforms use this field) -- `[rotated]...[/rotated]` sections inside sprite templates emit their contents only for rotated sprites; non-rotated sprites have the block removed automatically. -- `{{rotated}}` (`true` when the sprite was packed with 90-degree rotation, otherwise `false`; available for custom templates) -- You can also guard sections by `type` attributes (for example `[markers type="json"]` or `[marker type="circle"]`) to emit format-specific or marker-type-specific content. Non-matching blocks are dropped automatically. -- Escaped sprite fields: `{{name_json}}`, `{{name_csv}}`, `{{name_xml}}`, `{{name_css}}`, `{{path_json}}`, `{{path_csv}}`, `{{path_xml}}`, `{{path_css}}` -- Per-sprite markers: `{{sprite_markers_count}}`, `{{sprite_markers_json}}`, `{{sprite_markers_csv}}`, `{{sprite_markers_xml}}`, `{{sprite_markers_css}}` -- Marker loop placeholders: - - `{{marker_index}}`, `{{marker_name}}`, `{{marker_type}}` - - `{{marker_x}}`, `{{marker_y}}`, `{{marker_radius}}`, `{{marker_w}}`, `{{marker_h}}` - - `{{marker_vertices}}`, `{{marker_vertices_json}}`, `{{marker_vertices_csv}}`, `{{marker_vertices_xml}}`, `{{marker_vertices_css}}` - - `{{marker_sprite_index}}`, `{{marker_sprite_name}}`, `{{marker_sprite_path}}` -- Animation loop placeholders: - - `{{animation_index}}`, `{{animation_name}}` - - `{{animation_sprite_count}}`, `{{animation_sprite_indexes}}`, `{{animation_sprite_indexes_json}}`, `{{animation_sprite_indexes_csv}}` -- Extra file placeholders: - - `{{has_markers}}`, `{{has_animations}}`, `{{marker_count}}`, `{{animation_count}}` - - `{{markers_path}}`, `{{animations_path}}` - - `{{markers_raw}}`, `{{animations_raw}}` - - `{{markers_json}}`, `{{markers_csv}}`, `{{markers_xml}}`, `{{markers_css}}` - - `{{animations_json}}`, `{{animations_csv}}`, `{{animations_xml}}`, `{{animations_css}}` - -Typed placeholders (`*_json`, `*_xml`, `*_csv`, `*_css`) are the explicit format-safe form and should be preferred. -Unsuffixed placeholders (for example `{{name}}`, `{{marker_name}}`, `{{marker_vertices}}`) are auto-encoded using `meta.extension` (fallback: transform name/argument) when the output format is JSON/XML/CSV/CSS. - -Sprite names default to the source file basename without extension (for example `./frames/run_01.png` becomes `run_01`). +### Transform search paths + +Transform files are searched in: +1. `{exe_dir}/transforms/` (beside the executable, portable install) +2. User data dir: + - Linux: `$XDG_DATA_HOME/sprat/transforms/` (default `~/.local/share/sprat/transforms/`) + - macOS: `~/Library/Application Support/sprat/transforms/` + - Windows: `%APPDATA%\sprat\transforms\` +3. `/usr/local/share/sprat/transforms/` (Global) + +### Built-in transforms + +| Name | Output | Notes | +|------|--------|-------| +| `json` | JSON | Generic metadata: sprites, atlases, animations, markers | +| `csv` | CSV | Flat spreadsheet-friendly list | +| `xml` | XML | Generic XML | +| `css` | CSS | CSS sprite sheet | +| `aseprite` | JSON | Aseprite JSON Array format; frameTags built from animations (non-contiguous animations become multiple tags) | +| `libgdx` | Text | LibGDX Atlas format; handles multipack | +| `godot` | JSON | Godot SpriteFrames resource | +| `phaser-hash` | JSON | Phaser 3 hash-keyed sprite sheet | +| `phaser-array` | JSON | Phaser 3 array-keyed sprite sheet | +| `phaser-anims` | JSON | Phaser 3 animation config | +| `plist` | plist | Apple / TexturePacker plist | +| `unity` | Group | `unity.json` + `unity.meta` + one `unity.anim` per animation; requires `--output-dir` | + +### Transform format + +Each transform is a Jsonnet file that evaluates to a JSON object: + +- `name` — display name +- `description` — shown by `--list-transforms` +- `extension` — output file extension (e.g. `".json"`) +- `content` — string output for a single file +- `files` — array of `{filename, content}` for multi-file output; mutually exclusive with `content`, requires `--output-dir` + +The layout data is available as `std.extVar("sprat")`: + +```jsonnet +local sprat = std.extVar("sprat"); +``` + +**Global fields:** + +| Field | Type | Description | +|---|---|---| +| `sprites` | array | All sprites across all atlases | +| `atlases` | array | Each entry has `index`, `width`, `height`, `path`, `sprites` | +| `animations` | array | Animation definitions | +| `markers` | array | All markers across all sprites | +| `atlas_width`, `atlas_height` | number | First atlas dimensions | +| `atlas_path`, `atlas_stem` | string | First atlas path and stem | +| `atlas_count` | number | Total atlas count | +| `multipack` | boolean | `true` when layout declares `multipack true` | +| `scale`, `extrude` | number | Layout-level values | +| `has_animations`, `has_markers` | boolean | Whether extra files were loaded | +| `animation_count`, `marker_count`, `sprite_count` | number | Counts | +| `output_stem`, `output_stem_hash_hex` | string | Output stem and its FNV-1a hex hash | +| `animations_path`, `markers_path` | string | Paths to the extra files | + +**Per sprite (`sprites[]`):** + +| Field | Description | +|---|---| +| `index`, `name`, `path`, `atlas_index` | Identity | +| `x`, `y`, `w`, `h` | Packed rectangle in the atlas | +| `content_w`, `content_h` | Dimensions accounting for rotation | +| `source_w`, `source_h` | Original size including trim margins | +| `trim_left`, `trim_top`, `trim_right`, `trim_bottom`, `has_trim` | Trim margins | +| `rotated` | `true` when packed rotated 90° clockwise | +| `unity_y` | `atlas_height - y - h` (Y-up coordinate for Unity) | +| `pivot_x`, `pivot_y` | Pivot in pixels from marker lookup (0 if not set) | +| `pivot_x_norm`, `pivot_y_norm` | Normalized; `pivot_y_norm` is Y-up (Unity convention) | +| `pivot_y_norm_raw` | Normalized Y-down | +| `name_hash_hex` | 16-char FNV-1a hex string | +| `name_hash_decimal` | FNV-1a as a decimal string (serialized as JSON string to avoid float precision loss) | +| `name_css` | CSS-safe identifier | +| `markers` | Array of marker objects attached to this sprite | + +**Per animation (`animations[]`):** + +| Field | Description | +|---|---| +| `index`, `name`, `fps`, `duration` | Identity and timing | +| `frame_indices` | Global sprite index sequence (may be non-contiguous) | +| `frames` | `[{index, name, name_hash_decimal, name_hash_hex}]` resolved per frame | +| `is_alias`, `alias_source`, `flip` | Alias support | + +**Per marker (`markers[]` and `sprite.markers[]`):** + +| Field | Description | +|---|---| +| `index`, `name`, `type` | Identity; `type` is `point`, `circle`, `rectangle`, or `polygon` | +| `x`, `y`, `radius`, `w`, `h`, `vertices` | Geometry (fields present depend on type) | +| `sprite_index`, `sprite_name`, `sprite_path` | Owning sprite | + +### Shared helpers (`sprat.libsonnet`) + +All transforms in the transforms directory can import shared helpers: + +```jsonnet +local lib = import "sprat.libsonnet"; +``` + +- `lib.format_double(v)` — formats a float like C's `%.8g` (works around a known Jsonnet v0.20 `%g` bug) +- `lib.consecutive_runs(indices)` — splits an index array into contiguous ranges `[{from, to}]`; used by the Aseprite transform to build frameTags from non-contiguous animations + +### Custom transforms + +A transform is any `.jsonnet` file. Pass a path directly to `--transform`: + +```jsonnet +local sprat = std.extVar("sprat"); +{ + name: "compact-log", + description: "One line per sprite", + extension: ".txt", + content: + "atlas=%dx%d sprites=%d\n" % [sprat.atlas_width, sprat.atlas_height, sprat.sprite_count] + + std.join("\n", [ + "%d %s @ %d,%d %dx%d" % [s.index, s.path, s.x, s.y, s.w, s.h] + for s in sprat.sprites + ]) + "\n", +} +``` + +```sh +./build/spratconvert --transform ./my.jsonnet < layout.txt > output.txt +``` + +Multi-file output — return `files` instead of `content` and use `--output-dir`: + +```jsonnet +local sprat = std.extVar("sprat"); +{ + name: "one-per-anim", + extension: ".txt", + files: [ + { filename: anim.name + ".txt", content: anim.name + ": " + anim.fps + "fps\n" } + for anim in sprat.animations + ], +} +``` + +```sh +./build/spratconvert --transform ./per-anim.jsonnet --output-dir ./out < layout.txt +``` + +Sprite names default to the source file basename without extension (e.g. `./frames/run_01.png` becomes `run_01`). `--markers` expects a plaintext file using the `path` and `- marker` DSL. +An optional `root` directive sets a base directory; `path` values that are relative are resolved against it. Supported marker types: - `point`: `x,y` - `circle`: `x,y radius` @@ -563,7 +667,8 @@ Supported marker types: Example `markers.txt`: ```txt -path "./frames/a.png" +root "./frames" +path "a.png" - marker "hit" point 3,5 - marker "hurt" circle 6,7 4 path "b" @@ -571,50 +676,19 @@ path "b" ``` `--animations` expects a plaintext file using the `animation` and `- frame` DSL. Frame entries are resolved to sprite indexes by path, name, or index. +An optional `root` directive sets a base directory; quoted frame paths that are relative are resolved against it. Example `animations.txt`: ```txt +root "./frames" fps 12 animation "run" 8 -- frame "./frames/a.png" +- frame "a.png" - frame "b" animation "idle" - frame 1 ``` -Custom transform example: - -```ini -[meta] -name=compact-log -[/meta] - -[header] -atlas={{atlas_width}}x{{atlas_height}} sprites={{sprite_count}} -[/header] - -[sprites] - [sprite] -{{index}} {{path}} @ {{x}},{{y}} {{w}}x{{h}} - [/sprite] -[/sprites] - -[separator] -; -[/separator] - -[footer] - -done -[/footer] -``` - -Run custom transform: - -```sh -./build/spratconvert --transform ./my.transform < layout.txt > layout.custom.txt -``` - Column meanings for the `sprite` line in trim mode: - `""`: source image path. diff --git a/VERSION b/VERSION index 600e6fd..8de8a0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.3.3 +v0.11.4 diff --git a/build_test/_deps/jsonnet-build/jsonnet b/build_test/_deps/jsonnet-build/jsonnet new file mode 100755 index 0000000..e890c40 Binary files /dev/null and b/build_test/_deps/jsonnet-build/jsonnet differ diff --git a/build_test/_deps/jsonnet-build/jsonnetfmt b/build_test/_deps/jsonnet-build/jsonnetfmt new file mode 100755 index 0000000..70a8300 Binary files /dev/null and b/build_test/_deps/jsonnet-build/jsonnetfmt differ diff --git a/build_test/_deps/jsonnet-build/libjsonnet++.so.0 b/build_test/_deps/jsonnet-build/libjsonnet++.so.0 new file mode 120000 index 0000000..0021398 --- /dev/null +++ b/build_test/_deps/jsonnet-build/libjsonnet++.so.0 @@ -0,0 +1 @@ +libjsonnet++.so.0.20.0 \ No newline at end of file diff --git a/build_test/_deps/jsonnet-build/libjsonnet++.so.0.20.0 b/build_test/_deps/jsonnet-build/libjsonnet++.so.0.20.0 new file mode 100755 index 0000000..e994a41 Binary files /dev/null and b/build_test/_deps/jsonnet-build/libjsonnet++.so.0.20.0 differ diff --git a/build_test/_deps/jsonnet-build/libjsonnet.so.0 b/build_test/_deps/jsonnet-build/libjsonnet.so.0 new file mode 120000 index 0000000..fa8e6c5 --- /dev/null +++ b/build_test/_deps/jsonnet-build/libjsonnet.so.0 @@ -0,0 +1 @@ +libjsonnet.so.0.20.0 \ No newline at end of file diff --git a/build_test/_deps/jsonnet-build/libjsonnet.so.0.20.0 b/build_test/_deps/jsonnet-build/libjsonnet.so.0.20.0 new file mode 100755 index 0000000..46e24b8 Binary files /dev/null and b/build_test/_deps/jsonnet-build/libjsonnet.so.0.20.0 differ diff --git a/build_test/_deps/jsonnet-build/to_c_array b/build_test/_deps/jsonnet-build/to_c_array new file mode 100755 index 0000000..965b28c Binary files /dev/null and b/build_test/_deps/jsonnet-build/to_c_array differ diff --git a/build_test/_deps/jsonnet-src b/build_test/_deps/jsonnet-src new file mode 160000 index 0000000..f45e01d --- /dev/null +++ b/build_test/_deps/jsonnet-src @@ -0,0 +1 @@ +Subproject commit f45e01d632b29e4c0757ec7ba188ce759298e6d3 diff --git a/build_test/_deps/jsonnet-subbuild/CMakeLists.txt b/build_test/_deps/jsonnet-subbuild/CMakeLists.txt new file mode 100644 index 0000000..c897e81 --- /dev/null +++ b/build_test/_deps/jsonnet-subbuild/CMakeLists.txt @@ -0,0 +1,42 @@ +# Distributed under the OSI-approved BSD 3-Clause License. See accompanying +# file LICENSE.rst or https://cmake.org/licensing for details. + +cmake_minimum_required(VERSION 4.3.2) + +# Reject any attempt to use a toolchain file. We must not use one because +# we could be downloading it here. If the CMAKE_TOOLCHAIN_FILE environment +# variable is set, the cache variable will have been initialized from it. +unset(CMAKE_TOOLCHAIN_FILE CACHE) +unset(ENV{CMAKE_TOOLCHAIN_FILE}) + +# We name the project and the target for the ExternalProject_Add() call +# to something that will highlight to the user what we are working on if +# something goes wrong and an error message is produced. + +project(jsonnet-populate NONE) + + +# Pass through things we've already detected in the main project to avoid +# paying the cost of redetecting them again in ExternalProject_Add() +set(GIT_EXECUTABLE [==[/usr/bin/git]==]) +set(Git_VERSION [==[2.54.0]==]) +set_property(GLOBAL PROPERTY _CMAKE_FindGit_GIT_EXECUTABLE_VERSION + [==[/usr/bin/git;2.54.0]==] +) + + +include(ExternalProject) +ExternalProject_Add(jsonnet-populate + "UPDATE_DISCONNECTED" "False" "GIT_REPOSITORY" "https://github.com/google/jsonnet.git" "EXTERNALPROJECT_INTERNAL_ARGUMENT_SEPARATOR" "GIT_TAG" "f45e01d632b29e4c0757ec7ba188ce759298e6d3" + SOURCE_DIR "/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-src" + BINARY_DIR "/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" + USES_TERMINAL_DOWNLOAD YES + USES_TERMINAL_UPDATE YES + USES_TERMINAL_PATCH YES +) + + diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-build b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-build new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-configure b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-configure new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-done b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-done new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-download b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-download new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-gitclone-lastrun.txt b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-gitclone-lastrun.txt new file mode 100644 index 0000000..fe763b8 --- /dev/null +++ b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-gitclone-lastrun.txt @@ -0,0 +1,15 @@ +# This is a generated file and its contents are an internal implementation detail. +# The download step will be re-executed if anything in this file changes. +# No other meaning or use of this file is supported. + +method=git +command=/usr/bin/cmake;-DCMAKE_MESSAGE_LOG_LEVEL=VERBOSE;-P;/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/tmp/jsonnet-populate-gitclone.cmake +source_dir=/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-src +work_dir=/home/pedroac/Projects/sprat-cli/build_test/_deps +repository=https://github.com/google/jsonnet.git +remote=origin +init_submodules=TRUE +recurse_submodules=--recursive +submodules= +CMP0097=NEW + diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-gitinfo.txt b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-gitinfo.txt new file mode 100644 index 0000000..fe763b8 --- /dev/null +++ b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-gitinfo.txt @@ -0,0 +1,15 @@ +# This is a generated file and its contents are an internal implementation detail. +# The download step will be re-executed if anything in this file changes. +# No other meaning or use of this file is supported. + +method=git +command=/usr/bin/cmake;-DCMAKE_MESSAGE_LOG_LEVEL=VERBOSE;-P;/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/tmp/jsonnet-populate-gitclone.cmake +source_dir=/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-src +work_dir=/home/pedroac/Projects/sprat-cli/build_test/_deps +repository=https://github.com/google/jsonnet.git +remote=origin +init_submodules=TRUE +recurse_submodules=--recursive +submodules= +CMP0097=NEW + diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-install b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-install new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-mkdir b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-mkdir new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-patch b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-patch new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-patch-info.txt b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-patch-info.txt new file mode 100644 index 0000000..53e1e1e --- /dev/null +++ b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-patch-info.txt @@ -0,0 +1,6 @@ +# This is a generated file and its contents are an internal implementation detail. +# The update step will be re-executed if anything in this file changes. +# No other meaning or use of this file is supported. + +command= +work_dir= diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-test b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-test new file mode 100644 index 0000000..e69de29 diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-update-info.txt b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-update-info.txt new file mode 100644 index 0000000..4d2a523 --- /dev/null +++ b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/src/jsonnet-populate-stamp/jsonnet-populate-update-info.txt @@ -0,0 +1,7 @@ +# This is a generated file and its contents are an internal implementation detail. +# The patch step will be re-executed if anything in this file changes. +# No other meaning or use of this file is supported. + +command (connected)=/usr/bin/cmake;-Dcan_fetch=YES;-DCMAKE_MESSAGE_LOG_LEVEL=VERBOSE;-P;/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/tmp/jsonnet-populate-gitupdate.cmake +command (disconnected)=/usr/bin/cmake;-Dcan_fetch=NO;-DCMAKE_MESSAGE_LOG_LEVEL=VERBOSE;-P;/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/tmp/jsonnet-populate-gitupdate.cmake +work_dir=/home/pedroac/Projects/sprat-cli/build_test/_deps/jsonnet-src diff --git a/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/tmp/jsonnet-populate-cfgcmd.txt b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/tmp/jsonnet-populate-cfgcmd.txt new file mode 100644 index 0000000..6a6ed5f --- /dev/null +++ b/build_test/_deps/jsonnet-subbuild/jsonnet-populate-prefix/tmp/jsonnet-populate-cfgcmd.txt @@ -0,0 +1 @@ +cmd='' diff --git a/build_test/spratprofiles.cfg b/build_test/spratprofiles.cfg new file mode 100644 index 0000000..8becca4 --- /dev/null +++ b/build_test/spratprofiles.cfg @@ -0,0 +1,59 @@ +# Default spratlayout profiles. + +[profile fast] +label=Fast +mode=fast +optimize=gpu +padding=0 +trim_transparent=false + +[profile desktop] +label=Desktop +mode=compact +optimize=gpu +max_width=8192 +max_height=8192 +padding=2 +extrude=0 +trim_transparent=true + +[profile mobile] +label=Mobile +mode=compact +optimize=gpu +max_width=2048 +max_height=2048 +padding=2 +extrude=0 +trim_transparent=true + +[profile legacy] +label=Legacy (POT) +mode=pot +optimize=gpu +max_width=2048 +max_height=2048 +padding=1 +extrude=0 +trim_transparent=true + +[profile space] +label=Space Efficient +mode=compact +optimize=space +padding=0 +rotate=true +trim_transparent=true + +[profile css] +label=CSS Sprites +mode=compact +optimize=space +padding=0 +trim_transparent=false + +[profile grid] +label=Grid +mode=grid +padding=0 +trim_transparent=false diff --git a/build_test/tests/core_test b/build_test/tests/core_test new file mode 100755 index 0000000..7f535b5 Binary files /dev/null and b/build_test/tests/core_test differ diff --git a/build_test/tests/layout_test b/build_test/tests/layout_test new file mode 100755 index 0000000..e836b47 Binary files /dev/null and b/build_test/tests/layout_test differ diff --git a/build_test/transforms/aseprite.jsonnet b/build_test/transforms/aseprite.jsonnet new file mode 100644 index 0000000..bb68b1c --- /dev/null +++ b/build_test/transforms/aseprite.jsonnet @@ -0,0 +1,45 @@ +// aseprite.jsonnet – Aseprite JSON Array sprite sheet format. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_obj(s) = { + filename: s.name, + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + duration: 100, +}; + +// Build frameTags from animations: each animation may be non-contiguous, so split into runs. +local frame_tag(anim) = + local runs = lib.consecutive_runs(anim.frame_indices); + [ + { name: anim.name, from: run.from, to: run.to, direction: "forward" } + for run in runs + ]; + +local frame_tags = std.flatMap(frame_tag, sprat.animations); + +local result = { + frames: [frame_obj(s) for s in sprat.sprites], + meta: { + app: "https://www.aseprite.org/", + version: "1.3", + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + frameTags: frame_tags, + layers: [], + slices: [], + }, +}; + +{ + name: "Aseprite", + description: "Aseprite JSON Array sprite sheet format (frameTags populated when animations are present)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/build_test/transforms/css.jsonnet b/build_test/transforms/css.jsonnet new file mode 100644 index 0000000..679fd82 --- /dev/null +++ b/build_test/transforms/css.jsonnet @@ -0,0 +1,34 @@ +// css.jsonnet – CSS classes for web sprite rendering. +local sprat = std.extVar("sprat"); + +local sprite_css(s) = + '.sprite-' + s.name_css + ' {\n' + + (if s.atlas_path != "" then ' background-image: url(\'' + s.atlas_path + '\');\n' else "") + + ' background-position: -' + s.x + 'px -' + s.y + 'px;\n' + + ' width: ' + s.w + 'px;\n' + + ' height: ' + s.h + 'px;\n' + + ' /* source: ' + s.path + ' */\n' + + ' /* name: ' + s.name + ' */\n' + + ' /* atlas_index: ' + s.atlas_index + ' */\n' + + (if s.rotated then + ' transform: rotate(-90deg) translate(-100%, 0);\n transform-origin: top left;\n' + else "") + + '}\n'; + +local header = + ':root {\n' + + ' --atlas-width: ' + sprat.atlas_width + 'px;\n' + + ' --atlas-height: ' + sprat.atlas_height + 'px;\n' + + ' --atlas-scale: ' + sprat.scale + ';\n' + + '}\n\n' + + '.sprat-sprite {\n' + + ' background-repeat: no-repeat;\n' + + ' display: inline-block;\n' + + '}\n'; + +{ + name: "CSS", + description: "CSS classes for web sprite rendering", + extension: ".css", + content: header + std.join("\n", [sprite_css(s) for s in sprat.sprites]), +} diff --git a/transforms/css.transform b/build_test/transforms/css.transform similarity index 88% rename from transforms/css.transform rename to build_test/transforms/css.transform index 9275f9f..a78305f 100644 --- a/transforms/css.transform +++ b/build_test/transforms/css.transform @@ -1,5 +1,5 @@ [meta] -name=css +name=CSS description=CSS classes for web sprite rendering extension=.css [/meta] @@ -19,10 +19,10 @@ extension=.css [sprites] [sprite] -.sprite-{{index}} { - [atlas_path] +.sprite-{{name_css}} { + [if atlas_path!=""] background-image: url('{{atlas_path}}'); - [/atlas_path] + [/if] background-position: -{{x}}px -{{y}}px; width: {{w}}px; height: {{h}}px; @@ -30,10 +30,10 @@ extension=.css /* source: {{path}} */ /* name: {{name}} */ /* atlas_index: {{atlas_index}} */ - [rotated] + [if rotated="true"] transform: rotate(90deg); transform-origin: top left; - [/rotated] + [/if] } [/sprite] [/sprites] diff --git a/build_test/transforms/csv.jsonnet b/build_test/transforms/csv.jsonnet new file mode 100644 index 0000000..78ad047 --- /dev/null +++ b/build_test/transforms/csv.jsonnet @@ -0,0 +1,74 @@ +// csv.jsonnet – CSV rows for spreadsheets and data tools. +local sprat = std.extVar("sprat"); + +local csv_escape(s) = + local needs_quotes = std.length( + [c for c in std.stringChars(s) if c == '"' || c == ',' || c == '\n' || c == '\r'] + ) > 0; + if needs_quotes then + '"' + std.strReplace(s, '"', '""') + '"' + else + s; + +local marker_vertices_csv(verts) = + std.join("|", ["" + v.x + "," + v.y for v in verts]); + +local marker_json(m) = + '{"name":' + std.manifestJsonEx(m.name, "") + ',"type":"' + m.type + '"' + + ',"x":' + m.x + ',"y":' + m.y + + (if m.type == "circle" then ',"radius":' + m.radius else "") + + (if m.type == "rectangle" then ',"w":' + m.w + ',"h":' + m.h else "") + + (if m.type == "polygon" then + ',"vertices":[' + std.join(",", ['{"x":' + v.x + ',"y":' + v.y + '}' for v in m.vertices]) + ']' + else "") + + "}"; + +local markers_json_array(markers) = + "[" + std.join(",", [marker_json(m) for m in markers]) + "]"; + +local header = "index,name,path,atlas_index,atlas_path,x,y,w,h,pivot_x,pivot_y,trim_left,trim_top,trim_right,trim_bottom,marker_count,markers_json,rotation\n"; + +local sprite_row(s) = + "" + s.index + "," + + csv_escape(s.name) + "," + + csv_escape(s.path) + "," + + s.atlas_index + "," + + csv_escape(s.atlas_path) + "," + + s.x + "," + s.y + "," + s.w + "," + s.h + "," + + s.pivot_x + "," + s.pivot_y + "," + + s.trim_left + "," + s.trim_top + "," + s.trim_right + "," + s.trim_bottom + "," + + std.length(s.markers) + "," + + markers_json_array(s.markers) + "," + + (if s.rotated then "90" else "0") + "\n"; + +local marker_row(m) = + "marker," + m.index + "," + + csv_escape(m.name) + "," + + m.type + "," + + m.x + "," + m.y + "," + m.radius + "," + m.w + "," + m.h + "," + + marker_vertices_csv(m.vertices) + "," + + m.sprite_index + "," + + csv_escape(m.sprite_name) + "," + + csv_escape(m.sprite_path) + "\n"; + +local anim_row(a) = + if a.is_alias then + "animation," + a.index + "," + csv_escape(a.name) + ",alias," + + csv_escape(a.alias_source) + + (if a.flip != "" then "," + a.flip else "") + "\n" + else + "animation," + a.index + "," + csv_escape(a.name) + "," + a.fps + "," + + std.join("|", ["" + idx for idx in a.frame_indices]) + + (if a.flip != "" then "," + a.flip else "") + "\n"; + +local body = + std.join("", [sprite_row(s) for s in sprat.sprites]) + + std.join("", [marker_row(m) for m in sprat.markers]) + + std.join("", [anim_row(a) for a in sprat.animations]); + +{ + name: "CSV", + description: "CSV rows for spreadsheets and data tools", + extension: ".csv", + content: header + body, +} diff --git a/transforms/csv.transform b/build_test/transforms/csv.transform similarity index 80% rename from transforms/csv.transform rename to build_test/transforms/csv.transform index 3456d9d..44e26a0 100644 --- a/transforms/csv.transform +++ b/build_test/transforms/csv.transform @@ -1,5 +1,5 @@ [meta] -name=csv +name=CSV description=CSV rows for spreadsheets and data tools extension=.csv [/meta] @@ -14,40 +14,24 @@ index,name,path,atlas_index,atlas_path,x,y,w,h,pivot_x,pivot_y,trim_left,trim_to {{index}},{{name}},{{path}},{{atlas_index}},{{atlas_path}},{{x}},{{y}},{{w}},{{h}},{{pivot_x}},{{pivot_y}},{{trim_left}},{{trim_top}},{{trim_right}},{{trim_bottom}},{{sprite_markers_count}},{{markers_json}},{{rotation}} [/sprite] - [/sprites] [separator] [/separator] -[if_markers] -[/if_markers] - -# markers - [markers] [marker] marker,{{marker_index}},{{marker_name}},{{marker_type}},{{marker_x}},{{marker_y}},{{marker_radius}},{{marker_w}},{{marker_h}},{{marker_vertices}},{{marker_sprite_index}},{{marker_sprite_name}},{{marker_sprite_path}} - [/marker] - [/markers] [markers_separator] [/markers_separator] -[if_animations] -[/if_animations] - -# animations - [animations] [animation] -animation,{{animation_index}},{{animation_name}},{{fps}},{{sprite_indexes}} - +[if is_alias="false"]animation,{{animation_index}},{{animation_name}},{{fps}},{{sprite_indexes}}[/if][if is_alias="true"]animation,{{animation_index}},{{animation_name}},alias,{{animation_alias}}[/if][if flip!=""],{{flip}}[/if] [/animation] - - [/animations] [animations_separator] diff --git a/build_test/transforms/godot.jsonnet b/build_test/transforms/godot.jsonnet new file mode 100644 index 0000000..4be4e0c --- /dev/null +++ b/build_test/transforms/godot.jsonnet @@ -0,0 +1,39 @@ +// godot.jsonnet – Godot-compatible JSON sprite sheet. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_obj(s) = { + name: s.name, + region: { x: s.x, y: s.y, w: s.w, h: s.h }, + margin: { left: s.trim_left, top: s.trim_top, right: s.trim_right, bottom: s.trim_bottom }, + source_size: { w: s.source_w, h: s.source_h }, + rotated: s.rotated, + pivot_offset: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +// Godot animations use from/to indices (first and last frame index). +local anim_obj(a) = { + name: a.name, + from: if std.length(a.frame_indices) > 0 then a.frame_indices[0] else 0, + to: if std.length(a.frame_indices) > 0 then a.frame_indices[std.length(a.frame_indices) - 1] else 0, + speed: a.fps, + loop: true, +}; + +local result = + { + image: sprat.atlas_path, + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: sprat.scale, + frames: [frame_obj(s) for s in sprat.sprites], + } + + (if sprat.has_animations then { + animations: [anim_obj(a) for a in sprat.animations], + } else {}); + +{ + name: "Godot", + description: "Godot-compatible JSON sprite sheet (load at runtime with AtlasTexture/SpriteFrames)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/build_test/transforms/json.jsonnet b/build_test/transforms/json.jsonnet new file mode 100644 index 0000000..e69a482 --- /dev/null +++ b/build_test/transforms/json.jsonnet @@ -0,0 +1,40 @@ +// json.jsonnet – Generic JSON metadata format. +local sprat = std.extVar("sprat"); + +local sprite_obj(s) = { + name: s.name, + path: s.path, + atlas_index: s.atlas_index, + rect: { x: s.x, y: s.y, w: s.w, h: s.h }, + pivot: { x: s.pivot_x, y: s.pivot_y }, + trim: { left: s.trim_left, top: s.trim_top, right: s.trim_right, bottom: s.trim_bottom }, + markers: s.markers, + rotation: if s.rotated then 90 else 0, +}; + +local anim_obj(a) = + if a.is_alias then + { name: a.name, alias: a.alias_source } + + (if a.flip != "" then { flip: a.flip } else {}) + else + { name: a.name, fps: a.fps, sprite_indexes: a.frame_indices, sprite_names: [f.name for f in a.frames] } + + (if a.flip != "" then { flip: a.flip } else {}); + +local atlas_obj(at) = { width: at.width, height: at.height, path: at.path }; + +local result = { + multipack: sprat.multipack, + scale: sprat.scale, + extrude: sprat.extrude, + atlases: [atlas_obj(at) for at in sprat.atlases], + sprites: [sprite_obj(s) for s in sprat.sprites], +} + (if sprat.has_animations then { + animations: [anim_obj(a) for a in sprat.animations], +} else {}); + +{ + name: "JSON", + description: "JSON metadata for scripting and runtime loading", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/json.transform b/build_test/transforms/json.transform similarity index 67% rename from transforms/json.transform rename to build_test/transforms/json.transform index 1cfc48c..09e202a 100644 --- a/transforms/json.transform +++ b/build_test/transforms/json.transform @@ -1,5 +1,5 @@ [meta] -name=json +name=JSON description=JSON metadata for scripting and runtime loading extension=.json [/meta] @@ -22,10 +22,7 @@ extension=.json { "width": {{atlas_width}}, "height": {{atlas_height}}, - "path": "{{atlas_path}}", - "sprites": [ - {{sprites}} - ] + "path": "{{atlas_path}}" } [/atlas] @@ -36,13 +33,16 @@ extension=.json [atlas_footer] + ], + "sprites": [ + {{sprites}} ] [/atlas_footer] [/atlases] [sprites] [sprite] -{"name": "{{name}}", "path": "{{path}}", "atlas_index": {{atlas_index}}, "x": {{x}}, "y": {{y}}, "w": {{w}}, "h": {{h}}, "pivot_x": {{pivot_x}}, "pivot_y": {{pivot_y}}, "trim_left": {{trim_left}}, "trim_top": {{trim_top}}, "trim_right": {{trim_right}}, "trim_bottom": {{trim_bottom}}, "markers": [{{sprite_markers}}], "rotation": {{rotation}}} +{"name": "{{name}}", "path": "{{path}}", "atlas_index": {{atlas_index}}, "rect": {"x": {{x}}, "y": {{y}}, "w": {{w}}, "h": {{h}}}, "pivot": {"x": {{pivot_x}}, "y": {{pivot_y}}}, "trim": {"left": {{trim_left}}, "top": {{trim_top}}, "right": {{trim_right}}, "bottom": {{trim_bottom}}}, "markers": [{{sprite_markers}}], "rotation": {{rotation}}} [/sprite] [/sprites] @@ -62,14 +62,14 @@ extension=.json [if_markers] [/if_markers] -[if_animations] +[if has_animations="true"] , "animations": [ -[/if_animations] +[/if] [animations] [animation] - {"name": "{{animation_name}}", "fps": {{fps}}, "sprite_indexes": {{sprite_indexes}}} + {"name": "{{animation_name}}"[if is_alias="false"], "fps": {{fps}}, "sprite_indexes": {{sprite_indexes}}, "sprite_names": {{sprite_names}}[/if][if is_alias="true"], "alias": "{{animation_alias}}"[/if][if flip!=""], "flip": "{{flip}}"[/if]} [/animation] [/animations] @@ -81,8 +81,8 @@ extension=.json ] [/animations_footer] -[if_no_animations] -[/if_no_animations] +[if has_animations="false"] +[/if] [footer] } diff --git a/build_test/transforms/libgdx.jsonnet b/build_test/transforms/libgdx.jsonnet new file mode 100644 index 0000000..f03cda3 --- /dev/null +++ b/build_test/transforms/libgdx.jsonnet @@ -0,0 +1,26 @@ +// libgdx.jsonnet – LibGDX TextureAtlas format (.atlas). +local sprat = std.extVar("sprat"); + +local sprite_entry(s) = + s.name + "\n" + + " rotate: " + s.rotated + "\n" + + " xy: " + s.x + ", " + s.y + "\n" + + " size: " + s.w + ", " + s.h + "\n" + + " orig: " + s.source_w + ", " + s.source_h + "\n" + + " offset: " + s.trim_left + ", " + s.trim_bottom + "\n" + + " index: -1\n"; + +local atlas_block(at) = + at.path + "\n" + + "size: " + at.width + "," + at.height + "\n" + + "format: RGBA8888\n" + + "filter: Nearest,Nearest\n" + + "repeat: none\n\n" + + std.join("", [sprite_entry(s) for s in at.sprites]); + +{ + name: "LibGDX", + description: "LibGDX TextureAtlas format (.atlas); animation data is not part of this format", + extension: ".atlas", + content: std.join("", [atlas_block(at) for at in sprat.atlases]), +} diff --git a/build_test/transforms/phaser-anims.jsonnet b/build_test/transforms/phaser-anims.jsonnet new file mode 100644 index 0000000..68ec4aa --- /dev/null +++ b/build_test/transforms/phaser-anims.jsonnet @@ -0,0 +1,22 @@ +// phaser-anims.jsonnet – Phaser 3 animation manager JSON. +local sprat = std.extVar("sprat"); + +local frame_ref(f) = { key: sprat.atlas_stem, frame: f.name }; + +local anim_obj(a) = { + key: a.name, + frameRate: a.fps, + repeat: -1, + frames: [frame_ref(f) for f in a.frames], +}; + +local result = { + anims: [anim_obj(a) for a in sprat.animations], +}; + +{ + name: "Phaser Animations", + description: "Phaser 3 animation manager JSON (load separately via this.anims.fromJSON(); requires --atlas so frame keys resolve to the correct texture)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/build_test/transforms/phaser-array.jsonnet b/build_test/transforms/phaser-array.jsonnet new file mode 100644 index 0000000..72d98cf --- /dev/null +++ b/build_test/transforms/phaser-array.jsonnet @@ -0,0 +1,29 @@ +// phaser-array.jsonnet – Phaser 3 JSON Array atlas format. +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + filename: s.name, + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +local result = { + frames: [frame_obj(s) for s in sprat.sprites], + meta: { + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Phaser JSON Array", + description: "Phaser 3 atlas format (JSON Array, compatible with TexturePacker JSON Array output)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/build_test/transforms/phaser-hash.jsonnet b/build_test/transforms/phaser-hash.jsonnet new file mode 100644 index 0000000..d23415c --- /dev/null +++ b/build_test/transforms/phaser-hash.jsonnet @@ -0,0 +1,34 @@ +// phaser-hash.jsonnet – Phaser 3 JSON Hash atlas format. +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +local frames_obj = std.foldl( + function(acc, s) acc { [s.name]: frame_obj(s) }, + sprat.sprites, + {} +); + +local result = { + frames: frames_obj, + meta: { + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Phaser JSON Hash", + description: "Phaser 3 atlas format (JSON Hash, compatible with TexturePacker JSON Hash output)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/build_test/transforms/plist.jsonnet b/build_test/transforms/plist.jsonnet new file mode 100644 index 0000000..2a65097 --- /dev/null +++ b/build_test/transforms/plist.jsonnet @@ -0,0 +1,68 @@ +// plist.jsonnet – Cocos2d-x TextureAtlas plist format (format 2). +local sprat = std.extVar("sprat"); + +local xml_escape(s) = + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace(s, "&", "&"), + "<", "<"), + ">", ">"), + '"', """), + "'", "'"); + +local sprite_entry(s) = + local content_w = s.content_w; + local content_h = s.content_h; + local source_w = s.source_w; + local source_h = s.source_h; + local cx = std.floor((s.trim_left - s.trim_right) / 2); + local cy = std.floor((s.trim_bottom - s.trim_top) / 2); + local plist_frame = "{" + s.x + "," + s.y + "},{" + s.w + "," + s.h + "}"; + local plist_offset = "{" + cx + "," + cy + "}"; + local plist_source_color_rect = "{" + s.trim_left + "," + s.trim_top + "},{" + content_w + "," + content_h + "}"; + local plist_source_size = "{" + source_w + "," + source_h + "}"; + '\t\t' + xml_escape(s.name) + '\n' + + '\t\t\n' + + '\t\t\tframe\n' + + '\t\t\t' + plist_frame + '\n' + + '\t\t\toffset\n' + + '\t\t\t' + plist_offset + '\n' + + '\t\t\trotated\n' + + '\t\t\t' + (if s.rotated then '' else '') + '\n' + + '\t\t\tsourceColorRect\n' + + '\t\t\t' + plist_source_color_rect + '\n' + + '\t\t\tsourceSize\n' + + '\t\t\t' + plist_source_size + '\n' + + '\t\t\n'; + +local plist_atlas_size = "{" + sprat.atlas_width + "," + sprat.atlas_height + "}"; + +{ + name: "plist", + description: "Cocos2d-x TextureAtlas plist format (format 2)", + extension: ".plist", + content: + '\n' + + '\n' + + '\n' + + '\n' + + '\tframes\n' + + '\t\n\n' + + std.join("", [sprite_entry(s) for s in sprat.sprites]) + + '\t\n' + + '\tmetadata\n' + + '\t\n' + + '\t\tformat\n' + + '\t\t2\n' + + '\t\trealTextureFileName\n' + + '\t\t' + xml_escape(sprat.atlas_path) + '\n' + + '\t\tsize\n' + + '\t\t' + plist_atlas_size + '\n' + + '\t\ttextureFileName\n' + + '\t\t' + xml_escape(sprat.atlas_path) + '\n' + + '\t\n' + + '\n' + + '\n', +} diff --git a/build_test/transforms/sprat.libsonnet b/build_test/transforms/sprat.libsonnet new file mode 100644 index 0000000..9a48001 --- /dev/null +++ b/build_test/transforms/sprat.libsonnet @@ -0,0 +1,33 @@ +// sprat.libsonnet – shared helpers for all sprat Jsonnet transforms. +{ + // Format a double like C's %.8g: up to 8 decimal places, no trailing zeros. + // Jsonnet v0.20.0 has a known bug with %g format; we use %f + trim instead. + format_double(v):: + local s = std.format("%.8f", v); + local rtrim(str) = + if std.length(str) == 0 then "0" + else if str[std.length(str) - 1] == "0" then rtrim(std.substr(str, 0, std.length(str) - 1)) + else if str[std.length(str) - 1] == "." then std.substr(str, 0, std.length(str) - 1) + else str; + rtrim(s), + + // Split an array of frame indices into contiguous runs. + // Returns [{from: N, to: M}, ...]. + consecutive_runs(indices):: + if std.length(indices) == 0 then [] + else + local fold_result = std.foldl( + function(acc, idx) + if acc.current_end == idx - 1 then + acc { current_end: idx } + else + acc { + runs: acc.runs + [{from: acc.current_start, to: acc.current_end}], + current_start: idx, + current_end: idx, + }, + indices[1:], + { runs: [], current_start: indices[0], current_end: indices[0] } + ); + fold_result.runs + [{from: fold_result.current_start, to: fold_result.current_end}], +} diff --git a/build_test/transforms/unity.anim.jsonnet b/build_test/transforms/unity.anim.jsonnet new file mode 100644 index 0000000..90dbabd --- /dev/null +++ b/build_test/transforms/unity.anim.jsonnet @@ -0,0 +1,71 @@ +// unity.anim.jsonnet – Unity AnimationClip (.anim) per-animation files. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_entry(frame, i, fps) = + " - time: " + lib.format_double(i / fps) + "\n" + + " value: {fileID: " + frame.name_hash_decimal + + ", guid: " + sprat.output_stem_hash_hex + "0000000000000000, type: 3}\n"; + +local render_clip(anim) = + local eff_fps = if anim.fps > 0 then anim.fps else 8; + "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n" + + "--- !u!74 &740000" + anim.index + "\n" + + "AnimationClip:\n" + + " m_ObjectHideFlags: 0\n" + + " m_CorrespondingSourceObject: {fileID: 0}\n" + + " m_PrefabInstance: {fileID: 0}\n" + + " m_PrefabAsset: {fileID: 0}\n" + + " m_Name: " + anim.name + "\n" + + " serializedVersion: 6\n" + + " m_Legacy: 0\n" + + " m_Compressed: 0\n" + + " m_UseHighQualityCurve: 1\n" + + " m_RotationCurves: []\n" + + " m_CompressedRotationCurves: []\n" + + " m_EulerCurves: []\n" + + " m_PositionCurves: []\n" + + " m_ScaleCurves: []\n" + + " m_FloatCurves: []\n" + + " m_PPtrCurves:\n" + + " - curve:\n" + + std.join("", [frame_entry(anim.frames[i], i, eff_fps) for i in std.range(0, std.length(anim.frames) - 1)]) + + " attribute: m_Sprite\n" + + " path:\n" + + " classID: 212\n" + + " script: {fileID: 0}\n" + + " m_AnimationClipSettings:\n" + + " serializedVersion: 2\n" + + " m_AdditiveReferencePoseClip: {fileID: 0}\n" + + " m_AdditiveReferencePoseTime: 0\n" + + " m_StartTime: 0\n" + + " m_StopTime: " + lib.format_double(anim.duration) + "\n" + + " m_OrientationOffsetY: 0\n" + + " m_Level: 0\n" + + " m_CycleOffset: 0\n" + + " m_HasAdditiveReferencePose: 0\n" + + " m_LoopTime: 1\n" + + " m_LoopBlend: 0\n" + + " m_LoopBlendOrientation: 0\n" + + " m_LoopBlendPositionY: 0\n" + + " m_LoopBlendPositionXZ: 0\n" + + " m_KeepOriginalOrientation: 0\n" + + " m_KeepOriginalPositionY: 1\n" + + " m_KeepOriginalPositionXZ: 0\n" + + " m_HeightFromFeet: 0\n" + + " m_Mirror: 0\n" + + " m_EditorCurves: []\n" + + " m_EulerEditorCurves: []\n" + + " m_HasGenericRootTransform: 0\n" + + " m_HasMotionFloatCurves: 0\n" + + " m_Events: []\n"; + +{ + name: "Unity AnimationClip", + description: "Unity AnimationClip (.anim) sprite animation; GUIDs match the unity.meta transform output; use --output-dir to write one .anim file per animation", + extension: ".anim", + files: [ + { filename: anim.name + ".anim", content: render_clip(anim) } + for anim in sprat.animations + ], +} diff --git a/build_test/transforms/unity.json.jsonnet b/build_test/transforms/unity.json.jsonnet new file mode 100644 index 0000000..51ea091 --- /dev/null +++ b/build_test/transforms/unity.json.jsonnet @@ -0,0 +1,36 @@ +// unity.json.jsonnet – Unity-compatible JSON sprite sheet (TexturePacker JSON Hash format). +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm }, +}; + +local frames_obj = std.foldl( + function(acc, s) acc { [s.name]: frame_obj(s) }, + sprat.sprites, + {} +); + +local result = { + frames: frames_obj, + meta: { + app: "https://github.com/pedroac/sprat-cli", + version: "1.0", + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Unity JSON", + description: "Unity-compatible JSON sprite sheet (TexturePacker JSON Hash format with normalized pivots)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/build_test/transforms/unity.meta.jsonnet b/build_test/transforms/unity.meta.jsonnet new file mode 100644 index 0000000..59ebf00 --- /dev/null +++ b/build_test/transforms/unity.meta.jsonnet @@ -0,0 +1,114 @@ +// unity.meta.jsonnet – Unity .meta file spriteSheet section (YAML). +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local id_entry(s) = + " - first:\n" + + " 213: " + s.name_hash_decimal + "\n" + + " second: " + s.name + "\n"; + +local sprite_rect_entry(s) = + " - serializedVersion: 2\n" + + " name: " + s.name + "\n" + + " rect:\n" + + " serializedVersion: 2\n" + + " x: " + s.x + "\n" + + " y: " + s.unity_y + "\n" + + " width: " + s.content_w + "\n" + + " height: " + s.content_h + "\n" + + " alignment: 9\n" + + " pivot: {x: " + lib.format_double(s.pivot_x_norm) + ", y: " + lib.format_double(s.pivot_y_norm) + "}\n" + + " border: {x: 0, y: 0, z: 0, w: 0}\n" + + " outline: []\n" + + " physicsShape: []\n" + + " tessellationDetail: 0\n" + + " bones: []\n" + + " spriteID: " + s.name_hash_hex + "\n" + + " internalID: " + s.name_hash_decimal + "\n" + + " vertices: []\n" + + " indices:\n" + + " edges: []\n" + + " weights: []\n"; + +{ + name: "Unity Meta", + description: "Unity .meta file spriteSheet section (YAML)", + extension: ".meta", + content: + "fileFormatVersion: 2\n" + + "guid: " + sprat.output_stem_hash_hex + "0000000000000000\n" + + "TextureImporter:\n" + + " internalIDToNameTable:\n" + + std.join("", [id_entry(s) for s in sprat.sprites]) + + " externalObjects: {}\n" + + " serializedVersion: 13\n" + + " mipmaps:\n" + + " mipMapMode: 0\n" + + " enableMipMap: 0\n" + + " sRGBTexture: 1\n" + + " linearTexture: 0\n" + + " fadeOut: 0\n" + + " borderMipMap: 0\n" + + " mipMapsPreserveCoverage: 0\n" + + " alphaTestReferenceValue: 0.5\n" + + " mipMapFadeDistanceStart: 1\n" + + " mipMapFadeDistanceEnd: 3\n" + + " bumpmap:\n" + + " convertToNormalMap: 0\n" + + " externalNormalMap: 0\n" + + " heightScale: 0.25\n" + + " normalMapFilter: 0\n" + + " isReadable: 0\n" + + " streamingMipmaps: 0\n" + + " streamingMipmapsPriority: 0\n" + + " vTOnly: 0\n" + + " ignoreMasterTextureLimit: 0\n" + + " vtOnly: 0\n" + + " ignoreMipmapLimit: 0\n" + + " isDirectBinding: 0\n" + + " importAsync: 0\n" + + " filterMode: 0\n" + + " aniso: 1\n" + + " mipBias: 0\n" + + " textureType: 8\n" + + " textureShape: 1\n" + + " singleChannelComponent: 0\n" + + " flipbookRows: 1\n" + + " flipbookColumns: 1\n" + + " maxTextureSizeSet: 0\n" + + " compressionQuality: 50\n" + + " textureFormat: -1\n" + + " uncompressed: 0\n" + + " alphaUsage: 1\n" + + " alphaIsTransparency: 1\n" + + " spriteMode: 2\n" + + " spriteExtrude: 1\n" + + " spriteMeshType: 1\n" + + " alignment: 0\n" + + " spritePivot: {x: 0.5, y: 0.5}\n" + + " spritePixelsToUnits: 100\n" + + " spriteBorder: {x: 0, y: 0, z: 0, w: 0}\n" + + " spriteGenerateFallbackPhysicsShape: 1\n" + + " alphaTestReferenceValue: 0.5\n" + + " mipMapFadeDistanceStart: 1\n" + + " mipMapFadeDistanceEnd: 3\n" + + " spriteSheet:\n" + + " serializedVersion: 2\n" + + " sprites:\n" + + std.join("", [sprite_rect_entry(s) for s in sprat.sprites]) + + " outline: []\n" + + " physicsShape: []\n" + + " bones: []\n" + + " spriteID:\n" + + " internalID: 0\n" + + " vertices: []\n" + + " indices:\n" + + " edges: []\n" + + " weights: []\n" + + " spritePackingTag:\n" + + " pSDRemoveMatte: 0\n" + + " pSDShowRemoveMatteOption: 0\n" + + " userData:\n" + + " assetBundleName:\n" + + " assetBundleVariant:\n", +} diff --git a/build_test/transforms/xml.jsonnet b/build_test/transforms/xml.jsonnet new file mode 100644 index 0000000..e2440e8 --- /dev/null +++ b/build_test/transforms/xml.jsonnet @@ -0,0 +1,82 @@ +// xml.jsonnet – XML layout format for engine import pipelines. +local sprat = std.extVar("sprat"); + +local xml_escape(s) = + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace(s, "&", "&"), + "<", "<"), + ">", ">"), + '"', """), + "'", "'"); + +local marker_xml(m) = + if m.type == "point" then + '' + else if m.type == "circle" then + '' + else if m.type == "rectangle" then + '' + else if m.type == "polygon" then + '' + + std.join("|", ["" + v.x + "," + v.y for v in m.vertices]) + + '' + else ""; + +local sprite_xml(s) = + local marker_section = + if std.length(s.markers) > 0 then + "\n \n " + + std.join("\n ", [marker_xml(m) for m in s.markers]) + + "\n \n" + else ""; + '' + + marker_section + ""; + +local atlas_xml(at) = + ' \n' + + ' \n ' + + std.join("\n ", [sprite_xml(s) for s in at.sprites]) + + '\n \n '; + +local anim_xml(a) = + if a.is_alias then + ' " + else + ' '; + +local atlases_section = + ' \n' + + std.join("\n", [atlas_xml(at) for at in sprat.atlases]) + + '\n \n'; + +local animations_section = + if sprat.has_animations then + ' \n' + + std.join("\n", [anim_xml(a) for a in sprat.animations]) + + '\n \n' + else ""; + +{ + name: "XML", + description: "XML layout format for engine import pipelines", + extension: ".xml", + content: + '\n' + + '\n' + + atlases_section + + animations_section + + '\n', +} diff --git a/transforms/xml.transform b/build_test/transforms/xml.transform similarity index 81% rename from transforms/xml.transform rename to build_test/transforms/xml.transform index 8222768..4d7262e 100644 --- a/transforms/xml.transform +++ b/build_test/transforms/xml.transform @@ -1,5 +1,5 @@ [meta] -name=xml +name=XML description=XML layout format for engine import pipelines extension=.xml [/meta] @@ -36,10 +36,11 @@ extension=.xml [sprites] [sprite] - +[if sprite_markers_count!="0"] {{sprite_markers}} +[/if] [/sprite] @@ -60,13 +61,13 @@ extension=.xml [if_markers] [/if_markers] -[if_animations] +[if has_animations="true"] -[/if_animations] +[/if] [animations] [animation] - + [if is_alias="false"][/if][if is_alias="true"][/if] [/animation] [/animations] diff --git a/man/sprat-cli.1 b/man/sprat-cli.1 index 27f32bd..562b56f 100644 --- a/man/sprat-cli.1 +++ b/man/sprat-cli.1 @@ -1,18 +1,17 @@ -.TH SPRAT-CLI 1 "March 2026" "sprat-cli" "User Commands" +.TH SPRAT-CLI 1 "May 2026" "sprat-cli" "User Commands" .SH NAME sprat-cli \- sprite atlas layout and packing pipeline .SH SYNOPSIS .B spratlayout -.I folder +.I folder|file|- [\fB\-\-profile\fR \fINAME\fR] [\fB\-\-profiles\-config\fR \fIPATH\fR] -[\fB\-\-mode\fR compact|pot|fast] +[\fB\-\-mode\fR compact|pot|fast|grid] [\fB\-\-optimize\fR gpu|space] [\fB\-\-max\-width\fR \fIN\fR] [\fB\-\-max\-height\fR \fIN\fR] [\fB\-\-padding\fR \fIN\fR] [\fB\-\-extrude\fR \fIN\fR] -[\fB\-\-max\-combinations\fR \fIN\fR] [\fB\-\-source\-resolution\fR \fIWxH\fR] [\fB\-\-target\-resolution\fR \fIWxH\fR] [\fB\-\-resolution\-reference\fR largest|smallest] @@ -25,7 +24,7 @@ sprat-cli \- sprite atlas layout and packing pipeline [\fB\-\-debug\fR] .PP .B spratpack -[\fB\-o\fR \fIPATTERN\fR] +[\fB\-a\fR \fIPATTERN\fR] [\fB\-\-atlas\-index\fR \fIN\fR] [\fB\-\-extrude\fR \fIN\fR] [\fB\-\-protect\fR] @@ -38,7 +37,8 @@ sprat-cli \- sprite atlas layout and packing pipeline .PP .B spratconvert [\fB\-\-transform\fR \fINAME|PATH\fR] -[\fB\-\-output\fR \fIPATTERN\fR] +[\fB\-\-atlas\fR \fIPATTERN\fR] +[\fB\-\-output\-dir\fR \fIPATH\fR] [\fB\-\-list\-transforms\fR] [\fB\-\-markers\fR \fIPATH\fR] [\fB\-\-animations\fR \fIPATH\fR] @@ -62,20 +62,22 @@ sprat-cli \- sprite atlas layout and packing pipeline .SH DESCRIPTION \fBsprat\-cli\fR is a UNIX pipeline for generating sprite sheets and transforming layout metadata. .PP -\fBspratlayout\fR scans an image folder/list/tar input and writes a text layout to standard output. +\fBspratlayout\fR scans an image folder, list file, or TAR stream from stdin and writes a text layout to standard output. .PP -\fBspratpack\fR reads the layout from standard input and writes one or more PNG atlases. +\fBspratpack\fR reads the layout from standard input and writes one or more PNG atlases to standard output (or to files when \fB\-a\fR is used). .PP -\fBspratconvert\fR reads the same layout format from standard input and transforms it into text formats (for example JSON, CSV, XML, CSS) using template-driven transforms. +\fBspratconvert\fR reads the same layout format from standard input and transforms it into text formats (for example JSON, CSV, XML, CSS) using template-driven transforms, writing the result to standard output. .PP \fBspratframes\fR detects sprite rectangles in a sprite sheet and writes them to standard output in the spratframes format. .PP -\fBspratunpack\fR extracts sprites from an atlas using a frames definition file. If no output directory is specified, it writes a TAR stream to stdout. +\fBspratunpack\fR extracts sprites from an atlas using a frames definition file. +Atlas input is a file path, \fB\-\fR for an explicit stdin read, or standard input when no atlas path is given. +If \fB\-\-output\fR is not specified, extracted sprites are written as a TAR stream to standard output. .PP Normal output is written to stdout. Errors are written to stderr. .SH COMMANDS .SS spratlayout -Reads image metadata from files in \fIfolder\fR and prints layout lines: +Reads image metadata and prints layout lines to stdout: .PP atlas \fIwidth,height\fR .br @@ -87,7 +89,13 @@ If \fB\-\-trim\-transparent\fR is enabled, trim offsets are included per sprite. .PP If rotation is used for a sprite, the sprite line includes the \fBrotated\fR token. .PP -If \fIfolder\fR is actually a text file, it is treated as a newline-delimited list of image paths (comments starting with \fB#\fR and blank lines are ignored). Relative paths are resolved relative to the list file, and each path must point to an existing image or the command fails. +Input modes: +.IP "\(bu" 2 +\fBfolder\fR \(em scans all images in the directory. +.IP "\(bu" 2 +\fBfile\fR \(em treated as a newline-delimited list of image paths when the argument points to a text file (comments starting with \fB#\fR and blank lines are ignored). Relative paths are resolved relative to the list file; each path must point to an existing image or the command fails. +.IP "\(bu" 2 +\fB\-\fR \(em reads a TAR archive from standard input, extracts it to a temporary directory, and processes the contained images. Useful for piping the output of \fBspratunpack\fR (which writes a TAR stream) directly back into \fBspratlayout\fR. .PP Multipack: .IP "\(bu" 2 @@ -97,9 +105,11 @@ Profile configuration is loaded from the first existing file in this order: .IP "\(bu" 2 \fB\-\-profiles\-config\fR path (when provided) .IP "\(bu" 2 -\fB~/.config/sprat/spratprofiles.cfg\fR (Linux/macOS) or \fB%APPDATA%\\sprat\\spratprofiles.cfg\fR (Windows) +\fBspratprofiles.cfg\fR beside the executable (portable install) .IP "\(bu" 2 -\fB./spratprofiles.cfg\fR in the current working directory +\fB$XDG_CONFIG_HOME/sprat/spratprofiles.cfg\fR (Linux, default \fB~/.config/sprat/\fR), +\fB~/Library/Application Support/sprat/spratprofiles.cfg\fR (macOS), or +\fB%APPDATA%\\sprat\\spratprofiles.cfg\fR (Windows) .IP "\(bu" 2 Global installed config (typically \fB/usr/local/share/sprat/spratprofiles.cfg\fR) .PP @@ -131,7 +141,7 @@ By default, writes a single PNG to stdout. .IP "\(bu" 2 If multiple atlases are produced and output is stdout, result is written as a TAR stream. .IP "\(bu" 2 -If \fB\-o\fR (or \fB\-\-output\fR) is used with a pattern like \fBatlas_%d.png\fR, multiple atlases are written to files. +If \fB\-a\fR (or \fB\-\-atlas\fR) is used with a pattern like \fBatlas_%d.png\fR, multiple atlases are written to files. .IP "\(bu" 2 If \fB\-\-atlas\-index\fR is used, only the specified atlas index is processed and written to stdout. .SS spratconvert @@ -139,7 +149,8 @@ Reads layout text from stdin and writes transformed text to stdout. .PP Unsuffixed placeholders are auto-encoded based on output type inferred from transform metadata (\fBmeta.extension\fR) or transform name/argument. .PP -Transforms can be selected by name from \fBtransforms/\fR (for example \fBjson\fR, \fBcsv\fR, \fBxml\fR, \fBcss\fR) or by path to a custom \fB.transform\fR file. +Transforms can be selected by name (for example \fBjson\fR, \fBcsv\fR, \fBxml\fR, \fBcss\fR) or by path to a custom \fB.transform\fR file. +Transform files are searched in the directory beside the executable, the user data directory, and the global install directory. .PP Optional \fB\-\-markers\fR and \fB\-\-animations\fR files can be loaded and referenced by transform placeholders. .PP @@ -157,24 +168,64 @@ The core structure is defined by iteration blocks: .PP Templates support marker/animation conditional sections (\fB[if_markers]\fR, \fB[if_no_markers]\fR, \fB[if_animations]\fR, \fB[if_no_animations]\fR) and optional separator/header/footer sections. .PP +A general conditional syntax \fB[if ATTR="VALUE"]...[/if]\fR (or \fB[if ATTR!="VALUE"]...[/if]\fR) is also supported. At the section level it maps \fBhas_markers\fR and \fBhas_animations\fR to the corresponding conditional blocks. Within section content it can test any current rendering variable (for example \fB[if marker_type="point"]...[/if]\fR). +.PP Placeholders: .IP "\(bu" 2 \fBSprite fields\fR: \fB{{name}}\fR (basename without extension), \fB{{path}}\fR, \fB{{x}}\fR, \fB{{y}}\fR, \fB{{w}}\fR, \fB{{h}}\fR. .IP "\(bu" 2 -\fBPivot points\fR: \fB{{pivot_x}}\fR, \fB{{pivot_y}}\fR. These are resolved from markers named \fBpivot\fR of type \fBpoint\fR (either per-sprite or global). +\fBUnity Y-coordinate\fR: \fB{{unity_y}}\fR \(em Y-coordinate flipped for Unity's bottom-left origin (\fBatlas_height\fR - \fBy\fR - \fBh\fR). +.IP "\(bu" 2 +\fBSource size\fR: \fB{{source_w}}\fR, \fB{{source_h}}\fR \(em original sprite dimensions before trimming (\fBw\fR + \fBtrim_left\fR + \fBtrim_right\fR, and \fBh\fR + \fBtrim_top\fR + \fBtrim_bottom\fR). Required by formats such as Phaser Atlas JSON and Unity TexturePacker JSON. +.IP "\(bu" 2 +\fBTrim fields\fR: \fB{{trim_left}}\fR, \fB{{trim_top}}\fR, \fB{{trim_right}}\fR, \fB{{trim_bottom}}\fR (pixels removed from each edge), \fB{{src_x}}\fR/\fB{{src_y}}\fR (aliases for \fBtrim_left\fR/\fBtrim_top\fR), \fB{{has_trim}}\fR (\fBtrue\fR when any trim value is non-zero). Pre-computed plist helpers: \fB{{plist_frame}}\fR (\fB{x,y},{w,h}\fR), \fB{{plist_offset}}\fR (Cocos2d center offset), \fB{{plist_source_color_rect}}\fR (\fB{trim_left,trim_top},{w,h}\fR), \fB{{plist_source_size}}\fR (\fB{source_w,source_h}\fR). \fB{{rotated_plist}}\fR outputs \fB\fR or \fB\fR for use in XML/plist boolean elements. \fB{{plist_atlas_size}}\fR is a global field (\fB{atlas_width,atlas_height}\fR). +.IP "\(bu" 2 +\fBPivot points\fR: \fB{{pivot_x}}\fR, \fB{{pivot_y}}\fR. These are resolved from markers named \fBpivot\fR of type \fBpoint\fR (either per-sprite or global). \fB{{pivot_x_norm}}\fR, \fB{{pivot_y_norm}}\fR provide normalized coordinates (0.0 to 1.0). \fB{{pivot_y_norm}}\fR is flipped for Unity's coordinate system (1.0 - py/source_h). \fB{{pivot_y_norm_raw}}\fR provides normalized Y without flipping. +.IP "\(bu" 2 +\fBStable IDs\fR: \fB{{name_hash}}\fR, \fB{{name_hash_hex}}\fR \(em FNV-1a hash of the sprite name, useful for engine-specific internal IDs (such as Unity's spriteID). .IP "\(bu" 2 \fBSprite rotation\fR: \fB{{rotated}}\fR (\fBtrue\fR when the sprite was packed rotated, else \fBfalse\fR). .IP "\(bu" 2 \fBMarker fields\fR: \fB{{marker_name}}\fR, \fB{{marker_type}}\fR, \fB{{marker_sprite_index}}\fR, \fB{{marker_sprite_name}}\fR. .IP "\(bu" 2 -\fBAnimation fields\fR: \fB{{animation_name}}\fR, \fB{{animation_sprite_indexes_json}}\fR. +\fBAnimation fields\fR: \fB{{animation_name}}\fR, \fB{{animation_sprite_indexes_json}}\fR, \fB{{sprite_names}}\fR/\fB{{sprite_names_json}}\fR/\fB{{sprite_names_csv}}\fR (display names of frame sprites), \fB{{animation_from}}\fR/\fB{{animation_to}}\fR (first and last sprite index of the animation, useful for range-based formats such as Aseprite frameTags). .IP "\(bu" 2 Typed placeholders (for example \fB{{name_json}}\fR, \fB{{marker_vertices_xml}}\fR) are explicit and recommended. .PP -Built-in JSON transform output omits \fBindex\fR fields from \fBsprites[]\fR and \fBanimations[]\fR. +The built-in JSON transform produces a top-level \fBatlases\fR array (each entry has \fBwidth\fR, \fBheight\fR, \fBpath\fR) and a flat top-level \fBsprites\fR array. Sprite spatial data is grouped into nested objects: \fBrect\fR (\fBx\fR, \fBy\fR, \fBw\fR, \fBh\fR), \fBpivot\fR (\fBx\fR, \fBy\fR), and \fBtrim\fR (\fBleft\fR, \fBtop\fR, \fBright\fR, \fBbottom\fR). Index fields are omitted from \fBsprites[]\fR and \fBanimations[]\fR entries. +.PP +The \fBAseprite\fR transform produces an Aseprite JSON Array sprite sheet. When animations are present the \fBframeTags\fR array is populated using \fB{{animation_from}}\fR/\fB{{animation_to}}\fR sprite indices. +.PP +The \fBGodot\fR transform produces a JSON file suitable for runtime loading in Godot 4 via GDScript: +.PP +.nf +func make_sprite_frames(json_path: String) -> SpriteFrames: + var data = JSON.parse_string(FileAccess.get_file_as_string(json_path)) + var atlas = load("res://" + data.image) + var sf = SpriteFrames.new() + sf.remove_animation("default") + for anim in data.animations: + sf.add_animation(anim.name) + sf.set_animation_speed(anim.name, anim.speed) + for i in range(anim.from, anim.to + 1): + var f = data.frames[i] + var tex = AtlasTexture.new() + tex.atlas = atlas + tex.region = Rect2(f.region.x, f.region.y, f.region.w, f.region.h) + tex.margin = Rect2(f.margin.left, f.margin.top, + f.margin.right, f.margin.bottom) + sf.add_frame(anim.name, tex) + return sf +.fi +.PP +Each frame entry contains \fBregion\fR (atlas coordinates), \fBmargin\fR (trim offsets: left/top/right/bottom), \fBsource_size\fR, and \fBrotated\fR. Animation entries contain \fBfrom\fR/\fBto\fR frame indices, \fBspeed\fR (fps), and \fBloop: true\fR. +.PP +The \fBLibGDX\fR transform produces a LibGDX \fBTextureAtlas\fR \fB.atlas\fR file. The \fBoffset\fR field uses LibGDX's y-up convention: \fBoffset: {{trim_left}}, {{trim_bottom}}\fR. Animations are not encoded in the \fB.atlas\fR format; use sprite name suffixes (e.g. \fBwalk_0\fR, \fBwalk_1\fR) and \fBAnimation\fR in code instead. +.PP +The \fBplist\fR transform produces a Cocos2d-x TextureAtlas property list (format 2). Per-sprite values use Cocos2d's \fB{x,y}\fR and \fB{x,y},{w,h}\fR notation inside \fB\fR elements. The \fBoffset\fR is the center offset of the trimmed region from the original sprite center (y-up): \fB{(trim_left\-trim_right)/2, (trim_bottom\-trim_top)/2}\fR. These values are available as \fB{{plist_frame}}\fR, \fB{{plist_offset}}\fR, \fB{{plist_source_color_rect}}\fR, \fB{{plist_source_size}}\fR, and \fB{{plist_atlas_size}}\fR when writing custom transforms targeting this format. .SS spratframes Scans \fIinput_image\fR and detects sprite boundaries by finding non-transparent connected components or by detecting rectangles of a specific color. -Prints layout-compatible lines: +Prints layout-compatible lines to stdout: .PP path \fIinput_image\fR .br @@ -183,8 +234,19 @@ path \fIinput_image\fR sprite \fIx,y\fR \fIw,h\fR .SS spratunpack Extracts individual sprites from an \fIatlas.png\fR using a frames definition (\fB.json\fR or \fB.spratframes\fR). -Atlas input can be a file path, \fB\-\fR, or standard input (when no atlas path is given). -If \fB\-\-frames\fR is not specified, it looks for \fB.json\fR or \fB.spratframes\fR (path input only). +.PP +Atlas input: +.IP "\(bu" 2 +A file path reads the atlas from disk. If \fB\-\-frames\fR is not specified, it looks for \fB.json\fR or \fB.spratframes\fR alongside the atlas. +.IP "\(bu" 2 +\fB\-\fR or omitting the atlas argument reads the atlas PNG from standard input. \fB\-\-frames\fR is required in this case. +.PP +Output: +.IP "\(bu" 2 +If \fB\-\-output\fR is specified, extracted sprites are written as individual PNG files to that directory. +.IP "\(bu" 2 +If \fB\-\-output\fR is omitted, sprites are written as a TAR stream to standard output, which can be piped to \fBspratlayout \-\fR for further processing. +.PP Supports de-obfuscation of protected atlases (starting with "SPRAT!" signature). .SH OPTIONS .SS spratlayout @@ -195,8 +257,9 @@ Profile name loaded from profile config. Default: \fBfast\fR. \fB\-\-profiles\-config\fR \fIPATH\fR Use an explicit profile configuration file. .TP -\fB\-\-mode\fR compact|pot|fast +\fB\-\-mode\fR compact|pot|fast|grid Override packing mode from selected profile. +\fBgrid\fR places every sprite in a uniform cell (sized to the largest frame) arranged left-to-right, top-to-bottom, so any frame can be addressed by column and row index. .TP \fB\-\-optimize\fR gpu|space Override optimization target from selected profile. @@ -219,9 +282,6 @@ Extra pixels between packed sprites. Overrides profile value. \fB\-\-extrude\fR \fIN\fR Repeat edge pixels N times (padding should be >= extrude * 2). .TP -\fB\-\-max\-combinations\fR \fIN\fR -Maximum number of compact candidate combinations to test (\fB0\fR means auto/unlimited). Overrides profile value. -.TP \fB\-\-source\-resolution\fR \fIWxH\fR Source design resolution baseline (for example \fB800x600\fR). Must be used together with \fB\-\-target\-resolution\fR. .TP @@ -244,28 +304,31 @@ Enable 90-degree sprite rotation during packing (overrides profile value). Split into multiple atlases if they don't fit in the specified max dimensions. .TP \fB\-\-sort\fR name|none -Order of sprites in layout. Default: \fBname\fR for folders, \fBnone\fR for list/stdin. +Order of sprites in layout. \fBname\fR sorts by filename using natural ordering and enforces that order in the output regardless of packing optimizations. \fBnone\fR allows the packer to reorder sprites for better packing efficiency. Default: \fBnone\fR. .TP \fB\-\-threads\fR \fIN\fR -Worker thread count for packing. Default: auto. +Number of worker threads used during compact-mode packing (\fB\-\-preset quality\fR or \fB\-\-preset small\fR). +Has no effect when using \fBfast\fR or \fBpot\fR presets. +Defaults to the number of logical CPU cores reported by the OS. +Set to \fB1\fR to force single-threaded packing, which is useful for reproducible benchmarks or when memory is constrained. .TP \fB\-\-debug\fR Enable detailed error reporting and debug visualization. .PP Config keys available per profile section: .IP "\(bu" 2 -\fBmode=compact|pot|fast\fR +\fBmode=compact|pot|fast|grid\fR .IP "\(bu" 2 \fBoptimize=gpu|space\fR .IP "\(bu" 2 \fBmax_width\fR, \fBmax_height\fR .IP "\(bu" 2 -\fBpadding\fR, \fBmax_combinations\fR +\fBpadding\fR .IP "\(bu" 2 \fBscale\fR (\fB0 < scale <= 1\fR), \fBtrim_transparent\fR, \fBthreads\fR, \fBmultipack\fR .SS spratpack .TP -\fB\-o\fR, \fB\-\-output\fR \fIPATTERN\fR +\fB\-a\fR, \fB\-\-atlas\fR \fIPATTERN\fR Output filename pattern (e.g. \fBatlas_%d.png\fR). Required for multipack layouts when not writing to stdout. .TP \fB\-\-atlas\-index\fR \fIN\fR @@ -297,20 +360,37 @@ Enable detailed error reporting and debug visualization. .SS spratconvert .TP \fB\-\-transform\fR \fINAME|PATH\fR -Transform name from \fBtransforms/\fR or path to a custom transform file. Default: \fBjson\fR. +Transform name or path to a custom transform file. Default: \fBjson\fR. .TP -\fB\-o\fR, \fB\-\-output\fR \fIPATTERN\fR +\fB\-a\fR, \fB\-\-atlas\fR \fIPATTERN\fR Atlas path pattern used by \fB{{atlas_path}}\fR/\fB{{atlas_*}}\fR placeholders. Example: \fBatlas_%d.png\fR. .TP +\fB\-\-output\-dir\fR \fIPATH\fR +Write transform output to \fIPATH/{variant}{extension}\fR instead of stdout. +When \fB\-\-transform\fR is a plain name without a dot (for example \fBphaser\fR), \fBspratconvert\fR scans the transforms directory for files named \fBphaser.*.transform\fR and renders each one, using the part after the dot as the variant (for example \fBhash\fR becomes \fIPATH/hash.json\fR). +When a single named or path transform is given, the output stem is derived from the transform filename (for example \fBphaser.hash.transform\fR writes \fIPATH/hash.json\fR). +The placeholder \fB{{output_stem}}\fR is available inside transform templates and resolves to the variant name. +.TP \fB\-\-list\-transforms\fR Print available transforms and exit. .TP \fB\-\-markers\fR \fIPATH\fR Load external markers plaintext file using the \fBpath\fR and \fB- marker\fR DSL. +An optional \fBroot\fR directive sets a base directory; relative \fBpath\fR values are resolved against it. Supported marker types: \fBpoint\fR (\fBx,y\fR), \fBcircle\fR (\fBx,y radius\fR), \fBrectangle\fR (\fBx,y w,h\fR), \fBpolygon\fR (\fBx,y x,y ...\fR). .TP \fB\-\-animations\fR \fIPATH\fR Load external animations plaintext file using the \fBanimation\fR and \fB- frame\fR DSL. +An optional \fBroot\fR directive sets a base directory; relative quoted frame paths are resolved against it. +.PP +An animation can be declared as an alias of another animation using the \fBalias\fR keyword: +.PP +animation \fI"name"\fR alias \fI"source"\fR [\fBh-flip\fR] [\fBv-flip\fR] +.PP +Alias declarations carry no frame list; instead they reference another animation by name and attach optional rendering hints. +\fBh-flip\fR and \fBv-flip\fR are independent and order-insensitive. +The source name is stored as metadata; validation is the runtime's responsibility. +In the output, alias entries carry an \fBalias\fR field in place of \fBfps\fR and \fBsprite_indexes\fR. .TP \fB\-\-auto\-animations\fR Automatically group frames into animations by name pattern. @@ -348,50 +428,65 @@ Number of worker threads for sprite extraction. Default: auto. Enable detailed error reporting. .SH EXAMPLES .TP -Generate layout text: +Generate a layout from a folder: .B spratlayout ./frames > layout.txt .TP -Pack PNG from layout: -.B spratpack < layout.txt > spritesheet.png +Generate a layout from a list file: +.B spratlayout frames.txt > layout.txt .TP -Multipack to multiple files: -.B spratlayout ./frames --multipack --max-width 1024 --max-height 1024 | spratpack -o atlas_%d.png +Exclude files from sync/watch regeneration: +.B printf 'exclude "enemy.png"\n' > frames/.spratlayoutignore .TP -Transform layout to JSON with auto-animations: -.B spratconvert --transform json --auto-animations < layout.txt > layout.json +Pack a PNG atlas from a layout file: +.B spratpack < layout.txt > spritesheet.png .TP -Protect output atlas: -.B spratpack --protect < layout.txt > protected.png +Full pipeline (layout and pack in one step): +.B spratlayout ./frames | spratpack > spritesheet.png .TP -Unpack protected atlas: -.B spratunpack protected.png --frames atlas.json --output ./extracted +Multipack to multiple files: +.B spratlayout ./frames --multipack --max-width 1024 --max-height 1024 | spratpack -a atlas_%d.png .TP -Transform layout with custom template: -.B spratconvert --transform ./my.transform < layout.txt > layout.custom.txt +Convert layout to JSON: +.B spratconvert --transform json < layout.txt > layout.json +.TP +Convert layout to JSON with auto-animations: +.B spratconvert --transform json --auto-animations < layout.txt > layout.json .TP -Transform layout with extra files: +Convert layout with markers and animations: .B spratconvert --transform json --markers markers.txt --animations animations.txt < layout.txt > layout.json .TP -Run as one pipeline: -.B spratlayout ./frames --trim-transparent --padding 2 | spratpack > spritesheet.png +Convert with a custom transform template: +.B spratconvert --transform ./my.transform < layout.txt > layout.custom.txt .TP -Debug frame bounds: -.B spratpack --frame-lines --line-width 2 --line-color 0,255,0 < layout.txt > spritesheet_lines.png +Full pipeline including conversion: +.B spratlayout ./frames | tee layout.txt | spratconvert --transform json > layout.json && spratpack < layout.txt > spritesheet.png .TP -Limit worker threads: -.B spratlayout ./frames --threads 4 > layout.txt ; spratpack --threads 4 < layout.txt > spritesheet.png +Pack and convert in parallel from the same layout: +.B spratlayout ./frames > layout.txt && spratpack < layout.txt > spritesheet.png && spratconvert < layout.txt > layout.json .TP -Detect sprites in a sheet: -.B spratframes sheet.png > frames.spratframes +Protect output atlas: +.B spratpack --protect < layout.txt > protected.png .TP Unpack an atlas to a directory: .B spratunpack atlas.png --frames atlas.json --output ./extracted .TP -Unpack atlas to TAR (stdout): +Unpack atlas to TAR stream (stdout): .B spratunpack atlas.png > sprites.tar .TP -Unpack atlas from stdin to TAR (stdout): +Unpack atlas from stdin to TAR stream: .B cat atlas.png | spratunpack --frames atlas.json > sprites.tar +.TP +Re-pack an unpacked atlas (round-trip via TAR stdin): +.B spratunpack atlas.png | spratlayout - | spratpack > repacked.png +.TP +Pack frames from a TAR archive: +.B tar cf - ./frames | spratlayout - | spratpack > spritesheet.png +.TP +Debug frame bounds: +.B spratpack --frame-lines --line-width 2 --line-color 0,255,0 < layout.txt > spritesheet_lines.png +.TP +Detect sprites in a sheet: +.B spratframes sheet.png > frames.spratframes .SH EXIT STATUS All commands return: .TP diff --git a/spratprofiles.cfg b/spratprofiles.cfg index ff11719..8becca4 100644 --- a/spratprofiles.cfg +++ b/spratprofiles.cfg @@ -1,55 +1,59 @@ # Default spratlayout profiles. + +[profile fast] +label=Fast +mode=fast +optimize=gpu +padding=0 +trim_transparent=false + [profile desktop] +label=Desktop mode=compact optimize=gpu -padding=0 +max_width=8192 +max_height=8192 +padding=2 extrude=0 -max_combinations=0 -scale=1 trim_transparent=true -# multipack=false [profile mobile] +label=Mobile mode=compact optimize=gpu max_width=2048 max_height=2048 padding=2 extrude=0 -max_combinations=0 -scale=1 trim_transparent=true [profile legacy] +label=Legacy (POT) mode=pot -optimize=space -max_width=1024 -max_height=1024 -padding=0 -max_combinations=0 -scale=1 +optimize=gpu +max_width=2048 +max_height=2048 +padding=1 +extrude=0 trim_transparent=true [profile space] +label=Space Efficient mode=compact optimize=space padding=0 -max_combinations=0 -scale=1 +rotate=true trim_transparent=true -[profile fast] -mode=fast -optimize=gpu +[profile css] +label=CSS Sprites +mode=compact +optimize=space padding=0 -max_combinations=0 -scale=1 trim_transparent=false -[profile css] -mode=fast -optimize=space +[profile grid] +label=Grid +mode=grid padding=0 -max_combinations=0 -scale=1 trim_transparent=false diff --git a/src/commands/spratconvert_command.cpp b/src/commands/spratconvert_command.cpp index 7645626..fe278fe 100644 --- a/src/commands/spratconvert_command.cpp +++ b/src/commands/spratconvert_command.cpp @@ -16,9 +16,10 @@ #endif #endif #include +#include #include -#include #include +#include #include namespace fs = std::filesystem; #include @@ -26,7 +27,6 @@ namespace fs = std::filesystem; #include #include #include -#include #include #include #include @@ -35,37 +35,10 @@ namespace fs = std::filesystem; #include "core/cli_parse.h" #include "core/i18n.h" #include "core/output_pattern.h" +#include "core/fnv1a.h" +#include namespace { -struct Transform { - std::string name; - std::string description; - std::string extension; - std::string header; - std::string if_markers; - std::string if_no_markers; - std::string markers_header; - std::string markers; - std::string markers_separator; - std::string markers_footer; - std::string sprite; - std::string sprite_markers_header; - std::string sprite_marker; - std::string sprite_markers_separator; - std::string sprite_markers_footer; - std::string separator; - std::string if_animations; - std::string if_no_animations; - std::string animations_header; - std::string animations; - std::string animations_separator; - std::string animations_footer; - std::string atlas_header; - std::string atlas; - std::string atlas_separator; - std::string atlas_footer; - std::string footer; -}; struct MarkerItem { size_t index = 0; @@ -86,7 +59,6 @@ constexpr int DEFAULT_ANIMATION_FPS = 8; constexpr int k_default_precision = 8; constexpr size_t k_string_growth_padding = 8; constexpr unsigned char k_json_control_char_limit = 0x20; -constexpr size_t k_token_replacement_reserve_extra = 64; constexpr std::uint8_t HEX_NIBBLE_MASK = 0x0f; constexpr int BITS_PER_NIBBLE = 4; constexpr const char* HEX_DIGITS = "0123456789abcdef"; @@ -96,6 +68,8 @@ struct AnimationItem { std::string name; std::vector sprite_indexes; int fps = DEFAULT_ANIMATION_FPS; + std::string alias_source; + std::string flip; }; using Sprite = sprat::core::Sprite; @@ -110,12 +84,12 @@ using sprat::core::validate_output_pattern; std::string trim_copy(const std::string& s) { size_t start = 0; - while (start < s.size() && std::isspace(static_cast(s.at(start))) != 0) { + while (start < s.size() && std::isspace(static_cast(s[start])) != 0) { ++start; } size_t end = s.size(); - while (end > start && std::isspace(static_cast(s.at(end - 1))) != 0) { + while (end > start && std::isspace(static_cast(s[end - 1])) != 0) { --end; } @@ -123,18 +97,23 @@ std::string trim_copy(const std::string& s) { } bool read_text_file(const fs::path& path, std::string& out, std::string& error) { - std::ifstream in(path, std::ios::binary); + std::ifstream in(path, std::ios::binary | std::ios::ate); if (!in) { error = "Failed to open file: " + path.string(); return false; } - std::ostringstream buffer; - buffer << in.rdbuf(); + const auto size = in.tellg(); + if (size < 0) { + error = "Failed to read file: " + path.string(); + return false; + } + in.seekg(0); + out.resize(static_cast(size)); + in.read(out.data(), size); if (!in.good() && !in.eof()) { error = "Failed to read file: " + path.string(); return false; } - out = buffer.str(); return true; } @@ -152,11 +131,10 @@ std::string escape_json(const std::string& s) { case '\t': out += "\\t"; break; default: if (static_cast(c) < k_json_control_char_limit) { - std::ostringstream hex; - hex << "\\u"; auto uc = static_cast(c); - hex << '0' << '0' << HEX_DIGITS[(uc >> BITS_PER_NIBBLE) & HEX_NIBBLE_MASK] << HEX_DIGITS[uc & HEX_NIBBLE_MASK]; - out += hex.str(); + out += "\\u00"; + out += HEX_DIGITS[(uc >> BITS_PER_NIBBLE) & HEX_NIBBLE_MASK]; + out += HEX_DIGITS[uc & HEX_NIBBLE_MASK]; } else { out.push_back(c); } @@ -166,392 +144,10 @@ std::string escape_json(const std::string& s) { return out; } -std::string escape_xml(const std::string& s) { - std::string out; - out.reserve(s.size() + k_string_growth_padding); - for (char c : s) { - switch (c) { - case '&': out += "&"; break; - case '<': out += "<"; break; - case '>': out += ">"; break; - case '"': out += """; break; - case '\'': out += "'"; break; - default: out.push_back(c); break; - } - } - return out; -} - -std::string escape_csv(const std::string& s) { - bool needs_quotes = false; - for (char c : s) { - if (c == '"' || c == ',' || c == '\n' || c == '\r') { - needs_quotes = true; - break; - } - } - if (!needs_quotes) { - return s; - } - - std::string out = "\""; - for (char c : s) { - if (c == '"') { - out += "\"\""; - } else { - out.push_back(c); - } - } - out.push_back('"'); - return out; -} - -std::string escape_css_string(const std::string& s) { - std::string out; - out.reserve(s.size() + k_string_growth_padding); - for (char c : s) { - if (c == '\\' || c == '"') { - out.push_back('\\'); - } - if (c == '\n') { - out += "\\a "; - continue; - } - out.push_back(c); - } - return out; -} - -enum class PlaceholderEncoding { - none, - json, - xml, - csv, - css -}; - -std::string escape_value(const std::string& value, PlaceholderEncoding encoding) { - switch (encoding) { - case PlaceholderEncoding::json: return escape_json(value); - case PlaceholderEncoding::xml: return escape_xml(value); - case PlaceholderEncoding::csv: return escape_csv(value); - case PlaceholderEncoding::css: return escape_css_string(value); - default: return value; - } -} - -std::string filter_sections_by_attr(const std::string& input, - const std::map& vars, - PlaceholderEncoding encoding) { - std::string output; - size_t pos = 0; - auto encoding_to_string = [](PlaceholderEncoding enc) { - switch (enc) { - case PlaceholderEncoding::json: return std::string("json"); - case PlaceholderEncoding::xml: return std::string("xml"); - case PlaceholderEncoding::csv: return std::string("csv"); - case PlaceholderEncoding::css: return std::string("css"); - default: return std::string(); - } - }; - const std::string encoding_name = encoding_to_string(encoding); - - while (pos < input.size()) { - size_t start = input.find('[', pos); - if (start == std::string::npos) { - output.append(input.substr(pos)); - break; - } - output.append(input.substr(pos, start - pos)); - if (start + 1 >= input.size() || input[start + 1] == '/') { - output.push_back(input[start]); - pos = start + 1; - continue; - } - size_t header_end = input.find(']', start + 1); - if (header_end == std::string::npos) { - output.append(input.substr(start)); - break; - } - std::string header = input.substr(start + 1, header_end - start - 1); - std::string tag; - std::string attr; - std::string value; - size_t i = 0; - while (i < header.size() && !std::isspace(static_cast(header[i]))) { - tag.push_back(header[i]); - ++i; - } - while (i < header.size()) { - while (i < header.size() && std::isspace(static_cast(header[i]))) { - ++i; - } - size_t name_start = i; - while (i < header.size() && header[i] != '=' && !std::isspace(static_cast(header[i]))) { - ++i; - } - std::string attr_name = header.substr(name_start, i - name_start); - while (i < header.size() && header[i] != '=') { - ++i; - } - if (i >= header.size() || header[i] != '=') { - break; - } - ++i; - while (i < header.size() && std::isspace(static_cast(header[i]))) { - ++i; - } - if (i >= header.size() || header[i] != '"') { - break; - } - ++i; - size_t value_start = i; - while (i < header.size() && header[i] != '"') { - ++i; - } - std::string attr_value = header.substr(value_start, i - value_start); - ++i; - if (attr_name == "type" || attr_name == "marker_type") { - attr = attr_name; - value = attr_value; - break; - } - } - size_t close = input.find("[/" + tag + "]", header_end + 1); - if (close == std::string::npos) { - output.append(input.substr(start)); - break; - } - bool keep = true; - if (!attr.empty()) { - if (attr == "type") { - keep = (value == encoding_name); - } else { - auto it = vars.find(attr); - keep = (it != vars.end() && it->second == value); - } - } - if (!keep) { - pos = close + tag.size() + 3; - continue; - } - output.append(input.substr(header_end + 1, close - header_end - 1)); - pos = close + tag.size() + 3; - } - - return output; -} - -std::string filter_rotated_sections(const std::string& input, bool rotated) { - std::string output; - size_t pos = 0; - while (pos < input.size()) { - size_t start = input.find("[rotated]", pos); - if (start == std::string::npos) { - output.append(input.substr(pos)); - break; - } - output.append(input.substr(pos, start - pos)); - size_t end = input.find("[/rotated]", start + 9); - if (end == std::string::npos) { - break; - } - if (rotated) { - output.append(input.substr(start + 9, end - start - 9)); - } - pos = end + 10; - } - return output; -} - -std::string to_lower_copy(std::string value) { - std::ranges::transform(value, value.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return value; -} - -bool has_suffix(const std::string& value, const std::string& suffix) { - if (value.size() < suffix.size()) { - return false; - } - return value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; -} - -PlaceholderEncoding detect_placeholder_encoding(const Transform& transform, - const std::string& transform_arg) { - const auto from_token = [](const std::string& token) -> PlaceholderEncoding { - std::string normalized = to_lower_copy(token); - if (!normalized.empty() && normalized.front() == '.') { - normalized.erase(normalized.begin()); - } - if (normalized == "json") { - return PlaceholderEncoding::json; - } - if (normalized == "xml") { - return PlaceholderEncoding::xml; - } - if (normalized == "csv") { - return PlaceholderEncoding::csv; - } - if (normalized == "css") { - return PlaceholderEncoding::css; - } - return PlaceholderEncoding::none; - }; - - if (PlaceholderEncoding from_meta = from_token(transform.extension); - from_meta != PlaceholderEncoding::none) { - return from_meta; - } - if (PlaceholderEncoding from_name = from_token(transform.name); - from_name != PlaceholderEncoding::none) { - return from_name; - } - return from_token(transform_arg); -} - -std::string replace_tokens(const std::string& input, - const std::map& vars, - PlaceholderEncoding encoding) { - bool rotated = false; - auto rotated_it = vars.find("rotated"); - if (rotated_it != vars.end()) { - rotated = (rotated_it->second == "true"); - } - std::string filtered = filter_rotated_sections(input, rotated); - filtered = filter_sections_by_attr(filtered, vars, encoding); - std::string out; - out.reserve(filtered.size() + k_token_replacement_reserve_extra); - - auto is_composite_variable = [](std::string_view key) { - return key == "sprites" || key == "markers" || key == "animations" || - key == "sprite_markers" || key == "atlases" || key == "sprite_indexes" || - key == "vertices"; - }; - - size_t i = 0; - while (i < filtered.size()) { - size_t open = filtered.find("{{", i); - if (open == std::string::npos) { - out.append(filtered.substr(i)); - break; - } - - out.append(filtered.substr(i, open - i)); - - bool is_raw = false; - size_t close; - std::string key; - - if (open + 2 < filtered.size() && filtered[open + 2] == '{') { - is_raw = true; - close = filtered.find("}}}", open + 3); - if (close == std::string::npos) { - out.append(filtered.substr(open)); - break; - } - key = trim_copy(filtered.substr(open + 3, close - (open + 3))); - i = close + 3; - } else { - close = filtered.find("}}", open + 2); - if (close == std::string::npos) { - out.append(filtered.substr(open)); - break; - } - key = trim_copy(filtered.substr(open + 2, close - (open + 2))); - i = close + 2; - } - - auto it = vars.find(key); - if (it != vars.end()) { - PlaceholderEncoding entry_encoding = (is_raw || is_composite_variable(key)) ? PlaceholderEncoding::none : encoding; - out.append(escape_value(it->second, entry_encoding)); - } - } - - return out; -} - -std::string format_sprite_indexes(const std::vector& values, PlaceholderEncoding encoding) { - if (values.empty()) { - return (encoding == PlaceholderEncoding::json) ? "[]" : ""; - } - std::ostringstream oss; - if (encoding == PlaceholderEncoding::json) { - oss << "["; - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) oss << ","; - oss << values[i]; - } - oss << "]"; - } else if (encoding == PlaceholderEncoding::csv) { - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) oss << "|"; - oss << values[i]; - } - } else { - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) oss << ","; - oss << values[i]; - } - } - return oss.str(); -} - -std::string format_markers_json(const std::vector& markers) { - std::ostringstream oss; - oss << "["; - bool first_marker = true; - for (const auto& marker : markers) { - if (!first_marker) { - oss << ","; - } - oss << R"({"name":")" << escape_json(marker.name) << R"(","type":")" << escape_json(marker.type) << "\""; - if (marker.type == "point") { - oss << ",\"x\":" << marker.x << ",\"y\":" << marker.y; - } else if (marker.type == "circle") { - oss << ",\"x\":" << marker.x << ",\"y\":" << marker.y << ",\"radius\":" << marker.radius; - } else if (marker.type == "rectangle") { - oss << ",\"x\":" << marker.x << ",\"y\":" << marker.y << ",\"w\":" << marker.w << ",\"h\":" << marker.h; - } else if (marker.type == "polygon") { - oss << ",\"vertices\":["; - bool first_vertex = true; - for (const auto& vertex : marker.vertices) { - if (!first_vertex) { - oss << ","; - } - oss << "{\"x\":" << vertex.first << ",\"y\":" << vertex.second << "}"; - first_vertex = false; - } - oss << "]"; - } - oss << "}"; - first_marker = false; - } - oss << "]"; - return oss.str(); -} - -std::string format_vertices(const std::vector>& vertices, PlaceholderEncoding encoding) { - if (vertices.empty()) { - return (encoding == PlaceholderEncoding::json) ? "[]" : ""; - } - std::ostringstream oss; - if (encoding == PlaceholderEncoding::json) { - oss << "["; - for (size_t i = 0; i < vertices.size(); ++i) { - if (i > 0) oss << ","; - oss << "{\"x\":" << vertices[i].first << ",\"y\":" << vertices[i].second << "}"; - } - oss << "]"; - } else { - for (size_t i = 0; i < vertices.size(); ++i) { - if (i > 0) oss << "|"; - oss << vertices[i].first << "," << vertices[i].second; - } - } - return oss.str(); +std::string format_double(double value) { + std::array buf{}; + int n = std::snprintf(buf.data(), buf.size(), "%.*g", k_default_precision, value); + return std::string(buf.data(), n > 0 ? static_cast(n) : 0); } std::string sprite_name_from_path(const std::string& path) { @@ -580,6 +176,15 @@ void collect_sprite_name_indexes(const Layout& layout, by_path[s.path] = idx; fs::path p(s.path); by_path[p.filename().string()] = idx; + size_t sep = s.path.find('/'); + while (sep != std::string::npos) { + ++sep; + std::string suffix = s.path.substr(sep); + if (!suffix.empty()) { + by_path.emplace(suffix, idx); + } + sep = s.path.find('/', sep); + } std::string name = sprite_name_from_path(s.path); sprite_names.push_back(name); by_name[name] = idx; @@ -594,6 +199,17 @@ int resolve_sprite_index(const std::string& key, if (by_path_it != by_path.end()) { return by_path_it->second; } + size_t sep = key.find('/'); + while (sep != std::string::npos) { + ++sep; + if (sep < key.size()) { + auto it = by_path.find(key.substr(sep)); + if (it != by_path.end()) { + return it->second; + } + } + sep = key.find('/', sep); + } auto by_name_it = by_name.find(key); if (by_name_it != by_name.end()) { return by_name_it->second; @@ -612,6 +228,7 @@ std::vector parse_markers_data(const std::string& markers_text, std::istringstream iss(markers_text); std::string line; int current_sprite_index = -1; + std::string raw_root; while (std::getline(iss, line)) { std::string trimmed = trim_copy(line); @@ -625,7 +242,16 @@ std::vector parse_markers_data(const std::string& markers_text, continue; } - if (cmd == "path") { + if (cmd == "root") { + size_t pos = 4; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { + ++pos; + } + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + parse_quoted(trimmed, pos, raw_root, error); + } + } else if (cmd == "path") { std::string path; size_t pos = trimmed.find("path") + 4; while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { @@ -634,10 +260,16 @@ std::vector parse_markers_data(const std::string& markers_text, if (pos < trimmed.size() && trimmed[pos] == '"') { std::string error; if (parse_quoted(trimmed, pos, path, error)) { + if (!raw_root.empty() && fs::path(path).is_relative()) { + path = (fs::path(raw_root) / path).string(); + } current_sprite_index = resolve_sprite_index(path, by_path, by_name); } } else { if (liss >> path) { + if (!raw_root.empty() && fs::path(path).is_relative()) { + path = (fs::path(raw_root) / path).string(); + } current_sprite_index = resolve_sprite_index(path, by_path, by_name); } } @@ -741,6 +373,7 @@ std::vector parse_animations_data( std::istringstream iss(animations_text); std::string line; AnimationItem* current_anim = nullptr; + std::string raw_root; while (std::getline(iss, line)) { std::string trimmed = trim_copy(line); @@ -754,7 +387,16 @@ std::vector parse_animations_data( continue; } - if (cmd == "fps") { + if (cmd == "root") { + size_t pos = 4; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { + ++pos; + } + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + parse_quoted(trimmed, pos, raw_root, error); + } + } else if (cmd == "fps") { int fps = 0; if (liss >> fps) { animation_fps_out = fps; @@ -777,19 +419,49 @@ std::vector parse_animations_data( pos = trimmed.find(name, pos) + name.length(); } - int fps = animation_fps_out > 0 ? animation_fps_out : DEFAULT_ANIMATION_FPS; - int custom_fps = 0; - std::istringstream rest(trimmed.substr(pos)); - if (rest >> custom_fps) { - fps = custom_fps; - } - AnimationItem item; item.index = animations.size(); item.name = name; - item.fps = fps; - animations.push_back(std::move(item)); - current_anim = &animations.back(); + item.fps = animation_fps_out > 0 ? animation_fps_out : DEFAULT_ANIMATION_FPS; + + std::string next_token; + { + std::istringstream rest(trimmed.substr(pos)); + rest >> next_token; + } + + if (next_token == "alias") { + size_t alias_kw_pos = trimmed.find("alias", pos); + size_t alias_src_pos = alias_kw_pos + 5; + while (alias_src_pos < trimmed.size() && std::isspace(static_cast(trimmed[alias_src_pos]))) { + alias_src_pos++; + } + if (alias_src_pos < trimmed.size() && trimmed[alias_src_pos] == '"') { + std::string error; + std::string alias_source; + if (parse_quoted(trimmed, alias_src_pos, alias_source, error)) { + item.alias_source = alias_source; + } + } + std::string tok; + std::istringstream flip_rest(trimmed.substr(alias_src_pos)); + while (flip_rest >> tok) { + if (tok == "flip") { + std::string val; + if (flip_rest >> val) item.flip = val; + } + } + animations.push_back(std::move(item)); + current_anim = nullptr; + } else { + int custom_fps = 0; + std::istringstream fps_iss(next_token); + if (fps_iss >> custom_fps) { + item.fps = custom_fps; + } + animations.push_back(std::move(item)); + current_anim = &animations.back(); + } } else if (cmd == "-") { std::string subcmd; if (!(liss >> subcmd) || subcmd != "frame") { @@ -799,7 +471,6 @@ std::vector parse_animations_data( continue; } - std::string frame_token; size_t pos = trimmed.find("frame") + 5; while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { pos++; @@ -808,17 +479,24 @@ std::vector parse_animations_data( std::string path; std::string error; if (parse_quoted(trimmed, pos, path, error)) { + if (!raw_root.empty() && fs::path(path).is_relative()) { + path = (fs::path(raw_root) / path).string(); + } int idx = resolve_sprite_index(path, by_path, by_name); if (idx >= 0) { current_anim->sprite_indexes.push_back(idx); } } } else { + std::string frame_token; if (liss >> frame_token) { int idx = -1; if (parse_int(frame_token, idx)) { current_anim->sprite_indexes.push_back(idx); } else { + if (!raw_root.empty() && fs::path(frame_token).is_relative()) { + frame_token = (fs::path(raw_root) / frame_token).string(); + } idx = resolve_sprite_index(frame_token, by_path, by_name); if (idx >= 0) { current_anim->sprite_indexes.push_back(idx); @@ -831,445 +509,644 @@ std::vector parse_animations_data( return animations; } +#ifndef SPRAT_GLOBAL_TRANSFORMS_DIR +#define SPRAT_GLOBAL_TRANSFORMS_DIR "/usr/local/share/sprat/transforms" +#endif -bool parse_transform_file(const fs::path& path, Transform& out, std::string& error) { - std::ifstream in(path); - if (!in) { - error = "Failed to open transform file: " + path.string(); - return false; - } +using sprat::core::to_quoted; - Transform parsed; - std::vector section_stack; - std::string line; - std::string legacy_sprite_block; - std::string legacy_marker_block; - std::string legacy_animation_block; - bool saw_sprite_item = false; - bool saw_marker_item = false; - bool saw_animation_item = false; - - auto append_line = [&](std::string& target, const std::string& value) { - if (!target.empty()) { - target.push_back('\n'); +fs::path g_exec_dir; + +std::optional resolve_user_transforms_dir() { +#ifdef _WIN32 + static const char* const envs[] = {"APPDATA", "LOCALAPPDATA"}; + for (const char* env : envs) { + const char* val = std::getenv(env); + if (val != nullptr && val[0] != '\0') { + const fs::path dir = fs::path(val) / "sprat" / "transforms"; + std::error_code ec; + if (fs::exists(dir, ec) && fs::is_directory(dir, ec)) { + return dir; + } } - target.append(value); - }; + } + return std::nullopt; +#elif defined(__APPLE__) + const char* home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') { + return std::nullopt; + } + const fs::path mac_dir = fs::path(home) / "Library" / "Application Support" / "sprat" / "transforms"; + std::error_code ec_mac; + if (fs::exists(mac_dir, ec_mac) && fs::is_directory(mac_dir, ec_mac)) { + return mac_dir; + } + return std::nullopt; +#else + const char* home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') { + return std::nullopt; + } + const char* xdg_data_home = std::getenv("XDG_DATA_HOME"); + const fs::path data_dir = (xdg_data_home != nullptr && xdg_data_home[0] != '\0') + ? fs::path(xdg_data_home) / "sprat" / "transforms" + : fs::path(home) / ".local" / "share" / "sprat" / "transforms"; + std::error_code ec; + if (fs::exists(data_dir, ec) && fs::is_directory(data_dir, ec)) { + return data_dir; + } + return std::nullopt; +#endif +} - auto is_known_section = [](const std::string& s) { - return s == "meta" - || s == "header" - || s == "if_markers" - || s == "if_no_markers" - || s == "markers_header" - || s == "markers" - || s == "marker" - || s == "markers_separator" - || s == "markers_footer" - || s == "sprites" - || s == "sprite" - || s == "sprite_markers_header" - || s == "sprite_marker" - || s == "sprite_markers_separator" - || s == "sprite_markers_footer" - || s == "separator" - || s == "if_animations" - || s == "if_no_animations" - || s == "animations_header" - || s == "animations" - || s == "animation" - || s == "animations_separator" - || s == "animations_footer" - || s == "atlases" - || s == "atlas_header" - || s == "atlas" - || s == "atlas_separator" - || s == "atlas_footer" - || s == "footer"; - }; +fs::path find_transforms_dir() { + std::vector candidates; + if (!g_exec_dir.empty()) { + candidates.push_back(g_exec_dir / "transforms"); + } + if (std::optional user_dir = resolve_user_transforms_dir()) { + candidates.push_back(*user_dir); + } +#ifdef SPRAT_SOURCE_DIR + candidates.push_back(fs::path(SPRAT_SOURCE_DIR) / "transforms"); +#endif + candidates.emplace_back(SPRAT_GLOBAL_TRANSFORMS_DIR); - bool dsl_mode = false; - while (std::getline(in, line)) { - std::string trimmed = trim_copy(line); - if (trimmed.empty() && section_stack.empty()) { - continue; + for (const auto& candidate : candidates) { + std::error_code ec; + if (fs::exists(candidate, ec) && fs::is_directory(candidate, ec)) { + return candidate; } + } - if (!trimmed.empty() && trimmed.front() == '#') { - continue; - } + return fs::path(SPRAT_GLOBAL_TRANSFORMS_DIR); +} - bool section_tag = false; - if (trimmed.size() >= 3 && trimmed.front() == '[' && trimmed.back() == ']') { - std::string full_tag = trim_copy(trimmed.substr(1, trimmed.size() - 2)); - if (!full_tag.empty() && full_tag.front() == '/') { - std::string tag = trim_copy(full_tag.substr(1)); - if (is_known_section(tag) && !section_stack.empty() && tag == section_stack.back()) { - section_stack.pop_back(); - section_tag = true; - dsl_mode = false; - } - } else { - std::string tag; - size_t space_pos = full_tag.find_first_of(" \t\r\n"); - if (space_pos != std::string::npos) { - tag = full_tag.substr(0, space_pos); - } else { - tag = full_tag; - } - - if (is_known_section(tag)) { - // Only treat as a section if there are no attributes - if (space_pos == std::string::npos) { - if (tag == "sprite") { - if (section_stack.empty() || (section_stack.back() != "sprites" && section_stack.back() != "atlas")) { - // Auto-open sprites if sprite is used without it - section_stack.push_back("sprites"); - } - saw_sprite_item = true; - } else if (tag == "marker") { - if (section_stack.empty() || section_stack.back() != "markers") { - section_stack.push_back("markers"); - } - saw_marker_item = true; - } else if (tag == "animation") { - if (section_stack.empty() || section_stack.back() != "animations") { - section_stack.push_back("animations"); - } - saw_animation_item = true; - } else if (tag == "atlas") { - if (section_stack.empty() || section_stack.back() != "atlases") { - section_stack.push_back("atlases"); - } - } - section_stack.push_back(tag); - section_tag = true; - dsl_mode = false; - } - } - } - } +std::string format_atlas_path(const std::string& pattern, int index) { + if (pattern.empty()) { + return ""; + } + std::string out; + std::string error; + if (!format_index_pattern(pattern, index, out, error)) { + return ""; + } + return out; +} - if (section_tag) { - continue; - } +bool is_digit(char c) { return c >= '0' && c <= '9'; } - if (section_stack.empty()) { - std::istringstream liss(trimmed); - std::string cmd; - if (liss >> cmd) { - if (cmd == "-") { - std::string subcmd; - if (liss >> subcmd) { - if (is_known_section(subcmd)) { - dsl_mode = true; - if (subcmd == "sprite") { - if (section_stack.empty() || section_stack.back() != "sprites") { - section_stack.push_back("sprites"); - } - saw_sprite_item = true; - } else if (subcmd == "marker") { - if (section_stack.empty() || section_stack.back() != "markers") { - section_stack.push_back("markers"); - } - saw_marker_item = true; - } else if (subcmd == "animation") { - if (section_stack.empty() || section_stack.back() != "animations") { - section_stack.push_back("animations"); - } - saw_animation_item = true; - } - section_stack.push_back(subcmd); - continue; - } - } - } else if (is_known_section(cmd)) { - // Start section - dsl_mode = true; - section_stack.push_back(cmd); - - // If it's meta, we might have arguments on the same line - if (cmd == "meta") { - std::string rest; - if (std::getline(liss, rest)) { - std::string trimmed_rest = trim_copy(rest); - if (!trimmed_rest.empty()) { - size_t eq = trimmed_rest.find('='); - if (eq != std::string::npos) { - std::string key = trim_copy(trimmed_rest.substr(0, eq)); - std::string value = trim_copy(trimmed_rest.substr(eq + 1)); - if (key == "name") parsed.name = value; - else if (key == "description") parsed.description = value; - else if (key == "extension") parsed.extension = value; - } - } - } - } - continue; - } - } - } else if (dsl_mode) { - // Check for new section starting without [tag], auto-closing previous - std::istringstream liss(trimmed); - std::string cmd; - if (liss >> cmd) { - if (cmd == "-") { - std::string subcmd; - if (liss >> subcmd && is_known_section(subcmd)) { - // Pop until we find where it belongs or just pop current if it's a sibling/new level - if (subcmd == "sprite" || subcmd == "marker" || subcmd == "animation" || subcmd == "atlas" || subcmd == "atlases" || subcmd == "sprite_marker" || - subcmd == "sprite_markers_header" || subcmd == "sprite_markers_separator" || subcmd == "sprite_markers_footer") { - // These can be nested. If we are in the parent, stay. If we are in another sibling, pop. - std::string parent; - if (subcmd == "sprite") { - if (!section_stack.empty() && section_stack.back() == "atlas") parent = "atlas"; - else parent = "sprites"; - } - else if (subcmd == "marker") parent = "markers"; - else if (subcmd == "animation") parent = "animations"; - else if (subcmd == "atlas") parent = "atlases"; - else if (subcmd == "sprite_marker") parent = "sprite"; - else if (subcmd == "sprite_markers_header") parent = "sprite"; - else if (subcmd == "sprite_markers_separator") parent = "sprite"; - else if (subcmd == "sprite_markers_footer") parent = "sprite"; - - while (!section_stack.empty() && section_stack.back() != parent && !parent.empty()) { - section_stack.pop_back(); - } - if (section_stack.empty() && !parent.empty()) section_stack.push_back(parent); - if (subcmd == "sprite") saw_sprite_item = true; - else if (subcmd == "marker") saw_marker_item = true; - else if (subcmd == "animation") saw_animation_item = true; - section_stack.push_back(subcmd); - continue; - } else { - while (!section_stack.empty()) section_stack.pop_back(); - section_stack.push_back(subcmd); - continue; - } - } - } else if (is_known_section(cmd)) { - while (!section_stack.empty()) section_stack.pop_back(); - section_stack.push_back(cmd); - if (cmd == "meta") { - std::string rest; - if (std::getline(liss, rest)) { - std::string trimmed_rest = trim_copy(rest); - if (!trimmed_rest.empty()) { - size_t eq = trimmed_rest.find('='); - if (eq != std::string::npos) { - std::string key = trim_copy(trimmed_rest.substr(0, eq)); - std::string value = trim_copy(trimmed_rest.substr(eq + 1)); - if (key == "name") parsed.name = value; - else if (key == "description") parsed.description = value; - else if (key == "extension") parsed.extension = value; - } - } - } - } - continue; - } - } +std::string get_animation_name(const std::string& name) { + std::string anim_name = name; + while (!anim_name.empty()) { + char back = anim_name.back(); + if (is_digit(back) || back == '_' || back == '-' || back == ' ' || back == '.' || back == '(' || back == ')') { + anim_name.pop_back(); + } else { + break; } + } + return anim_name; +} - if (section_stack.empty()) { - continue; - } +struct GroupMember { + std::string variant; + fs::path path; +}; - const std::string section = section_stack.back(); +std::string extract_variant(const std::string& stem) { + const auto dot_pos = stem.find('.'); + if (dot_pos == std::string::npos) return ""; + return stem.substr(dot_pos + 1); +} - if (section == "meta") { - size_t eq = line.find('='); - if (eq == std::string::npos) { - continue; - } - std::string key = trim_copy(line.substr(0, eq)); - std::string value = trim_copy(line.substr(eq + 1)); - if (key == "name") { - parsed.name = value; - } else if (key == "description") { - parsed.description = value; - } else if (key == "extension") { - parsed.extension = value; +// ─── Jsonnet helpers ────────────────────────────────────────────────────────── + +// Build the JSON data string passed as std.extVar("sprat") to all transforms. +std::string build_sprat_json( + const Layout& layout, + const std::vector& sprite_names, + const std::vector& marker_items, + const std::vector& normalized_animations, + const std::vector>& sprite_markers, + int global_pivot_x, + int global_pivot_y, + bool has_global_pivot, + const std::string& output_pattern_arg, + const std::string& output_stem, + const std::string& markers_path_arg, + const std::string& animations_path_arg, + int animation_fps) +{ + // Helper: format uint64 as 16-char hex string + auto to_hex16 = [](uint64_t v) -> std::string { + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", static_cast(v)); + return std::string(buf); + }; + + // Helper: build CSS-safe identifier + auto to_css_name = [](const std::string& name) -> std::string { + std::string out; + for (char c : name) { + if (std::isalnum(static_cast(c)) || c == '-' || c == '_') { + out.push_back(c); + } else { + out.push_back('-'); } - continue; } - - if (section == "header") { - append_line(parsed.header, line); - } else if (section == "if_markers") { - append_line(parsed.if_markers, line); - } else if (section == "if_no_markers") { - append_line(parsed.if_no_markers, line); - } else if (section == "markers_header") { - append_line(parsed.markers_header, line); - } else if (section == "markers") { - append_line(legacy_marker_block, line); - } else if (section == "marker") { - append_line(parsed.markers, line); - } else if (section == "markers_separator") { - append_line(parsed.markers_separator, line); - } else if (section == "markers_footer") { - append_line(parsed.markers_footer, line); - } else if (section == "sprites") { - append_line(legacy_sprite_block, line); - } else if (section == "sprite") { - append_line(parsed.sprite, line); - } else if (section == "sprite_markers_header") { - append_line(parsed.sprite_markers_header, line); - } else if (section == "sprite_marker") { - append_line(parsed.sprite_marker, line); - } else if (section == "sprite_markers_separator") { - append_line(parsed.sprite_markers_separator, line); - } else if (section == "sprite_markers_footer") { - append_line(parsed.sprite_markers_footer, line); - } else if (section == "separator") { - append_line(parsed.separator, line); - } else if (section == "if_animations") { - append_line(parsed.if_animations, line); - } else if (section == "if_no_animations") { - append_line(parsed.if_no_animations, line); - } else if (section == "animations_header") { - append_line(parsed.animations_header, line); - } else if (section == "animations") { - append_line(legacy_animation_block, line); - } else if (section == "animation") { - append_line(parsed.animations, line); - } else if (section == "animations_separator") { - append_line(parsed.animations_separator, line); - } else if (section == "animations_footer") { - append_line(parsed.animations_footer, line); - } else if (section == "atlas_header") { - append_line(parsed.atlas_header, line); - } else if (section == "atlas") { - append_line(parsed.atlas, line); - } else if (section == "atlas_separator") { - append_line(parsed.atlas_separator, line); - } else if (section == "atlas_footer") { - append_line(parsed.atlas_footer, line); - } else if (section == "footer") { - append_line(parsed.footer, line); + if (!out.empty() && std::isdigit(static_cast(out[0]))) { + out.insert(0, 1, '_'); } - } + return out; + }; - if (dsl_mode) { - section_stack.clear(); - } + // Helper: build JSON array of marker objects + auto marker_to_json = [&](const MarkerItem& m) -> std::string { + std::string o = "{\"name\":\"" + escape_json(m.name) + "\""; + o += ",\"type\":\"" + escape_json(m.type) + "\""; + o += ",\"x\":" + std::to_string(m.x); + o += ",\"y\":" + std::to_string(m.y); + if (m.type == "circle") { + o += ",\"radius\":" + std::to_string(m.radius); + } else if (m.type == "rectangle") { + o += ",\"w\":" + std::to_string(m.w); + o += ",\"h\":" + std::to_string(m.h); + } else if (m.type == "polygon") { + o += ",\"vertices\":["; + for (size_t vi = 0; vi < m.vertices.size(); ++vi) { + if (vi > 0) o += ','; + o += "{\"x\":" + std::to_string(m.vertices[vi].first); + o += ",\"y\":" + std::to_string(m.vertices[vi].second) + "}"; + } + o += "]"; + } + o += ",\"sprite_index\":" + std::to_string(m.sprite_index); + o += ",\"sprite_name\":\"" + escape_json(m.sprite_name) + "\""; + o += ",\"sprite_path\":\"" + escape_json(m.sprite_path) + "\""; + o += ",\"index\":" + std::to_string(m.index); + o += "}"; + return o; + }; - if (!section_stack.empty()) { - error = "Unclosed section [" + section_stack.back() + "]: " + path.string(); - return false; - } + // Helper: build full sprite JSON object + auto sprite_to_json = [&](size_t i) -> std::string { + const Sprite& s = layout.sprites[i]; + const std::string& sname = sprite_names[i]; - if (!saw_sprite_item) { - parsed.sprite = legacy_sprite_block; - } - if (!saw_marker_item) { - parsed.markers = legacy_marker_block; - } - if (!saw_animation_item) { - parsed.animations = legacy_animation_block; - } + const int content_w = s.rotated ? s.h : s.w; + const int content_h = s.rotated ? s.w : s.h; + const int source_w = content_w + s.src_x + s.trim_right; + const int source_h = content_h + s.src_y + s.trim_bottom; + const bool has_trim = (s.src_x != 0) || (s.src_y != 0) || + (s.trim_right != 0) || (s.trim_bottom != 0); - if (parsed.name.empty()) { - parsed.name = path.stem().string(); - } - if (parsed.sprite.empty()) { - error = "Transform missing [sprite] section (or legacy [sprites] body): " + path.string(); - return false; - } + int unity_y = 0; + if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { + unity_y = layout.atlases[static_cast(s.atlas_index)].height - s.y - s.h; + } - out = std::move(parsed); - return true; -} + int px = has_global_pivot ? global_pivot_x : 0; + int py = has_global_pivot ? global_pivot_y : 0; + for (const auto& marker : sprite_markers[i]) { + if (marker.name == "pivot" && marker.type == "point") { + px = marker.x; + py = marker.y; + break; + } + } -std::string format_double(double value) { - std::ostringstream oss; - oss.unsetf(std::ios::floatfield); - oss.precision(k_default_precision); - oss << value; - return oss.str(); -} + double pivot_x_norm = (source_w > 0) ? (static_cast(px) / source_w) : 0.0; + double pivot_y_norm = (source_h > 0) ? (1.0 - static_cast(py) / source_h) : 0.0; + double pivot_y_norm_raw = (source_h > 0) ? (static_cast(py) / source_h) : 0.0; -#ifndef SPRAT_GLOBAL_TRANSFORMS_DIR -#define SPRAT_GLOBAL_TRANSFORMS_DIR "/usr/local/share/sprat/transforms" -#endif + const uint64_t nh = sprat::core::fnv1a_hash( + reinterpret_cast(sname.c_str()), sname.size()); + const std::string nh_hex = to_hex16(nh); + const std::string nh_dec = std::to_string(nh); -using sprat::core::to_quoted; + std::string a_path = format_atlas_path(output_pattern_arg, s.atlas_index); -fs::path g_exec_dir; + std::string o = "{"; + o += "\"index\":" + std::to_string(i); + o += ",\"name\":\"" + escape_json(sname) + "\""; + o += ",\"path\":\"" + escape_json(s.path) + "\""; + o += ",\"atlas_index\":" + std::to_string(s.atlas_index); + o += ",\"atlas_path\":\"" + escape_json(a_path) + "\""; + o += ",\"x\":" + std::to_string(s.x); + o += ",\"y\":" + std::to_string(s.y); + o += ",\"w\":" + std::to_string(s.w); + o += ",\"h\":" + std::to_string(s.h); + o += ",\"trim_left\":" + std::to_string(s.src_x); + o += ",\"trim_top\":" + std::to_string(s.src_y); + o += ",\"trim_right\":" + std::to_string(s.trim_right); + o += ",\"trim_bottom\":" + std::to_string(s.trim_bottom); + o += ",\"has_trim\":" + std::string(has_trim ? "true" : "false"); + o += ",\"rotated\":" + std::string(s.rotated ? "true" : "false"); + o += ",\"content_w\":" + std::to_string(content_w); + o += ",\"content_h\":" + std::to_string(content_h); + o += ",\"source_w\":" + std::to_string(source_w); + o += ",\"source_h\":" + std::to_string(source_h); + o += ",\"unity_y\":" + std::to_string(unity_y); + o += ",\"pivot_x\":" + std::to_string(px); + o += ",\"pivot_y\":" + std::to_string(py); + o += ",\"pivot_x_norm\":" + format_double(pivot_x_norm); + o += ",\"pivot_y_norm\":" + format_double(pivot_y_norm); + o += ",\"pivot_y_norm_raw\":" + format_double(pivot_y_norm_raw); + o += ",\"name_hash_hex\":\"" + nh_hex + "\""; + o += ",\"name_hash_decimal\":\"" + nh_dec + "\""; + o += ",\"name_css\":\"" + escape_json(to_css_name(sname)) + "\""; + + // sprite_markers array + o += ",\"markers\":["; + const auto& sm = sprite_markers[i]; + for (size_t j = 0; j < sm.size(); ++j) { + if (j > 0) o += ','; + o += marker_to_json(sm[j]); + } + o += "]"; + + // atlas dimensions (for per-sprite access) + if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { + o += ",\"atlas_width\":" + + std::to_string(layout.atlases[static_cast(s.atlas_index)].width); + o += ",\"atlas_height\":" + + std::to_string(layout.atlases[static_cast(s.atlas_index)].height); + } else { + o += ",\"atlas_width\":0,\"atlas_height\":0"; + } -std::optional resolve_user_transforms_dir() { -#ifdef _WIN32 - static const char* const envs[] = {"APPDATA", "LOCALAPPDATA"}; - for (const char* env : envs) { - const char* val = std::getenv(env); - if (val != nullptr && val[0] != '\0') { - const fs::path dir = fs::path(val) / "sprat" / "transforms"; - std::error_code ec; - if (fs::exists(dir, ec) && fs::is_directory(dir, ec)) { - return dir; + o += "}"; + return o; + }; + + // Global hash for output_stem + const std::string& hash_source = output_pattern_arg.empty() ? output_stem : output_pattern_arg; + const uint64_t stem_hash = sprat::core::fnv1a_hash( + reinterpret_cast(hash_source.c_str()), hash_source.size()); + const std::string stem_hash_hex = to_hex16(stem_hash); + + const std::string atlas_path_0 = format_atlas_path(output_pattern_arg, 0); + const std::string atlas_stem_0 = fs::path(atlas_path_0).stem().string(); + + const int eff_fps = animation_fps > 0 ? animation_fps : DEFAULT_ANIMATION_FPS; + const int atlas_w0 = layout.atlases.empty() ? 0 : layout.atlases[0].width; + const int atlas_h0 = layout.atlases.empty() ? 0 : layout.atlases[0].height; + + std::string j = "{"; + + // Global scalars + j += "\"atlas_path\":\"" + escape_json(atlas_path_0) + "\""; + j += ",\"atlas_stem\":\"" + escape_json(atlas_stem_0) + "\""; + j += ",\"atlas_width\":" + std::to_string(atlas_w0); + j += ",\"atlas_height\":" + std::to_string(atlas_h0); + j += ",\"atlas_count\":" + std::to_string(layout.atlases.size()); + j += ",\"multipack\":" + std::string(layout.multipack ? "true" : "false"); + j += ",\"scale\":" + format_double(layout.scale); + j += ",\"extrude\":" + std::to_string(layout.extrude); + j += ",\"sprite_count\":" + std::to_string(layout.sprites.size()); + j += ",\"animation_count\":" + std::to_string(normalized_animations.size()); + j += ",\"marker_count\":" + std::to_string(marker_items.size()); + j += ",\"output_pattern\":\"" + escape_json(output_pattern_arg) + "\""; + j += ",\"output_stem\":\"" + escape_json(output_stem) + "\""; + j += ",\"output_stem_hash_hex\":\"" + stem_hash_hex + "\""; + j += ",\"has_animations\":" + std::string(normalized_animations.empty() ? "false" : "true"); + j += ",\"has_markers\":" + std::string(marker_items.empty() ? "false" : "true"); + j += ",\"animations_path\":\"" + escape_json(animations_path_arg) + "\""; + j += ",\"markers_path\":\"" + escape_json(markers_path_arg) + "\""; + j += ",\"fps\":" + std::to_string(eff_fps); + + // sprites array (all sprites flat) + j += ",\"sprites\":["; + for (size_t i = 0; i < layout.sprites.size(); ++i) { + if (i > 0) j += ','; + j += sprite_to_json(i); + } + j += "]"; + + // atlases array + j += ",\"atlases\":["; + for (size_t ai = 0; ai < layout.atlases.size(); ++ai) { + if (ai > 0) j += ','; + const auto& atlas = layout.atlases[ai]; + const std::string a_path = format_atlas_path(output_pattern_arg, static_cast(ai)); + j += "{\"index\":" + std::to_string(ai); + j += ",\"width\":" + std::to_string(atlas.width); + j += ",\"height\":" + std::to_string(atlas.height); + j += ",\"path\":\"" + escape_json(a_path) + "\""; + // sprites in this atlas + j += ",\"sprites\":["; + bool first_as = true; + for (size_t si = 0; si < layout.sprites.size(); ++si) { + if (layout.sprites[si].atlas_index == static_cast(ai)) { + if (!first_as) j += ','; + first_as = false; + j += sprite_to_json(si); } } - } -#endif + j += "]}"; + } + j += "]"; + + // animations array + j += ",\"animations\":["; + for (size_t ai = 0; ai < normalized_animations.size(); ++ai) { + if (ai > 0) j += ','; + const AnimationItem& anim = normalized_animations[ai]; + const bool is_alias = !anim.alias_source.empty(); + const int eff_anim_fps = anim.fps > 0 ? anim.fps : DEFAULT_ANIMATION_FPS; + + j += "{\"index\":" + std::to_string(ai); + j += ",\"name\":\"" + escape_json(anim.name) + "\""; + j += ",\"fps\":" + std::to_string(eff_anim_fps); + j += ",\"is_alias\":" + std::string(is_alias ? "true" : "false"); + j += ",\"alias_source\":\"" + escape_json(anim.alias_source) + "\""; + j += ",\"flip\":\"" + escape_json(anim.flip) + "\""; + + // frame_indices + j += ",\"frame_indices\":["; + for (size_t fi = 0; fi < anim.sprite_indexes.size(); ++fi) { + if (fi > 0) j += ','; + j += std::to_string(anim.sprite_indexes[fi]); + } + j += "]"; + + // duration + double dur = anim.sprite_indexes.empty() ? 0.0 + : static_cast(anim.sprite_indexes.size()) / static_cast(eff_anim_fps); + j += ",\"duration\":" + format_double(dur); + + // frames: resolved sprite info per frame + j += ",\"frames\":["; + for (size_t fi = 0; fi < anim.sprite_indexes.size(); ++fi) { + if (fi > 0) j += ','; + const int sidx = anim.sprite_indexes[fi]; + const std::string& fname = sprite_names[static_cast(sidx)]; + const uint64_t fnh = sprat::core::fnv1a_hash( + reinterpret_cast(fname.c_str()), fname.size()); + j += "{\"index\":" + std::to_string(sidx); + j += ",\"name\":\"" + escape_json(fname) + "\""; + j += ",\"name_hash_hex\":\"" + to_hex16(fnh) + "\""; + j += ",\"name_hash_decimal\":\"" + std::to_string(fnh) + "\""; + j += "}"; + } + j += "]"; + + j += "}"; + } + j += "]"; + + // global markers array + j += ",\"markers\":["; + for (size_t mi = 0; mi < marker_items.size(); ++mi) { + if (mi > 0) j += ','; + j += marker_to_json(marker_items[mi]); + } + j += "]"; + + j += "}"; + return j; +} - const char* home = std::getenv("HOME"); - if (home == nullptr || home[0] == '\0') { - return std::nullopt; +// Evaluate a Jsonnet file with the given sprat JSON data. +// Returns the evaluated output string, or empty string on error (sets error). +std::string evaluate_transform( + const fs::path& transform_path, + const std::string& sprat_json, + std::string& error) +{ + jsonnet::Jsonnet vm; + if (!vm.init()) { + error = "Failed to initialize Jsonnet VM"; + return ""; } + vm.bindExtCodeVar("sprat", sprat_json); + // Always add the built-in transforms directory to the import path so that + // `import "sprat.libsonnet"` resolves from custom transforms outside that dir. + const fs::path transforms_dir = find_transforms_dir(); + if (!transforms_dir.empty()) + vm.addImportPath(transforms_dir.string()); + std::string output; + bool ok = vm.evaluateFile(transform_path.string(), &output); + if (!ok) { + error = vm.lastError(); + return ""; + } + return output; +} -#ifdef __APPLE__ - const fs::path mac_dir = fs::path(home) / "Library" / "Preferences" / "sprat" / "transforms"; - std::error_code ec_mac; - if (fs::exists(mac_dir, ec_mac) && fs::is_directory(mac_dir, ec_mac)) { - return mac_dir; +// Minimal result returned by a Jsonnet transform. +struct TransformResult { + std::string name; + std::string description; + std::string extension; + std::string icon; // optional relative path to icon + // Exactly one of content or files is populated: + std::string content; // single-file mode + struct FileEntry { std::string filename; std::string content; }; + std::vector files; // multi-file mode +}; + +// Unescape a JSON string (assumes well-formed JSON produced by Jsonnet). +static std::string json_unescape_string(const std::string& src, size_t start, size_t end) { + std::string out; + out.reserve(end - start); + for (size_t i = start; i < end; ) { + if (src[i] == '\\' && i + 1 < end) { + char next = src[i + 1]; + switch (next) { + case '"': out += '"'; i += 2; break; + case '\\': out += '\\'; i += 2; break; + case '/': out += '/'; i += 2; break; + case 'b': out += '\b'; i += 2; break; + case 'f': out += '\f'; i += 2; break; + case 'n': out += '\n'; i += 2; break; + case 'r': out += '\r'; i += 2; break; + case 't': out += '\t'; i += 2; break; + case 'u': { + if (i + 5 < end) { + unsigned int cp = 0; + for (int k = 0; k < 4; ++k) { + char h = src[i + 2 + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + } + // Encode code point as UTF-8 + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } + i += 6; + } else { + out += src[i]; + ++i; + } + break; + } + default: + out += src[i]; + ++i; + break; + } + } else { + out += src[i]; + ++i; + } } -#endif + return out; +} - const fs::path home_dir = fs::path(home) / ".config" / "sprat" / "transforms"; - std::error_code ec; - if (fs::exists(home_dir, ec) && fs::is_directory(home_dir, ec)) { - return home_dir; +// Find and extract the raw content of a JSON string value for a given key. +// Returns true and sets value_start/value_end (the range inside the quotes) if found. +static bool find_json_string_value(const std::string& json, const std::string& key, + size_t& value_start, size_t& value_end) { + const std::string needle = "\"" + key + "\""; + size_t pos = 0; + while (pos < json.size()) { + size_t kp = json.find(needle, pos); + if (kp == std::string::npos) return false; + pos = kp + needle.size(); + // skip whitespace and colon + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t' || + json[pos] == '\n' || json[pos] == '\r')) ++pos; + if (pos >= json.size() || json[pos] != ':') continue; + ++pos; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t' || + json[pos] == '\n' || json[pos] == '\r')) ++pos; + if (pos >= json.size() || json[pos] != '"') continue; + ++pos; + value_start = pos; + // scan to end of string value + while (pos < json.size()) { + if (json[pos] == '\\') { + pos += 2; + } else if (json[pos] == '"') { + value_end = pos; + return true; + } else { + ++pos; + } + } } - return std::nullopt; + return false; } -fs::path find_transforms_dir() { - std::vector candidates; - candidates.emplace_back("transforms"); - if (std::optional user_dir = resolve_user_transforms_dir()) { - candidates.push_back(*user_dir); +// Parse the JSON object output from a Jsonnet transform evaluation. +bool parse_transform_result(const std::string& json, TransformResult& out, std::string& error) { + // Extract name + size_t vs = 0, ve = 0; + if (find_json_string_value(json, "name", vs, ve)) { + out.name = json_unescape_string(json, vs, ve); } - if (!g_exec_dir.empty()) { - candidates.push_back(g_exec_dir / "transforms"); + if (find_json_string_value(json, "description", vs, ve)) { + out.description = json_unescape_string(json, vs, ve); + } + if (find_json_string_value(json, "icon", vs, ve)) { + out.icon = json_unescape_string(json, vs, ve); + } + if (find_json_string_value(json, "extension", vs, ve)) { + out.extension = json_unescape_string(json, vs, ve); + } else { + error = "Transform result missing required field: extension"; + return false; } -#ifdef SPRAT_SOURCE_DIR - candidates.push_back(fs::path(SPRAT_SOURCE_DIR) / "transforms"); -#endif - candidates.emplace_back(SPRAT_GLOBAL_TRANSFORMS_DIR); - for (const auto& candidate : candidates) { - std::error_code ec; - if (fs::exists(candidate, ec) && fs::is_directory(candidate, ec)) { - return candidate; + // Check for "files" array first — if present we're in multi-file mode and must + // NOT try to extract "content" from the top level, because "content" also appears + // as a key inside each files[] entry and find_json_string_value would match that. + const std::string files_key = "\"files\""; + size_t fp = json.find(files_key); + if (fp != std::string::npos) { + fp += files_key.size(); + // skip to '[' + while (fp < json.size() && json[fp] != '[') ++fp; + if (fp >= json.size()) { + error = "Malformed files array in transform result"; + return false; + } + ++fp; // skip '[' + while (fp < json.size()) { + // skip whitespace + while (fp < json.size() && (json[fp] == ' ' || json[fp] == '\t' || + json[fp] == '\n' || json[fp] == '\r' || json[fp] == ',')) ++fp; + if (fp >= json.size() || json[fp] == ']') break; + if (json[fp] != '{') { ++fp; continue; } + // find filename and content within this object + size_t obj_end = fp; + int depth = 0; + while (obj_end < json.size()) { + if (json[obj_end] == '{') ++depth; + else if (json[obj_end] == '}') { --depth; if (depth == 0) { ++obj_end; break; } } + else if (json[obj_end] == '"') { + ++obj_end; + while (obj_end < json.size() && json[obj_end] != '"') { + if (json[obj_end] == '\\') ++obj_end; + ++obj_end; + } + } + ++obj_end; + } + std::string obj_str = json.substr(fp, obj_end - fp); + TransformResult::FileEntry entry; + size_t fvs = 0, fve = 0; + if (find_json_string_value(obj_str, "filename", fvs, fve)) { + entry.filename = json_unescape_string(obj_str, fvs, fve); + } + if (find_json_string_value(obj_str, "content", fvs, fve)) { + entry.content = json_unescape_string(obj_str, fvs, fve); + } + if (!entry.filename.empty()) { + out.files.push_back(std::move(entry)); + } + fp = obj_end; } + return true; } - return fs::path("transforms"); + // No "files" key — check for top-level "content" (single-file mode). + if (find_json_string_value(json, "content", vs, ve)) { + out.content = json_unescape_string(json, vs, ve); + return true; + } + + error = "Transform result must have either 'content' or 'files' field"; + return false; } fs::path resolve_transform_path(const std::string& transform_arg) { fs::path candidate(transform_arg); - if (candidate.has_parent_path() || candidate.extension() == ".transform") { + if (candidate.has_parent_path() || candidate.extension() == ".jsonnet") { return candidate; } - return find_transforms_dir() / (transform_arg + ".transform"); + return find_transforms_dir() / (transform_arg + ".jsonnet"); } -bool load_transform_by_name(const std::string& transform_arg, Transform& out, std::string& error) { - fs::path transform_path = resolve_transform_path(transform_arg); - return parse_transform_file(transform_path, out, error); +std::vector discover_group_transforms(const std::string& group_name, + const fs::path& transforms_dir) { + std::vector members; + const std::string prefix = group_name + "."; + std::error_code ec; + for (const auto& entry : fs::directory_iterator(transforms_dir, ec)) { + if (ec) break; + if (entry.path().extension() != ".jsonnet") continue; + const std::string stem = entry.path().stem().string(); + if (stem.size() <= prefix.size()) continue; + if (stem.substr(0, prefix.size()) != prefix) continue; + members.push_back({stem.substr(prefix.size()), entry.path()}); + } + std::sort(members.begin(), members.end(), + [](const GroupMember& a, const GroupMember& b) { + return a.variant < b.variant; + }); + return members; } void list_transforms() { @@ -1280,73 +1157,172 @@ void list_transforms() { std::vector paths; for (const auto& entry : fs::directory_iterator(dir)) { - if (!entry.is_regular_file()) { - continue; - } - if (entry.path().extension() == ".transform") { + if (!entry.is_regular_file()) continue; + if (entry.path().extension() == ".jsonnet") { + // Skip group members (name.variant.jsonnet); only list top-level names + const std::string stem = entry.path().stem().string(); + // A group member has the form "group.variant"; skip it + // We list it only if it has no dot, or if no group prefix exists. + // Simple heuristic: skip stems containing a dot. + if (stem.find('.') != std::string::npos) continue; paths.push_back(entry.path()); } } + // Also include group names (unique group prefixes from group.variant.jsonnet files) + std::vector group_names_seen; + for (const auto& entry : fs::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + if (entry.path().extension() != ".jsonnet") continue; + const std::string stem = entry.path().stem().string(); + const auto dot_pos = stem.find('.'); + if (dot_pos == std::string::npos) continue; + const std::string grp = stem.substr(0, dot_pos); + if (std::find(group_names_seen.begin(), group_names_seen.end(), grp) + == group_names_seen.end()) { + group_names_seen.push_back(grp); + } + } + std::ranges::sort(paths); + // Empty mock data for listing + const std::string mock_json = R"({"sprites":[],"animations":[],"atlases":[],"markers":[],)" + R"("atlas_path":"","atlas_stem":"","atlas_width":0,"atlas_height":0,"atlas_count":0,)" + R"("multipack":false,"scale":1,"extrude":0,"sprite_count":0,"animation_count":0,)" + R"("marker_count":0,"output_pattern":"","output_stem":"","output_stem_hash_hex":"0000000000000000",)" + R"("has_animations":false,"has_markers":false,"animations_path":"","markers_path":"","fps":8})"; + for (const auto& path : paths) { - Transform t; - std::string error; - if (!parse_transform_file(path, t, error)) { - std::cerr << tr("Warning: ") << error << "\n"; + std::string eval_error; + std::string output = evaluate_transform(path, mock_json, eval_error); + if (output.empty()) { + std::cerr << tr("Warning: ") << eval_error << "\n"; continue; } - std::cout << t.name; - if (!t.description.empty()) { - std::cout << " - " << t.description; + TransformResult result; + std::string parse_error; + if (!parse_transform_result(output, result, parse_error)) { + std::cerr << tr("Warning: ") << parse_error << "\n"; + continue; + } + std::cout << result.name; + if (!result.description.empty()) { + std::cout << " - " << result.description; } std::cout << "\n"; + if (!result.icon.empty()) { + std::cout << " " << (dir / result.icon).string() << "\n"; + } + } + // Print group names + for (const auto& grp : group_names_seen) { + std::cout << grp << " (group)\n"; } } -bool is_digit(char c) { return c >= '0' && c <= '9'; } +void list_transforms_json() { + const fs::path dir = find_transforms_dir(); -std::string get_animation_name(const std::string& name) { - std::string anim_name = name; - while (!anim_name.empty()) { - char back = anim_name.back(); - if (is_digit(back) || back == '_' || back == '-' || back == ' ' || back == '.' || back == '(' || back == ')') { - anim_name.pop_back(); - } else { - break; + std::vector paths; + if (fs::exists(dir) && fs::is_directory(dir)) { + for (const auto& entry : fs::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + if (entry.path().extension() == ".jsonnet") { + const std::string stem = entry.path().stem().string(); + if (stem.find('.') != std::string::npos) continue; + paths.push_back(entry.path()); + } } } - return anim_name; -} -std::string format_atlas_path(const std::string& pattern, int index) { - if (pattern.empty()) { - return ""; - } - std::string out; - std::string error; - if (!format_index_pattern(pattern, index, out, error)) { - return ""; + std::vector group_names_seen; + if (fs::exists(dir) && fs::is_directory(dir)) { + for (const auto& entry : fs::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + if (entry.path().extension() != ".jsonnet") continue; + const std::string stem = entry.path().stem().string(); + const auto dot_pos = stem.find('.'); + if (dot_pos == std::string::npos) continue; + const std::string grp = stem.substr(0, dot_pos); + if (std::find(group_names_seen.begin(), group_names_seen.end(), grp) + == group_names_seen.end()) { + group_names_seen.push_back(grp); + } + } } - return out; + + std::ranges::sort(paths); + + const std::string mock_json = R"({"sprites":[],"animations":[],"atlases":[],"markers":[],)" + R"("atlas_path":"","atlas_stem":"","atlas_width":0,"atlas_height":0,"atlas_count":0,)" + R"("multipack":false,"scale":1,"extrude":0,"sprite_count":0,"animation_count":0,)" + R"("marker_count":0,"output_pattern":"","output_stem":"","output_stem_hash_hex":"0000000000000000",)" + R"("has_animations":false,"has_markers":false,"animations_path":"","markers_path":"","fps":8})"; + + std::cout << "[\n"; + bool first = true; + + for (const auto& path : paths) { + std::string eval_error; + std::string output = evaluate_transform(path, mock_json, eval_error); + if (output.empty()) { + std::cerr << tr("Warning: ") << eval_error << "\n"; + continue; + } + TransformResult result; + std::string parse_error; + if (!parse_transform_result(output, result, parse_error)) { + std::cerr << tr("Warning: ") << parse_error << "\n"; + continue; + } + const std::string icon_abs = result.icon.empty() + ? std::string{} + : (dir / result.icon).string(); + + if (!first) std::cout << ",\n"; + first = false; + std::cout << " {\n" + << " \"name\": \"" << escape_json(result.name) << "\",\n" + << " \"description\": \"" << escape_json(result.description) << "\",\n" + << " \"extension\": \"" << escape_json(result.extension) << "\",\n" + << " \"icon\": \"" << escape_json(icon_abs) << "\"\n" + << " }"; + } + + for (const auto& grp : group_names_seen) { + if (!first) std::cout << ",\n"; + first = false; + std::cout << " {\n" + << " \"name\": \"" << escape_json(grp) << "\",\n" + << " \"description\": \"\",\n" + << " \"extension\": \"\",\n" + << " \"icon\": \"\",\n" + << " \"group\": true\n" + << " }"; + } + + std::cout << "\n]\n"; } void print_usage() { std::cout << tr("Usage: spratconvert [OPTIONS]\n") << tr("\n") << tr("Read layout text from stdin and transform it into other formats.\n") - << tr("Unsuffixed placeholders are auto-encoded based on transform output type.\n") << tr("\n") << tr("Options:\n") << tr(" --transform NAME|PATH Transform name or path (default: json)\n") - << tr(" --output, -o PATTERN Atlas path pattern for atlas_* placeholders\n") + << tr(" --atlas, -a PATTERN Atlas path pattern for atlas_* placeholders\n") + << tr(" --output-dir PATH Write output to PATH/{variant}{extension} instead of stdout\n") << tr(" --list-transforms Print available transforms and exit\n") + << tr(" --list-transforms-json Print available transforms as JSON and exit\n") + << tr(" --transforms-dir Print the transforms directory and exit\n") << tr(" --markers PATH Load external markers file\n") << tr(" --animations PATH Load external animations file\n") << tr(" --auto-animations Group frames into animations by name pattern\n") << tr(" --help, -h Show this help message\n") << tr(" --version, -v Show version\n"); } + } // namespace int run_spratconvert(int argc, char** argv) { @@ -1360,15 +1336,20 @@ int run_spratconvert(int argc, char** argv) { std::string markers_path_arg; std::string animations_path_arg; std::string output_pattern_arg; + std::string output_dir_arg; bool list_only = false; + bool list_json_only = false; + bool show_transforms_dir = false; bool auto_animations = false; for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if (arg == "--transform" && i + 1 < argc) { transform_arg = argv[++i]; - } else if ((arg == "--output" || arg == "-o") && i + 1 < argc) { + } else if ((arg == "--atlas" || arg == "-a" || arg == "--output" || arg == "-o") && i + 1 < argc) { output_pattern_arg = argv[++i]; + } else if (arg == "--output-dir" && i + 1 < argc) { + output_dir_arg = argv[++i]; } else if (arg == "--markers" && i + 1 < argc) { markers_path_arg = argv[++i]; } else if (arg == "--animations" && i + 1 < argc) { @@ -1377,6 +1358,10 @@ int run_spratconvert(int argc, char** argv) { auto_animations = true; } else if (arg == "--list-transforms") { list_only = true; + } else if (arg == "--list-transforms-json") { + list_json_only = true; + } else if (arg == "--transforms-dir") { + show_transforms_dir = true; } else if (arg == "--help" || arg == "-h") { print_usage(); return 0; @@ -1389,25 +1374,26 @@ int run_spratconvert(int argc, char** argv) { } } + if (show_transforms_dir) { + std::cout << find_transforms_dir().string() << "\n"; + return 0; + } + if (list_only) { list_transforms(); return 0; } - Transform transform; - std::string transform_error; - if (!load_transform_by_name(transform_arg, transform, transform_error)) { - std::cerr << transform_error << "\n"; - return 1; + if (list_json_only) { + list_transforms_json(); + return 0; } - const PlaceholderEncoding placeholder_encoding = - detect_placeholder_encoding(transform, transform_arg); + // Read stdin and parse layout std::string input_text; { - std::ostringstream buffer; - buffer << std::cin.rdbuf(); - input_text = buffer.str(); + input_text.assign(std::istreambuf_iterator(std::cin), + std::istreambuf_iterator()); } std::istringstream layout_iss(input_text); Layout layout; @@ -1458,18 +1444,18 @@ int run_spratconvert(int argc, char** argv) { std::vector> sprite_markers; const std::vector marker_items = - parse_markers_data(markers_text, layout, sprite_index_by_path, sprite_index_by_name, sprite_names, sprite_markers); + parse_markers_data(markers_text, layout, sprite_index_by_path, sprite_index_by_name, + sprite_names, sprite_markers); int animation_fps = -1; std::vector animation_items = - parse_animations_data(animations_text, sprite_index_by_path, sprite_index_by_name, animation_fps); + parse_animations_data(animations_text, sprite_index_by_path, sprite_index_by_name, + animation_fps); if (auto_animations) { std::map> grouped; for (size_t i = 0; i < sprite_names.size(); ++i) { std::string anim_name = get_animation_name(sprite_names[i]); - if (anim_name.empty()) { - continue; - } + if (anim_name.empty()) continue; grouped[anim_name].push_back(static_cast(i)); } for (auto const& [name, frames] : grouped) { @@ -1485,8 +1471,8 @@ int run_spratconvert(int argc, char** argv) { } const int sprite_count_limit = static_cast(layout.sprites.size()); - std::vector normalized_animation_items = animation_items; - for (AnimationItem& item : normalized_animation_items) { + std::vector normalized_animations = animation_items; + for (AnimationItem& item : normalized_animations) { std::vector filtered; filtered.reserve(item.sprite_indexes.size()); for (int idx : item.sprite_indexes) { @@ -1509,239 +1495,141 @@ int run_spratconvert(int argc, char** argv) { } } - std::map global_vars; - if (!layout.atlases.empty()) { - global_vars["atlas_width"] = std::to_string(layout.atlases[0].width); - global_vars["atlas_height"] = std::to_string(layout.atlases[0].height); - } else { - global_vars["atlas_width"] = "0"; - global_vars["atlas_height"] = "0"; - } - global_vars["atlas_count"] = std::to_string(layout.atlases.size()); - global_vars["multipack"] = layout.multipack ? "true" : "false"; - global_vars["output_pattern"] = output_pattern_arg; - - std::ostringstream atlases_oss; - if (placeholder_encoding == PlaceholderEncoding::json) { - atlases_oss << "["; - for (size_t i = 0; i < layout.atlases.size(); ++i) { - if (i > 0) atlases_oss << ","; - std::string a_path = format_atlas_path(output_pattern_arg, static_cast(i)); - atlases_oss << "{\"width\":" << layout.atlases[i].width << ",\"height\":" << layout.atlases[i].height; - if (!a_path.empty()) { - atlases_oss << ",\"path\":\"" << escape_json(a_path) << "\""; - } - atlases_oss << "}"; - } - atlases_oss << "]"; - } else if (placeholder_encoding == PlaceholderEncoding::xml) { - for (size_t i = 0; i < layout.atlases.size(); ++i) { - std::string a_path = format_atlas_path(output_pattern_arg, static_cast(i)); - atlases_oss << "\n"; - } + // Mode detection: group vs single + const bool has_dot = transform_arg.find('.') != std::string::npos; + bool group_mode = false; + std::vector group_members; + if (!output_dir_arg.empty() && !has_dot) { + group_members = discover_group_transforms(transform_arg, find_transforms_dir()); + group_mode = !group_members.empty(); } - global_vars["atlases"] = atlases_oss.str(); - global_vars["scale"] = format_double(layout.scale); - global_vars["extrude"] = std::to_string(layout.extrude); - global_vars["sprite_count"] = std::to_string(layout.sprites.size()); - global_vars["marker_count"] = std::to_string(marker_items.size()); - global_vars["animation_count"] = std::to_string(normalized_animation_items.size()); - global_vars["fps"] = std::to_string(animation_fps > 0 ? animation_fps : DEFAULT_ANIMATION_FPS); - global_vars["animation_fps"] = global_vars["fps"]; - global_vars["markers_path"] = markers_path_arg; - global_vars["animations_path"] = animations_path_arg; - global_vars["has_markers"] = marker_items.empty() ? "false" : "true"; - global_vars["has_animations"] = normalized_animation_items.empty() ? "false" : "true"; - global_vars["markers_raw"] = markers_text; - global_vars["animations_raw"] = animations_text; - if (!transform.header.empty()) { - std::cout << replace_tokens(transform.header, global_vars, placeholder_encoding); - } - - auto populate_marker_vars = [&](std::map& vars, const MarkerItem& marker, size_t index) { - vars["marker_index"] = std::to_string(index); - vars["marker_name"] = marker.name; - vars["marker_type"] = marker.type; - vars["marker_x"] = std::to_string(marker.x); - vars["marker_y"] = std::to_string(marker.y); - vars["marker_radius"] = std::to_string(marker.radius); - vars["marker_w"] = std::to_string(marker.w); - vars["marker_h"] = std::to_string(marker.h); - vars["marker_vertices"] = format_vertices(marker.vertices, placeholder_encoding); - vars["marker_sprite_index"] = std::to_string(marker.sprite_index); - vars["marker_sprite_name"] = marker.sprite_name; - vars["marker_sprite_path"] = marker.sprite_path; + + // Helper to compute output_stem from a transform path + auto compute_output_stem = [](const std::string& targ) -> std::string { + const std::string stem_str = resolve_transform_path(targ).stem().string(); + std::string stem = extract_variant(stem_str); + if (stem.empty()) stem = stem_str; + return stem; }; - auto populate_sprite_vars = [&](std::map& vars, size_t i) { - const Sprite& s = layout.sprites[i]; - vars["index"] = std::to_string(i); - vars["atlas_index"] = std::to_string(s.atlas_index); - std::string a_path = format_atlas_path(output_pattern_arg, s.atlas_index); - vars["atlas_path"] = a_path; - if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { - vars["atlas_width"] = std::to_string(layout.atlases[static_cast(s.atlas_index)].width); - vars["atlas_height"] = std::to_string(layout.atlases[static_cast(s.atlas_index)].height); - } - vars["path"] = s.path; - vars["name"] = sprite_names[i]; - vars["x"] = std::to_string(s.x); - vars["y"] = std::to_string(s.y); - vars["w"] = std::to_string(s.w); - vars["h"] = std::to_string(s.h); - vars["pivot_x"] = has_global_pivot ? std::to_string(global_pivot_x) : "0"; - vars["pivot_y"] = has_global_pivot ? std::to_string(global_pivot_y) : "0"; - for (const auto& marker : sprite_markers[i]) { - if (marker.name == "pivot" && marker.type == "point") { - vars["pivot_x"] = std::to_string(marker.x); - vars["pivot_y"] = std::to_string(marker.y); - break; + // Helper to write a single TransformResult to a destination + auto write_result = [&](const TransformResult& result, + const std::string& out_dir, + const std::string& file_stem, + std::ostream* stdout_out) -> int { + if (!result.files.empty()) { + // Multi-file mode: requires --output-dir + if (out_dir.empty()) { + std::cerr << tr("Transform produces multiple files; use --output-dir\n"); + return 1; } - } - vars["src_x"] = std::to_string(s.src_x); - vars["src_y"] = std::to_string(s.src_y); - vars["trim_left"] = std::to_string(s.src_x); - vars["trim_top"] = std::to_string(s.src_y); - vars["trim_right"] = std::to_string(s.trim_right); - vars["trim_bottom"] = std::to_string(s.trim_bottom); - const bool has_trim = (s.src_x != 0) || (s.src_y != 0) || (s.trim_right != 0) || (s.trim_bottom != 0); - vars["has_trim"] = has_trim ? "true" : "false"; - vars["sprite_markers_count"] = std::to_string(sprite_markers[i].size()); - vars["markers_json"] = format_markers_json(sprite_markers[i]); // Shortcut for quick JSON inclusion - - if (!transform.sprite_marker.empty()) { - std::string sprite_markers_formatted; - if (!sprite_markers[i].empty()) { - if (!transform.sprite_markers_header.empty()) { - sprite_markers_formatted += replace_tokens(transform.sprite_markers_header, vars, placeholder_encoding); - } - for (size_t j = 0; j < sprite_markers[i].size(); ++j) { - if (j > 0 && !transform.sprite_markers_separator.empty()) { - sprite_markers_formatted += replace_tokens(transform.sprite_markers_separator, vars, placeholder_encoding); - } - std::map mvars = vars; - populate_marker_vars(mvars, sprite_markers[i][j], j); - sprite_markers_formatted += replace_tokens(transform.sprite_marker, mvars, placeholder_encoding); - } - if (!transform.sprite_markers_footer.empty()) { - sprite_markers_formatted += replace_tokens(transform.sprite_markers_footer, vars, placeholder_encoding); + std::error_code ec; + fs::create_directories(out_dir, ec); + if (ec) { + std::cerr << tr("Failed to create output directory: ") << ec.message() << "\n"; + return 1; + } + int exit_code = 0; + for (const auto& fe : result.files) { + const fs::path out_path = fs::path(out_dir) / fe.filename; + std::ofstream out_file(out_path, std::ios::binary); + if (!out_file) { + std::cerr << tr("Failed to open output file: ") << out_path.string() << "\n"; + exit_code = 1; + continue; } + out_file << fe.content; } - vars["sprite_markers"] = sprite_markers_formatted; + return exit_code; } - vars["rotation"] = s.rotated ? "90" : "0"; - vars["rotated"] = s.rotated ? "true" : "false"; - }; - - if (!marker_items.empty()) { - if (!transform.if_markers.empty()) { - std::cout << replace_tokens(transform.if_markers, global_vars, placeholder_encoding); - } - if (!transform.markers_header.empty()) { - std::cout << replace_tokens(transform.markers_header, global_vars, placeholder_encoding); - } - if (!transform.markers.empty()) { - for (size_t i = 0; i < marker_items.size(); ++i) { - if (i > 0 && !transform.markers_separator.empty()) { - std::cout << replace_tokens(transform.markers_separator, global_vars, placeholder_encoding); - } - std::map vars = global_vars; - populate_marker_vars(vars, marker_items[i], i); - std::cout << replace_tokens(transform.markers, vars, placeholder_encoding); + // Single content mode + if (!out_dir.empty()) { + std::error_code ec; + fs::create_directories(out_dir, ec); + if (ec) { + std::cerr << tr("Failed to create output directory: ") << ec.message() << "\n"; + return 1; + } + const std::string out_filename = file_stem + result.extension; + const fs::path out_path = fs::path(out_dir) / out_filename; + std::ofstream out_file(out_path, std::ios::binary); + if (!out_file) { + std::cerr << tr("Failed to open output file: ") << out_path.string() << "\n"; + return 1; } + out_file << result.content; + return 0; } - if (!transform.markers_footer.empty()) { - std::cout << replace_tokens(transform.markers_footer, global_vars, placeholder_encoding); + + if (stdout_out) { + *stdout_out << result.content; } - } else if (!transform.if_no_markers.empty()) { - std::cout << replace_tokens(transform.if_no_markers, global_vars, placeholder_encoding); - } + return 0; + }; - if (!transform.atlas.empty()) { - if (!transform.atlas_header.empty()) { - std::cout << replace_tokens(transform.atlas_header, global_vars, placeholder_encoding); + if (group_mode) { + std::error_code ec; + fs::create_directories(output_dir_arg, ec); + if (ec) { + std::cerr << tr("Failed to create output directory: ") << ec.message() << "\n"; + return 1; } - for (size_t i = 0; i < layout.atlases.size(); ++i) { - if (i > 0 && !transform.atlas_separator.empty()) { - std::cout << replace_tokens(transform.atlas_separator, global_vars, placeholder_encoding); - } - std::map avars = global_vars; - avars["atlas_index"] = std::to_string(i); - avars["atlas_width"] = std::to_string(layout.atlases[i].width); - avars["atlas_height"] = std::to_string(layout.atlases[i].height); - std::string a_path = format_atlas_path(output_pattern_arg, static_cast(i)); - avars["atlas_path"] = a_path; - avars["atlas_path_json"] = escape_json(a_path); - avars["atlas_path_xml"] = escape_xml(a_path); - avars["atlas_path_csv"] = escape_csv(a_path); - avars["atlas_path_css"] = escape_css_string(a_path); - - std::string sprites_in_atlas; - for (size_t j = 0; j < layout.sprites.size(); ++j) { - if (layout.sprites[j].atlas_index == (int)i) { - if (!sprites_in_atlas.empty() && !transform.separator.empty()) { - sprites_in_atlas += replace_tokens(transform.separator, avars, placeholder_encoding); - } - std::map svars = avars; - populate_sprite_vars(svars, j); - sprites_in_atlas += replace_tokens(transform.sprite, svars, placeholder_encoding); - } + int exit_code = 0; + for (const GroupMember& member : group_members) { + const std::string sprat_json = build_sprat_json( + layout, sprite_names, marker_items, normalized_animations, sprite_markers, + global_pivot_x, global_pivot_y, has_global_pivot, + output_pattern_arg, member.variant, + markers_path_arg, animations_path_arg, animation_fps); + + std::string eval_error; + std::string output = evaluate_transform(member.path, sprat_json, eval_error); + if (output.empty() && !eval_error.empty()) { + std::cerr << eval_error << "\n"; + exit_code = 1; + continue; } - avars["sprites"] = sprites_in_atlas; - std::cout << replace_tokens(transform.atlas, avars, placeholder_encoding); - } - if (!transform.atlas_footer.empty()) { - std::cout << replace_tokens(transform.atlas_footer, global_vars, placeholder_encoding); - } - } else { - for (size_t i = 0; i < layout.sprites.size(); ++i) { - if (i > 0 && !transform.separator.empty()) { - std::cout << replace_tokens(transform.separator, global_vars, placeholder_encoding); + + TransformResult result; + std::string parse_error; + if (!parse_transform_result(output, result, parse_error)) { + std::cerr << parse_error << "\n"; + exit_code = 1; + continue; } - std::map vars = global_vars; - populate_sprite_vars(vars, i); - std::cout << replace_tokens(transform.sprite, vars, placeholder_encoding); + + const int r = write_result(result, output_dir_arg, member.variant, nullptr); + if (r != 0) exit_code = r; } + return exit_code; } - if (!normalized_animation_items.empty()) { - if (!transform.if_animations.empty()) { - std::cout << replace_tokens(transform.if_animations, global_vars, placeholder_encoding); - } - if (!transform.animations_header.empty()) { - std::cout << replace_tokens(transform.animations_header, global_vars, placeholder_encoding); - } - if (!transform.animations.empty()) { - for (size_t i = 0; i < normalized_animation_items.size(); ++i) { - if (i > 0 && !transform.animations_separator.empty()) { - std::cout << replace_tokens(transform.animations_separator, global_vars, placeholder_encoding); - } - const AnimationItem& animation = normalized_animation_items[i]; - std::map vars = global_vars; - vars["animation_index"] = std::to_string(i); - vars["animation_name"] = animation.name; - vars["animation_sprite_count"] = std::to_string(animation.sprite_indexes.size()); - vars["sprite_indexes"] = format_sprite_indexes(animation.sprite_indexes, placeholder_encoding); - vars["fps"] = std::to_string(animation.fps); - vars["animation_fps"] = vars["fps"]; - std::cout << replace_tokens(transform.animations, vars, placeholder_encoding); - } - } - if (!transform.animations_footer.empty()) { - std::cout << replace_tokens(transform.animations_footer, global_vars, placeholder_encoding); - } - } else if (!transform.if_no_animations.empty()) { - std::cout << replace_tokens(transform.if_no_animations, global_vars, placeholder_encoding); + // Single transform mode + const fs::path transform_path = resolve_transform_path(transform_arg); + const std::string output_stem = !output_dir_arg.empty() + ? compute_output_stem(transform_arg) + : ""; + + const std::string sprat_json = build_sprat_json( + layout, sprite_names, marker_items, normalized_animations, sprite_markers, + global_pivot_x, global_pivot_y, has_global_pivot, + output_pattern_arg, output_stem, + markers_path_arg, animations_path_arg, animation_fps); + + std::string eval_error; + std::string output = evaluate_transform(transform_path, sprat_json, eval_error); + if (output.empty() && !eval_error.empty()) { + std::cerr << eval_error << "\n"; + return 1; } - if (!transform.footer.empty()) { - std::cout << replace_tokens(transform.footer, global_vars, placeholder_encoding); + TransformResult result; + std::string parse_error; + if (!parse_transform_result(output, result, parse_error)) { + std::cerr << parse_error << "\n"; + return 1; } - return 0; + return write_result(result, output_dir_arg, output_stem, &std::cout); } diff --git a/src/commands/spratframes_command.cpp b/src/commands/spratframes_command.cpp index 16c684a..7a5629f 100644 --- a/src/commands/spratframes_command.cpp +++ b/src/commands/spratframes_command.cpp @@ -36,7 +36,6 @@ namespace fs = std::filesystem; #include #include #include -#include #include "core/cli_parse.h" #include "core/i18n.h" @@ -137,6 +136,9 @@ class SpriteFramesDetector { std::vector component_labels_; std::vector component_bounds_; std::vector component_sizes_; + + // Reusable flood fill stack + std::vector> fill_stack_; // Rectangle detection std::vector detected_rectangles_; @@ -257,45 +259,45 @@ class SpriteFramesDetector { } Rectangle flood_fill_rectangle(int start_x, int start_y, std::vector& visited) { - Rectangle bounds{.x=start_x, .y=start_y, .w=1, .h=1}; - std::queue> queue; - queue.emplace(start_x, start_y); + fill_stack_.clear(); + fill_stack_.emplace_back(start_x, start_y); visited.at((static_cast(start_y) * width_) + start_x) = 1; - + int min_x = start_x; int max_x = start_x; int min_y = start_y; int max_y = start_y; - - const std::array dx = {-1, 1, 0, 0}; - const std::array dy = {0, 0, -1, 1}; - - while (!queue.empty()) { - auto [x, y] = queue.front(); - queue.pop(); - + + constexpr std::array dx = {-1, 1, 0, 0}; + constexpr std::array dy = {0, 0, -1, 1}; + + while (!fill_stack_.empty()) { + auto [x, y] = fill_stack_.back(); + fill_stack_.pop_back(); + min_x = std::min(min_x, x); max_x = std::max(max_x, x); min_y = std::min(min_y, y); max_y = std::max(max_y, y); - + for (size_t i = 0; i < 4; ++i) { - int nx = x + dx.at(i); - int ny = y + dy.at(i); - + int nx = x + dx[i]; + int ny = y + dy[i]; + if (nx >= 0 && nx < width_ && ny >= 0 && ny < height_ && (visited.at((static_cast(ny) * width_) + nx) == 0U) && is_rectangle_pixel(nx, ny)) { visited.at((static_cast(ny) * width_) + nx) = 1; - queue.emplace(nx, ny); + fill_stack_.emplace_back(nx, ny); } } } - + + Rectangle bounds{}; bounds.x = min_x; bounds.y = min_y; bounds.w = max_x - min_x + 1; bounds.h = max_y - min_y + 1; - + return bounds; } @@ -442,47 +444,47 @@ class SpriteFramesDetector { } int flood_fill_component(int start_x, int start_y, int component_id, Rectangle& bounds) { - std::queue> queue; - queue.emplace(start_x, start_y); + fill_stack_.clear(); + fill_stack_.emplace_back(start_x, start_y); component_labels_.at((static_cast(start_y) * width_) + start_x) = component_id; - + int min_x = start_x; int max_x = start_x; int min_y = start_y; int max_y = start_y; int size = 0; - - const std::array dx = {-1, 1, 0, 0, -1, 1, -1, 1}; - const std::array dy = {0, 0, -1, 1, -1, -1, 1, 1}; - - while (!queue.empty()) { - auto [x, y] = queue.front(); - queue.pop(); + + constexpr std::array dx = {-1, 1, 0, 0, -1, 1, -1, 1}; + constexpr std::array dy = {0, 0, -1, 1, -1, -1, 1, 1}; + + while (!fill_stack_.empty()) { + auto [x, y] = fill_stack_.back(); + fill_stack_.pop_back(); size++; - + min_x = std::min(min_x, x); max_x = std::max(max_x, x); min_y = std::min(min_y, y); max_y = std::max(max_y, y); - + for (size_t i = 0; i < dx.size(); ++i) { - int nx = x + dx.at(i); - int ny = y + dy.at(i); - + int nx = x + dx[i]; + int ny = y + dy[i]; + if (nx >= 0 && nx < width_ && ny >= 0 && ny < height_ && component_labels_.at((static_cast(ny) * width_) + nx) == -1 && (is_sprite_pixel(nx, ny) || is_near_sprite_pixel(nx, ny))) { component_labels_.at((static_cast(ny) * width_) + nx) = component_id; - queue.emplace(nx, ny); + fill_stack_.emplace_back(nx, ny); } } } - + bounds.x = min_x; bounds.y = min_y; bounds.w = max_x - min_x + 1; bounds.h = max_y - min_y + 1; - + return size; } diff --git a/src/commands/spratlayout_command.cpp b/src/commands/spratlayout_command.cpp index d0e3755..7375fe7 100644 --- a/src/commands/spratlayout_command.cpp +++ b/src/commands/spratlayout_command.cpp @@ -1,6 +1,6 @@ // spratlayout.cpp // MIT License (c) 2026 Pedro -// Compile: g++ -std=c++17 -O2 src/spratlayout.cpp -o spratlayout +// Compile: g++ -std=c++20 -O2 src/spratlayout.cpp -o spratlayout #ifdef _WIN32 #ifndef NOMINMAX @@ -9,6 +9,7 @@ #include #include #include +#include #ifndef STDIN_FILENO #define STDIN_FILENO 0 #endif @@ -23,6 +24,12 @@ #endif #endif +#ifdef _MSC_VER +static inline int popcount64(unsigned long long x) { return (int)__popcnt64(x); } +#else +static inline int popcount64(unsigned long long x) { return __builtin_popcountll(x); } +#endif + #include #include #include @@ -32,6 +39,7 @@ namespace fs = std::filesystem; #include #include +#include #include #include #include @@ -49,6 +57,8 @@ namespace fs = std::filesystem; #include #include #include +#include +#include #include #include #include @@ -56,7 +66,6 @@ namespace fs = std::filesystem; #include "core/i18n.h" #include "core/fnv1a.h" -#include #include constexpr int k_output_cache_format_version = 3; @@ -84,19 +93,33 @@ std::string trim_copy(const std::string& s) { return s.substr(start, end - start); } -enum class Mode : std::uint8_t { POT, COMPACT, FAST }; +std::string normalize_path_key(const fs::path& path) { + std::error_code ec; + fs::path absolute = fs::absolute(path, ec); + if (!ec) { + return absolute.lexically_normal().string(); + } + return path.lexically_normal().string(); +} + +bool parse_quoted_path_argument(std::string_view input, size_t& pos, std::string& out) { + std::string error; + return sprat::core::parse_quoted(input, pos, out, error); +} + +enum class Mode : std::uint8_t { POT, COMPACT, FAST, GRID }; enum class OptimizeTarget : std::uint8_t { GPU, SPACE }; enum class ResolutionReference : std::uint8_t { Largest, Smallest }; struct ProfileDefinition { std::string name; + std::string label; Mode mode = Mode::COMPACT; OptimizeTarget optimize_target = OptimizeTarget::GPU; std::optional max_width; std::optional max_height; std::optional padding; std::optional extrude; - std::optional max_combinations; std::optional scale; std::optional trim_transparent; std::optional rotate; @@ -108,14 +131,12 @@ struct ProfileDefinition { }; constexpr const char* k_profiles_config_filename = "spratprofiles.cfg"; -constexpr const char* k_user_profiles_config_relpath = ".config/sprat/spratprofiles.cfg"; constexpr const char* k_global_profiles_config_path = SPRAT_GLOBAL_PROFILE_CONFIG; constexpr const char k_default_profile_name[] = "fast"; constexpr Mode k_default_mode = Mode::FAST; constexpr OptimizeTarget k_default_optimize_target = OptimizeTarget::GPU; constexpr int k_default_padding = 0; constexpr int k_default_extrude = 0; -constexpr int k_default_max_combinations = 0; constexpr double k_default_scale = 1.0; constexpr bool k_default_trim_transparent = false; constexpr unsigned int k_default_threads = 0; @@ -141,9 +162,9 @@ constexpr size_t k_ustar_sig_required_len = k_tar_magic_offset + 6; constexpr int k_floating_point_precision = 17; constexpr int k_search_step_divisor = 24; constexpr int k_search_step_min = 8; -constexpr size_t k_guided_offsets_count = 11; +constexpr size_t k_guided_offsets_count = 15; constexpr std::array k_guided_search_offsets = { - 0, -1, 1, -2, 2, -4, 4, -8, 8, -12, 12 + 0, -1, 1, -2, 2, -4, 4, -8, 8, -12, 12, -16, 16, -20, 20 }; constexpr size_t k_sort_mode_count = 6; constexpr size_t k_sort_mode_index_area = 0; @@ -160,7 +181,7 @@ enum class RectHeuristic : std::uint8_t { }; constexpr size_t k_rect_heuristic_count = 3; -constexpr size_t k_guided_anchor_count = 3; +constexpr size_t k_guided_anchor_count = 4; constexpr size_t k_guided_sort_mode_count = 4; constexpr std::array k_guided_sort_indices = { k_sort_mode_index_height, @@ -168,10 +189,11 @@ constexpr std::array k_guided_sort_indices = { k_sort_mode_index_maxside, k_sort_mode_index_none }; -constexpr size_t k_guided_heuristic_count = 2; +constexpr size_t k_guided_heuristic_count = 3; constexpr std::array k_guided_heuristics = { RectHeuristic::BestShortSideFit, - RectHeuristic::BestAreaFit + RectHeuristic::BestAreaFit, + RectHeuristic::BottomLeft }; constexpr long long k_cache_max_age_seconds = 3600; constexpr size_t k_cache_max_layout_files = 16; @@ -206,6 +228,10 @@ bool parse_mode_from_string(const std::string& value, Mode& out, std::string& er out = Mode::FAST; return true; } + if (lower == "grid") { + out = Mode::GRID; + return true; + } error = "invalid mode '" + value + "'"; return false; } @@ -224,6 +250,32 @@ bool parse_optimize_target_from_string(const std::string& value, OptimizeTarget& return false; } +struct PresetDefinition { + Mode mode; + OptimizeTarget optimize_target; +}; + +bool parse_preset_from_string(const std::string& value, PresetDefinition& out) { + std::string lower = to_lower_copy(value); + if (lower == "fast") { + out = {Mode::FAST, OptimizeTarget::GPU}; + return true; + } + if (lower == "quality") { + out = {Mode::COMPACT, OptimizeTarget::GPU}; + return true; + } + if (lower == "small") { + out = {Mode::COMPACT, OptimizeTarget::SPACE}; + return true; + } + if (lower == "pot") { + out = {Mode::POT, OptimizeTarget::GPU}; + return true; + } + return false; +} + bool parse_resolution_reference_from_string(const std::string& value, ResolutionReference& out, std::string& error) { @@ -295,6 +347,192 @@ bool parse_bool_value(const std::string& value, bool& out) { return false; } +// Parses a [profile ] section header. On success, initialises `out` +// with default values and the parsed name and returns true. +bool parse_profile_section_header(const std::string& header_content, + std::unordered_set& seen_names, + size_t line_number, + ProfileDefinition& out, + std::string& error) { + std::istringstream iss(header_content); + std::string section_type; + if (!(iss >> section_type)) { + error = "empty section header at line " + std::to_string(line_number); + return false; + } + section_type = to_lower_copy(section_type); + if (section_type != "profile") { + error = "unsupported section '" + section_type + "' at line " + std::to_string(line_number); + return false; + } + + std::string remainder; + std::getline(iss, remainder); + remainder = trim_copy(remainder); + if (remainder.empty()) { + error = "missing profile name at line " + std::to_string(line_number); + return false; + } + + std::string name; + if (remainder.front() == '"') { + size_t pos = 0; + std::string parse_error; + if (!sprat::core::parse_quoted(remainder, pos, name, parse_error)) { + error = "invalid quoted profile name at line " + std::to_string(line_number) + ": " + parse_error; + return false; + } + if (!trim_copy(remainder.substr(pos)).empty()) { + error = "unexpected token after quoted profile name at line " + std::to_string(line_number); + return false; + } + } else { + std::istringstream name_iss(remainder); + std::string extra; + if (!(name_iss >> name)) { + error = "missing profile name at line " + std::to_string(line_number); + return false; + } + if (name_iss >> extra) { + error = "unexpected token '" + extra + "' in profile name (use quotes for names with spaces) at line " + + std::to_string(line_number); + return false; + } + } + + if (seen_names.contains(name)) { + error = "duplicate profile '" + name + "' at line " + std::to_string(line_number); + return false; + } + seen_names.insert(name); + out = ProfileDefinition{}; + out.name = name; + out.mode = Mode::COMPACT; + out.optimize_target = OptimizeTarget::GPU; + return true; +} + +// Applies a single key=value entry to `def`. Returns false and sets `error` +// on any validation failure. +bool apply_profile_entry(ProfileDefinition& def, + const std::string& key, + const std::string& value, + size_t line_number, + std::string& error) { + const std::string lower_key = to_lower_copy(key); + if (lower_key == "mode") { + Mode parsed_mode; + if (!parse_mode_from_string(value, parsed_mode, error)) { + error += " at line " + std::to_string(line_number); + return false; + } + def.mode = parsed_mode; + } else if (lower_key == "optimize") { + OptimizeTarget parsed_target; + if (!parse_optimize_target_from_string(value, parsed_target, error)) { + error += " at line " + std::to_string(line_number); + return false; + } + def.optimize_target = parsed_target; + } else if (lower_key == "max_width" || lower_key == "default_max_width") { + int parsed_width = 0; + if (!parse_positive_int(value, parsed_width)) { + error = "invalid max_width '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.max_width = parsed_width; + } else if (lower_key == "max_height" || lower_key == "default_max_height") { + int parsed_height = 0; + if (!parse_positive_int(value, parsed_height)) { + error = "invalid max_height '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.max_height = parsed_height; + } else if (lower_key == "padding") { + int parsed_padding = 0; + if (!parse_non_negative_int(value, parsed_padding)) { + error = "invalid padding '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.padding = parsed_padding; + } else if (lower_key == "extrude") { + int parsed_extrude = 0; + if (!parse_non_negative_int(value, parsed_extrude)) { + error = "invalid extrude '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.extrude = parsed_extrude; + } else if (lower_key == "scale") { + double parsed_scale = 0.0; + if (!parse_scale_factor(value, parsed_scale)) { + error = "invalid scale '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.scale = parsed_scale; + } else if (lower_key == "trim_transparent") { + bool parsed_trim = false; + if (!parse_bool_value(value, parsed_trim)) { + error = "invalid trim_transparent '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.trim_transparent = parsed_trim; + } else if (lower_key == "rotate") { + bool parsed_rotate = false; + if (!parse_bool_value(value, parsed_rotate)) { + error = "invalid rotate '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.rotate = parsed_rotate; + } else if (lower_key == "threads") { + unsigned int parsed_threads = 0; + if (!parse_positive_uint(value, parsed_threads)) { + error = "invalid threads '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.threads = parsed_threads; + } else if (lower_key == "multipack") { + bool parsed_multipack = false; + if (!parse_bool_value(value, parsed_multipack)) { + error = "invalid multipack '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.multipack = parsed_multipack; + } else if (lower_key == "source_resolution") { + int w = 0; + int h = 0; + if (!parse_resolution(value, w, h)) { + error = "invalid source_resolution '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.source_resolution = std::make_pair(w, h); + } else if (lower_key == "target_resolution") { + if (to_lower_copy(value) == "source") { + def.target_resolution = std::make_pair(-1, -1); + } else { + int w = 0; + int h = 0; + if (!parse_resolution(value, w, h)) { + error = "invalid target_resolution '" + value + "' at line " + std::to_string(line_number); + return false; + } + def.target_resolution = std::make_pair(w, h); + } + } else if (lower_key == "resolution_reference") { + ResolutionReference ref; + if (!parse_resolution_reference_from_string(value, ref, error)) { + error += " at line " + std::to_string(line_number); + return false; + } + def.resolution_reference = ref; + } else if (lower_key == "label") { + def.label = value; + } else { + error = "unknown key '" + key + "' at line " + std::to_string(line_number); + return false; + } + return true; +} + bool parse_profiles_config(std::istream& input, std::vector& out, std::string& error) { @@ -303,9 +541,10 @@ bool parse_profiles_config(std::istream& input, std::optional current; std::string line; size_t line_number = 0; + while (std::getline(input, line)) { ++line_number; - std::string trimmed = trim_copy(line); + const std::string trimmed = trim_copy(line); if (trimmed.empty() || trimmed.front() == '#' || trimmed.front() == ';') { continue; } @@ -313,66 +552,13 @@ bool parse_profiles_config(std::istream& input, if (trimmed.front() == '[' && trimmed.back() == ']') { if (current) { out.push_back(*current); - current.reset(); } - std::string header = trimmed.substr(1, trimmed.size() - 2); - std::istringstream iss(header); - std::string section_type; - if (!(iss >> section_type)) { - error = "empty section header at line " + std::to_string(line_number); - return false; - } - section_type = to_lower_copy(section_type); - if (section_type != "profile") { - error = "unsupported section '" + section_type + "' at line " + std::to_string(line_number); - return false; - } - - std::string name; - std::string remainder; - std::getline(iss, remainder); - remainder = trim_copy(remainder); - - if (remainder.empty()) { - error = "missing profile name at line " + std::to_string(line_number); - return false; - } - - if (remainder.front() == '"') { - size_t pos = 0; - std::string parse_error; - if (!sprat::core::parse_quoted(remainder, pos, name, parse_error)) { - error = "invalid quoted profile name at line " + std::to_string(line_number) + ": " + parse_error; - return false; - } - std::string extra = trim_copy(remainder.substr(pos)); - if (!extra.empty()) { - error = "unexpected token '" + extra + "' after quoted profile name at line " + std::to_string(line_number); - return false; - } - } else { - std::istringstream name_iss(remainder); - if (!(name_iss >> name)) { - error = "missing profile name at line " + std::to_string(line_number); - return false; - } - std::string extra; - if (name_iss >> extra) { - error = "unexpected token '" + extra + "' in profile name (use quotes for names with spaces) at line " + - std::to_string(line_number); - return false; - } - } - - if (seen_names.contains(name)) { - error = "duplicate profile '" + name + "' at line " + std::to_string(line_number); + current.reset(); + ProfileDefinition def; + if (!parse_profile_section_header(trimmed.substr(1, trimmed.size() - 2), + seen_names, line_number, def, error)) { return false; } - seen_names.insert(name); - ProfileDefinition def; - def.name = name; - def.mode = Mode::COMPACT; - def.optimize_target = OptimizeTarget::GPU; current = def; continue; } @@ -382,13 +568,13 @@ bool parse_profiles_config(std::istream& input, return false; } - size_t equals = trimmed.find('='); + const size_t equals = trimmed.find('='); if (equals == std::string::npos) { error = "invalid line '" + trimmed + "' at line " + std::to_string(line_number); return false; } - std::string key = trim_copy(trimmed.substr(0, equals)); - std::string value = trim_copy(trimmed.substr(equals + 1)); + const std::string key = trim_copy(trimmed.substr(0, equals)); + const std::string value = trim_copy(trimmed.substr(equals + 1)); if (key.empty()) { error = "empty key at line " + std::to_string(line_number); return false; @@ -397,123 +583,7 @@ bool parse_profiles_config(std::istream& input, error = "empty value for key '" + key + "' at line " + std::to_string(line_number); return false; } - - std::string lower_key = to_lower_copy(key); - if (lower_key == "mode") { - Mode parsed_mode; - if (!parse_mode_from_string(value, parsed_mode, error)) { - error += " at line " + std::to_string(line_number); - return false; - } - current->mode = parsed_mode; - } else if (lower_key == "optimize") { - OptimizeTarget parsed_target; - if (!parse_optimize_target_from_string(value, parsed_target, error)) { - error += " at line " + std::to_string(line_number); - return false; - } - current->optimize_target = parsed_target; - } else if (lower_key == "max_width" || lower_key == "default_max_width") { - int parsed_width = 0; - if (!parse_positive_int(value, parsed_width)) { - error = "invalid max_width '" + value + "' at line " + - std::to_string(line_number); - return false; - } - current->max_width = parsed_width; - } else if (lower_key == "max_height" || lower_key == "default_max_height") { - int parsed_height = 0; - if (!parse_positive_int(value, parsed_height)) { - error = "invalid max_height '" + value + "' at line " + - std::to_string(line_number); - return false; - } - current->max_height = parsed_height; - } else if (lower_key == "padding") { - int parsed_padding = 0; - if (!parse_non_negative_int(value, parsed_padding)) { - error = "invalid padding '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->padding = parsed_padding; - } else if (lower_key == "extrude") { - int parsed_extrude = 0; - if (!parse_non_negative_int(value, parsed_extrude)) { - error = "invalid extrude '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->extrude = parsed_extrude; - } else if (lower_key == "max_combinations") { - int parsed_max_combinations = 0; - if (!parse_non_negative_int(value, parsed_max_combinations)) { - error = "invalid max_combinations '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->max_combinations = parsed_max_combinations; - } else if (lower_key == "scale") { - double parsed_scale = 0.0; - if (!parse_scale_factor(value, parsed_scale)) { - error = "invalid scale '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->scale = parsed_scale; - } else if (lower_key == "trim_transparent") { - bool parsed_trim = false; - if (!parse_bool_value(value, parsed_trim)) { - error = "invalid trim_transparent '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->trim_transparent = parsed_trim; - } else if (lower_key == "rotate") { - bool parsed_rotate = false; - if (!parse_bool_value(value, parsed_rotate)) { - error = "invalid rotate '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->rotate = parsed_rotate; - } else if (lower_key == "threads") { - unsigned int parsed_threads = 0; - if (!parse_positive_uint(value, parsed_threads)) { - error = "invalid threads '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->threads = parsed_threads; - } else if (lower_key == "multipack") { - bool parsed_multipack = false; - if (!parse_bool_value(value, parsed_multipack)) { - error = "invalid multipack '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->multipack = parsed_multipack; - } else if (lower_key == "source_resolution") { - int w = 0; - int h = 0; - if (!parse_resolution(value, w, h)) { - error = "invalid source_resolution '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->source_resolution = std::make_pair(w, h); - } else if (lower_key == "target_resolution") { - if (to_lower_copy(value) == "source") { - current->target_resolution = std::make_pair(-1, -1); - } else { - int w = 0; - int h = 0; - if (!parse_resolution(value, w, h)) { - error = "invalid target_resolution '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->target_resolution = std::make_pair(w, h); - } - } else if (lower_key == "resolution_reference") { - ResolutionReference ref; - if (!parse_resolution_reference_from_string(value, ref, error)) { - error += " at line " + std::to_string(line_number); - return false; - } - current->resolution_reference = ref; - } else { - error = "unknown key '" + key + "' at line " + std::to_string(line_number); + if (!apply_profile_entry(*current, key, value, line_number, error)) { return false; } } @@ -521,7 +591,6 @@ bool parse_profiles_config(std::istream& input, if (current) { out.push_back(*current); } - if (out.empty()) { error = "no profiles defined"; return false; @@ -541,8 +610,8 @@ bool load_profiles_config_from_file(const fs::path& path, } std::optional resolve_user_profiles_config_path() { - // 1. Windows: %APPDATA%\sprat or %LOCALAPPDATA%\sprat #ifdef _WIN32 + // Windows: %APPDATA%\sprat\spratprofiles.cfg static const char* const envs[] = {"APPDATA", "LOCALAPPDATA"}; for (const char* env : envs) { const char* val = std::getenv(env); @@ -554,46 +623,52 @@ std::optional resolve_user_profiles_config_path() { } } } -#endif - + return std::nullopt; +#elif defined(__APPLE__) + // macOS: ~/Library/Application Support/sprat/spratprofiles.cfg const char* home = std::getenv("HOME"); if (home == nullptr || home[0] == '\0') { return std::nullopt; } - - // 2. macOS: ~/Library/Preferences/sprat/spratprofiles.cfg -#ifdef __APPLE__ - const fs::path mac_cfg = fs::path(home) / "Library" / "Preferences" / "sprat" / k_profiles_config_filename; + const fs::path mac_cfg = fs::path(home) / "Library" / "Application Support" / "sprat" / k_profiles_config_filename; std::error_code ec_mac; if (fs::exists(mac_cfg, ec_mac) && !ec_mac) { return mac_cfg; } -#endif - - // 3. Others (Linux): ~/.config/sprat/spratprofiles.cfg - const fs::path home_cfg = fs::path(home) / k_user_profiles_config_relpath; + return std::nullopt; +#else + // Linux/other: $XDG_CONFIG_HOME/sprat/spratprofiles.cfg (default ~/.config/sprat/) + const char* home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') { + return std::nullopt; + } + const char* xdg_config_home = std::getenv("XDG_CONFIG_HOME"); + const fs::path cfg = (xdg_config_home != nullptr && xdg_config_home[0] != '\0') + ? fs::path(xdg_config_home) / "sprat" / k_profiles_config_filename + : fs::path(home) / ".config" / "sprat" / k_profiles_config_filename; std::error_code ec; - if (fs::exists(home_cfg, ec) && !ec) { - return home_cfg; + if (fs::exists(cfg, ec) && !ec) { + return cfg; } return std::nullopt; +#endif } -std::vector build_default_profiles_config_candidates(const fs::path& cwd, const fs::path& exec_dir) { +std::vector build_default_profiles_config_candidates(const fs::path& exec_dir) { std::vector candidates; // Lookup order: - // 1) user config (Windows: %APPDATA%\sprat\spratprofiles.cfg, - // others: ~/.config/sprat/spratprofiles.cfg) - // 2) ./spratprofiles.cfg (current directory) - // 3) {exec_dir}/spratprofiles.cfg (beside executable) - // 4) global installed config + // 1) {exec_dir}/spratprofiles.cfg (beside executable, portable install) + // 2) user config: + // Windows: %APPDATA%\sprat\spratprofiles.cfg + // macOS: ~/Library/Application Support/sprat/spratprofiles.cfg + // Linux: $XDG_CONFIG_HOME/sprat/spratprofiles.cfg (default ~/.config/sprat/) + // 3) global installed config + if (!exec_dir.empty()) { + candidates.push_back(exec_dir / k_profiles_config_filename); + } if (std::optional user_config = resolve_user_profiles_config_path()) { candidates.push_back(*user_config); } - candidates.push_back(cwd / k_profiles_config_filename); - if (exec_dir != cwd && !exec_dir.empty()) { - candidates.push_back(exec_dir / k_profiles_config_filename); - } candidates.emplace_back(k_global_profiles_config_path); return candidates; } @@ -635,7 +710,8 @@ struct ImageCacheEntry { int trim_right = 0; int trim_bottom = 0; long long cached_at_unix = 0; - uint64_t content_hash = 0; // FNV-1a hash of raw RGBA buffer (0 if not computed) + uint64_t content_hash = 0; // FNV-1a hash of visible pixel region (0 = not computed) + uint64_t perceptual_hash = 0; // dHash of visible pixel region (0 = not computed) }; struct LayoutCandidate { @@ -695,15 +771,6 @@ bool checked_mul_size_t(size_t a, size_t b, size_t& out) { return false; } -inline bool pixel_is_opaque(const unsigned char* rgba, int width, int x, int y) { - if (rgba == nullptr || width <= 0 || x < 0 || y < 0 || x >= width) { - return false; - } - const size_t pixel_index = (static_cast(y) * static_cast(width)) + static_cast(x); - const size_t alpha_index = (pixel_index * static_cast(4)) + static_cast(3); - return rgba[alpha_index] != 0; -} - bool compute_trim_bounds(const unsigned char* rgba, int w, int h, @@ -724,15 +791,19 @@ bool compute_trim_bounds(const unsigned char* rgba, return false; } + // Use direct pointer arithmetic for alpha channel access. + // Bounds are already validated above (w > 0, h > 0, w/h <= k_max_image_dimension). + const size_t stride = static_cast(w) * 4; + int top_hit_x = -1; for (int y = 0; y < h; ++y) { + const unsigned char* row_alpha = rgba + static_cast(y) * stride + 3; for (int x = 0; x < w; ++x) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (row_alpha[static_cast(x) * 4] != 0) { + min_y = y; + top_hit_x = x; + break; } - min_y = y; - top_hit_x = x; - break; } if (top_hit_x >= 0) { break; @@ -744,13 +815,13 @@ bool compute_trim_bounds(const unsigned char* rgba, int bottom_hit_x = -1; for (int y = h - 1; y >= min_y; --y) { + const unsigned char* row_alpha = rgba + static_cast(y) * stride + 3; for (int x = w - 1; x >= 0; --x) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (row_alpha[static_cast(x) * 4] != 0) { + max_y = y; + bottom_hit_x = x; + break; } - max_y = y; - bottom_hit_x = x; - break; } if (bottom_hit_x >= 0) { break; @@ -761,13 +832,13 @@ bool compute_trim_bounds(const unsigned char* rgba, min_x = left_search_end; for (int x = 0; x <= left_search_end; ++x) { bool found = false; + const unsigned char* col_alpha = rgba + static_cast(x) * 4 + 3; for (int y = min_y; y <= max_y; ++y) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (col_alpha[static_cast(y) * stride] != 0) { + min_x = x; + found = true; + break; } - min_x = x; - found = true; - break; } if (found) { break; @@ -778,13 +849,13 @@ bool compute_trim_bounds(const unsigned char* rgba, max_x = right_search_start; for (int x = w - 1; x >= right_search_start; --x) { bool found = false; + const unsigned char* col_alpha = rgba + static_cast(x) * 4 + 3; for (int y = min_y; y <= max_y; ++y) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (col_alpha[static_cast(y) * stride] != 0) { + max_x = x; + found = true; + break; } - max_x = x; - found = true; - break; } if (found) { break; @@ -891,101 +962,116 @@ bool is_compressed_tar_file(const fs::path& path) { return detect_content_type_from_path(path) == ContentType::CompressedTarFile; } +// Returns an empty path if entry_name would escape output_dir (Zip Slip guard). +fs::path safe_extract_path(const fs::path& output_dir, const char* entry_name) { + if (entry_name == nullptr) { + return {}; + } + fs::path entry(entry_name); + if (entry.is_absolute()) { + return {}; + } + fs::path candidate = (output_dir / entry).lexically_normal(); + fs::path rel = candidate.lexically_relative(output_dir.lexically_normal()); + if (rel.empty() || rel.begin()->string() == "..") { + return {}; + } + return candidate; +} + bool extract_tar_file(const fs::path& tar_path, const fs::path& output_dir) { struct archive* a = archive_read_new(); if (a == nullptr) { std::cerr << tr("Error: Failed to create archive reader") << '\n'; return false; } - + // Enable all supported formats and compression archive_read_support_format_all(a); archive_read_support_filter_all(a); - + if (archive_read_open_filename(a, tar_path.string().c_str(), k_tar_read_buffer_size) != ARCHIVE_OK) { std::cerr << tr("Error: Failed to open archive file: ") << archive_error_string(a) << '\n'; archive_read_free(a); return false; } - + struct archive* ext = archive_write_disk_new(); if (ext == nullptr) { std::cerr << tr("Error: Failed to create archive writer") << '\n'; archive_read_free(a); return false; } - + archive_write_disk_set_options(ext, ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM | ARCHIVE_EXTRACT_ACL | ARCHIVE_EXTRACT_FFLAGS); - - la_ssize_t r = 0; + + bool had_error = false; struct archive_entry* entry = nullptr; - + while (true) { - r = archive_read_next_header(a, &entry); + la_ssize_t r = archive_read_next_header(a, &entry); if (r == ARCHIVE_EOF) { break; } if (r < ARCHIVE_OK) { std::cerr << tr("Error: Failed to read archive header: ") << archive_error_string(a) << '\n'; + had_error = true; break; } - + // Skip directories if (archive_entry_filetype(entry) == AE_IFDIR) { continue; } - - // Get the filename - const char* filename = archive_entry_pathname(entry); - if (filename == nullptr) { + + // Validate and resolve extraction path (guards against Zip Slip) + fs::path output_path = safe_extract_path(output_dir, archive_entry_pathname(entry)); + if (output_path.empty()) { + std::cerr << tr("Warning: Skipping archive entry with unsafe path: ") + << (archive_entry_pathname(entry) ? archive_entry_pathname(entry) : "(null)") << '\n'; continue; } - - // Extract to the correct path (preserving directory structure) - fs::path file_path(filename); - fs::path output_path = output_dir / file_path; - + // Create parent directory if needed std::error_code ec; fs::create_directories(output_path.parent_path(), ec); - + // Set the extraction path archive_entry_set_pathname(entry, output_path.string().c_str()); - + // Extract the file r = archive_write_header(ext, entry); if (r < ARCHIVE_OK) { std::cerr << tr("Error: Failed to write archive header: ") << archive_error_string(ext) << '\n'; + had_error = true; } else { const void* buff = nullptr; size_t size = 0; la_int64_t offset = 0; - + bool block_error = false; + while (archive_read_data_block(a, &buff, &size, &offset) == ARCHIVE_OK) { - r = archive_write_data_block(ext, buff, size, offset); - if (r < ARCHIVE_OK) { + if (archive_write_data_block(ext, buff, size, offset) < ARCHIVE_OK) { std::cerr << tr("Error: Failed to write archive data: ") << archive_error_string(ext) << '\n'; + had_error = true; + block_error = true; break; } } - - if (r < ARCHIVE_OK) { - std::cerr << tr("Error: Failed to read archive data: ") << archive_error_string(a) << '\n'; + + if (!block_error && archive_write_finish_entry(ext) < ARCHIVE_OK) { + std::cerr << tr("Error: Failed to finish archive entry: ") << archive_error_string(ext) << '\n'; + had_error = true; } } - - r = archive_write_finish_entry(ext); - if (r < ARCHIVE_OK) { - std::cerr << tr("Error: Failed to finish archive entry: ") << archive_error_string(ext) << '\n'; - } } - + archive_read_close(a); archive_read_free(a); archive_write_close(ext); archive_write_free(ext); - - return true; + + return !had_error; } bool extract_tar_from_stdin(const fs::path& output_dir) { @@ -1014,83 +1100,82 @@ bool extract_tar_from_stdin(const fs::path& output_dir) { } archive_write_disk_set_options(ext, ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM | ARCHIVE_EXTRACT_ACL | ARCHIVE_EXTRACT_FFLAGS); - - la_ssize_t r = 0; + + bool had_error = false; struct archive_entry* entry = nullptr; - + while (true) { - r = archive_read_next_header(a, &entry); + la_ssize_t r = archive_read_next_header(a, &entry); if (r == ARCHIVE_EOF) { break; } if (r < ARCHIVE_OK) { std::cerr << tr("Error: Failed to read archive header: ") << archive_error_string(a) << '\n'; + had_error = true; break; } - + // Skip directories if (archive_entry_filetype(entry) == AE_IFDIR) { continue; } - - // Get the filename - const char* filename = archive_entry_pathname(entry); - if (filename == nullptr) { + + // Validate and resolve extraction path (guards against Zip Slip) + fs::path output_path = safe_extract_path(output_dir, archive_entry_pathname(entry)); + if (output_path.empty()) { + std::cerr << tr("Warning: Skipping archive entry with unsafe path: ") + << (archive_entry_pathname(entry) ? archive_entry_pathname(entry) : "(null)") << '\n'; continue; } - - // Extract to the correct path (preserving directory structure) - fs::path file_path(filename); - fs::path output_path = output_dir / file_path; - + // Create parent directory if needed std::error_code ec; fs::create_directories(output_path.parent_path(), ec); - + // Set the extraction path archive_entry_set_pathname(entry, output_path.string().c_str()); - + // Extract the file r = archive_write_header(ext, entry); if (r < ARCHIVE_OK) { std::cerr << tr("Error: Failed to write archive header: ") << archive_error_string(ext) << '\n'; + had_error = true; } else { const void* buff = nullptr; size_t size = 0; la_int64_t offset = 0; - + bool block_error = false; + while (archive_read_data_block(a, &buff, &size, &offset) == ARCHIVE_OK) { - r = archive_write_data_block(ext, buff, size, offset); - if (r < ARCHIVE_OK) { + if (archive_write_data_block(ext, buff, size, offset) < ARCHIVE_OK) { std::cerr << tr("Error: Failed to write archive data: ") << archive_error_string(ext) << '\n'; + had_error = true; + block_error = true; break; } } - - if (r < ARCHIVE_OK) { - std::cerr << tr("Error: Failed to read archive data: ") << archive_error_string(a) << '\n'; + + if (!block_error && archive_write_finish_entry(ext) < ARCHIVE_OK) { + std::cerr << tr("Error: Failed to finish archive entry: ") << archive_error_string(ext) << '\n'; + had_error = true; } } - - r = archive_write_finish_entry(ext); - if (r < ARCHIVE_OK) { - std::cerr << tr("Error: Failed to finish archive entry: ") << archive_error_string(ext) << '\n'; - } } - + archive_read_close(a); archive_read_free(a); archive_write_close(ext); archive_write_free(ext); - - return true; + + return !had_error; } enum class InputType : std::uint8_t { Directory, ListFile, TarFile, - StdinTar + StdinTar, + StdinList }; struct InputContext { @@ -1108,8 +1193,10 @@ bool detect_and_extract_tar_content(const fs::path& input_path, InputContext& ou out_context.temp_dirs_to_cleanup.clear(); if (is_tar || is_compressed_tar) { - // Create a temporary directory for extraction - fs::path temp_dir = fs::temp_directory_path() / "spratlayout_extract"; + // Use a unique directory per invocation to avoid races between concurrent processes. + static std::atomic extract_counter{0}; + fs::path temp_dir = fs::temp_directory_path() + / ("spratlayout_extract_" + std::to_string(extract_counter.fetch_add(1))); std::error_code ec; fs::create_directories(temp_dir, ec); if (ec) { @@ -1141,13 +1228,15 @@ bool detect_and_extract_tar_content(const fs::path& input_path, InputContext& ou out_context.working_folder = input_path; return true; } - + return false; } bool load_content_from_stdin(InputContext& out_context) { - // Create a temporary directory for extraction - fs::path temp_dir = fs::temp_directory_path() / "spratlayout_extract_stdin"; + // Use a unique directory per invocation to avoid races between concurrent processes. + static std::atomic stdin_counter{0}; + fs::path temp_dir = fs::temp_directory_path() + / ("spratlayout_extract_stdin_" + std::to_string(stdin_counter.fetch_add(1))); std::error_code ec; fs::create_directories(temp_dir, ec); if (ec) { @@ -1173,7 +1262,14 @@ bool load_content_from_stdin(InputContext& out_context) { return true; } -void prune_stale_cache_entries(std::unordered_map& entries, +bool load_list_from_stdin(InputContext& out_context, const fs::path& cwd) { + out_context.type = InputType::StdinList; + out_context.working_folder = cwd; + out_context.temp_dirs_to_cleanup.clear(); + return true; +} + +void prune_stale_cache_entries(std::unordered_map& entries, long long now_unix, long long max_age_seconds) { if (max_age_seconds < 0 || max_age_seconds > k_max_cache_age_seconds_limit) { // 1 year limit @@ -1202,7 +1298,7 @@ bool load_image_cache(const fs::path& cache_path, if (!(in >> header_tag >> version)) { return false; } - if (header_tag != "spratlayout_cache" || (version != 1 && version != 2)) { + if (header_tag != "spratlayout_cache" || (version != 1 && version != 2 && version != 3)) { return false; } @@ -1223,17 +1319,25 @@ bool load_image_cache(const fs::path& cache_path, >> entry.trim_bottom)) { break; } - if (version == 2) { + if (version >= 2) { if (!(in >> entry.cached_at_unix)) { break; } } + if (version >= 3) { + if (!(in >> entry.content_hash >> entry.perceptual_hash)) { + break; + } + } if (entry.w <= 0 || entry.h <= 0 || entry.w > k_max_image_dimension || entry.h > k_max_image_dimension) { continue; } entry.trim_transparent = trim_flag != 0; const std::string key = path + (entry.trim_transparent ? "|1" : "|0"); out[key] = entry; + if (out.size() >= k_max_cache_entries) { + break; + } } return true; @@ -1241,10 +1345,6 @@ bool load_image_cache(const fs::path& cache_path, bool save_image_cache(const fs::path& cache_path, const std::unordered_map& entries) { - if (entries.size() > k_max_cache_entries) { // Limit cache size - return false; - } - fs::path tmp = cache_path; tmp += ".tmp"; @@ -1253,18 +1353,32 @@ bool save_image_cache(const fs::path& cache_path, return false; } - out << "spratlayout_cache 2\n"; + // Collect valid entries; if over the limit, keep only the most recently used. + using KV = std::pair; + std::vector valid; + valid.reserve(entries.size()); for (const auto& kv : entries) { - std::string path = kv.first; + const ImageCacheEntry& e = kv.second; + if (e.w > 0 && e.h > 0 && e.w <= k_max_image_dimension && e.h <= k_max_image_dimension) { + valid.push_back({&kv.first, &e}); + } + } + if (valid.size() > k_max_cache_entries) { + std::ranges::sort(valid, [](const KV& a, const KV& b) { + return a.second->cached_at_unix > b.second->cached_at_unix; // newest first + }); + valid.resize(k_max_cache_entries); + } + + out << "spratlayout_cache 3\n"; + for (const auto& [key_ptr, e_ptr] : valid) { + std::string path = *key_ptr; if (path.size() > 2 && path[path.size() - 2] == '|' && (path.back() == '0' || path.back() == '1')) { path = path.substr(0, path.size() - 2); } - const ImageCacheEntry& e = kv.second; - if (e.w <= 0 || e.h <= 0 || e.w > k_max_image_dimension || e.h > k_max_image_dimension) { - continue; - } + const ImageCacheEntry& e = *e_ptr; out << std::quoted(path) << " " << (e.trim_transparent ? 1 : 0) << " " << e.file_size << " " @@ -1275,7 +1389,9 @@ bool save_image_cache(const fs::path& cache_path, << e.trim_top << " " << e.trim_right << " " << e.trim_bottom << " " - << e.cached_at_unix << "\n"; + << e.cached_at_unix << " " + << e.content_hash << " " + << e.perceptual_hash << "\n"; } out.close(); if (!out) { @@ -1337,11 +1453,13 @@ fs::path build_cache_path(const fs::path& folder) { std::error_code ec; fs::path normalized = fs::absolute(folder, ec); std::string folder_key = (!ec ? normalized.lexically_normal().string() : folder.string()); - size_t hash = std::hash{}(folder_key); + uint64_t hash = sprat::core::fnv1a_hash( + reinterpret_cast(folder_key.data()), folder_key.size()); - std::ostringstream name; - name << "spratlayout_" << std::hex << hash << ".cache"; - return cache_root_dir() / name.str(); + std::array buf{}; + std::snprintf(buf.data(), buf.size(), "spratlayout_%016llx.cache", + static_cast(hash)); + return cache_root_dir() / buf.data(); } fs::path build_output_cache_path(const fs::path& base_cache_path, @@ -1355,29 +1473,37 @@ fs::path build_seed_cache_path(const fs::path& base_cache_path, } std::string to_hex_size_t(size_t value) { - std::ostringstream oss; - oss << std::hex << value; - return oss.str(); + std::array buf{}; + int n = std::snprintf(buf.data(), buf.size(), "%zx", value); + return std::string(buf.data(), n > 0 ? static_cast(n) : 0); } + void print_usage() { std::cout << tr("Usage: spratlayout [OPTIONS]\n") + << tr(" spratlayout --stdin-list [OPTIONS]\n") << tr("\n") << tr("Scan an image folder/list/tar and write a text layout to standard output.\n") << tr("Rotated sprites are emitted with a trailing 'rotated' token.\n") << tr("\n") + << tr("Presets (recommended starting point):\n") + << tr(" --preset fast Quick shelf packing, GPU-friendly dimensions\n") + << tr(" --preset quality Best packing quality, GPU-friendly dimensions\n") + << tr(" --preset small Best packing quality, smallest possible area\n") + << tr(" --preset pot Power-of-two atlas, GPU-friendly dimensions\n") + << tr("\n") << tr("Options:\n") << tr(" --profile NAME Profile name from config (default: fast)\n") << tr(" --profiles-config PATH Use an explicit profile configuration file\n") - << tr(" --mode MODE Packing mode: compact, pot, or fast\n") - << tr(" --optimize TARGET Optimization target: gpu or space\n") + << tr(" --default-profiles-config Print the default profiles config path and exit\n") + << tr(" --mode MODE Packing algorithm: compact, pot, or fast\n") + << tr(" --optimize TARGET Optimization metric: gpu (min max-side) or space (min area)\n") << tr(" --max-width N Maximum atlas width\n") << tr(" --max-height N Maximum atlas height\n") << tr(" --no-max-width Disable width limit (even if profile sets one)\n") << tr(" --no-max-height Disable height limit (even if profile sets one)\n") << tr(" --padding N Extra pixels between packed sprites\n") << tr(" --extrude N Repeat edge pixels N times (padding should be >= extrude * 2)\n") - << tr(" --max-combinations N Max combinations for compact search (0=auto)\n") << tr(" --source-resolution WxH Source design resolution baseline\n") << tr(" --target-resolution WxH Target output resolution\n") << tr(" --resolution-reference REF Axis ratio driver: largest or smallest\n") @@ -1386,9 +1512,13 @@ void print_usage() { << tr(" --rotate Allow 90-degree sprite rotation during packing\n") << tr(" --multipack Split into multiple atlases if they don't fit\n") << tr(" --deduplicate Deduplication mode: none, exact, perceptual\n") - << tr(" --sort name|none Order of sprites in layout (default: name for folders)\n") + << tr(" --sort name|none|stable[:] Order of sprites in layout (default: none)\n") + << tr(" stable: deterministic sort by size then path; is\n") + << tr(" area (default), maxside, height, width, or perimeter\n") << tr(" --threads N Number of worker threads\n") << tr(" --debug Enable detailed error reporting and debug visualization\n") + << tr(" --stdin-list Read image paths from stdin (one per line) instead of \n") + << tr(" Directory inputs honor .spratlayoutignore; list files may use exclude \"path\"\n") << tr(" --help, -h Show this help message\n") << tr(" --version, -v Show version\n"); } @@ -1407,12 +1537,32 @@ bool is_file_older_than_seconds(const fs::path& path, long long max_age_seconds) } fs::file_time_type now = fs::file_time_type::clock::now(); if (file_time > now) { - return false; + return true; // future mtime (clock skew); treat as stale } long long age = std::chrono::duration_cast(now - file_time).count(); return age > max_age_seconds; } +// Builds a sorted-or-ordered list of "path|file_size|mtime" strings for all sources. +std::vector build_source_sig_parts(bool preserve_source_order, + const std::vector& sources) { + std::vector parts; + parts.reserve(sources.size()); + for (const auto& source : sources) { + std::string line; + line += source.path; + line += '|'; + line += std::to_string(source.meta.file_size); + line += '|'; + line += std::to_string(source.meta.mtime_ticks); + parts.push_back(std::move(line)); + } + if (!preserve_source_order) { + std::ranges::sort(parts); + } + return parts; +} + std::string build_layout_signature(const std::string& profile_name, Mode mode, OptimizeTarget optimize_target, @@ -1420,42 +1570,46 @@ std::string build_layout_signature(const std::string& profile_name, int max_height_limit, int padding, int extrude, - int max_combinations, double scale, bool trim_transparent, bool allow_rotate, bool preserve_source_order, const std::string& deduplicateMode, const std::vector& sources) { - std::vector parts; - parts.reserve(sources.size()); - for (const auto& source : sources) { - std::ostringstream line; - line << source.path << "|" << source.meta.file_size << "|" << source.meta.mtime_ticks; - parts.push_back(line.str()); - } - if (!preserve_source_order) { - std::ranges::sort(parts); - } - - std::ostringstream sig; - sig << profile_name << "|" - << static_cast(mode) << "|" - << static_cast(optimize_target) << "|" - << max_width_limit << "|" - << max_height_limit << "|" - << padding << "|" - << extrude << "|" - << max_combinations << "|" - << std::setprecision(k_floating_point_precision) << scale << "|" - << (trim_transparent ? 1 : 0) << "|" - << (allow_rotate ? 1 : 0) << "|" - << (preserve_source_order ? 1 : 0) << "|" - << deduplicateMode; + const std::vector parts = build_source_sig_parts(preserve_source_order, sources); + + std::array scale_buf{}; + std::snprintf(scale_buf.data(), scale_buf.size(), "%.*g", k_floating_point_precision, scale); + + std::string sig; + sig += profile_name; + sig += '|'; + sig += std::to_string(static_cast(mode)); + sig += '|'; + sig += std::to_string(static_cast(optimize_target)); + sig += '|'; + sig += std::to_string(max_width_limit); + sig += '|'; + sig += std::to_string(max_height_limit); + sig += '|'; + sig += std::to_string(padding); + sig += '|'; + sig += std::to_string(extrude); + sig += '|'; + sig += scale_buf.data(); + sig += '|'; + sig += (trim_transparent ? '1' : '0'); + sig += '|'; + sig += (allow_rotate ? '1' : '0'); + sig += '|'; + sig += (preserve_source_order ? '1' : '0'); + sig += '|'; + sig += deduplicateMode; for (const std::string& part : parts) { - sig << "\n" << part; + sig += '\n'; + sig += part; } - return to_hex_size_t(std::hash{}(sig.str())); + return to_hex_size_t(std::hash{}(sig)); } std::string build_layout_seed_signature(const std::string& profile_name, @@ -1464,39 +1618,41 @@ std::string build_layout_seed_signature(const std::string& profile_name, int max_width_limit, int max_height_limit, int extrude, - int max_combinations, double scale, bool trim_transparent, bool allow_rotate, bool preserve_source_order, const std::vector& sources) { - std::vector parts; - parts.reserve(sources.size()); - for (const auto& source : sources) { - std::ostringstream line; - line << source.path << "|" << source.meta.file_size << "|" << source.meta.mtime_ticks; - parts.push_back(line.str()); - } - if (!preserve_source_order) { - std::ranges::sort(parts); - } - - std::ostringstream sig; - sig << profile_name << "|" - << static_cast(mode) << "|" - << static_cast(optimize_target) << "|" - << max_width_limit << "|" - << max_height_limit << "|" - << extrude << "|" - << max_combinations << "|" - << std::setprecision(k_floating_point_precision) << scale << "|" - << (trim_transparent ? 1 : 0) << "|" - << (allow_rotate ? 1 : 0) << "|" - << (preserve_source_order ? 1 : 0); + const std::vector parts = build_source_sig_parts(preserve_source_order, sources); + + std::array scale_buf{}; + std::snprintf(scale_buf.data(), scale_buf.size(), "%.*g", k_floating_point_precision, scale); + + std::string sig; + sig += profile_name; + sig += '|'; + sig += std::to_string(static_cast(mode)); + sig += '|'; + sig += std::to_string(static_cast(optimize_target)); + sig += '|'; + sig += std::to_string(max_width_limit); + sig += '|'; + sig += std::to_string(max_height_limit); + sig += '|'; + sig += std::to_string(extrude); + sig += '|'; + sig += scale_buf.data(); + sig += '|'; + sig += (trim_transparent ? '1' : '0'); + sig += '|'; + sig += (allow_rotate ? '1' : '0'); + sig += '|'; + sig += (preserve_source_order ? '1' : '0'); for (const std::string& part : parts) { - sig << "\n" << part; + sig += '\n'; + sig += part; } - return to_hex_size_t(std::hash{}(sig.str())); + return to_hex_size_t(std::hash{}(sig)); } bool load_output_cache(const fs::path& cache_path, @@ -1706,7 +1862,7 @@ void prune_cache_family_group(const fs::path& base_cache_path, continue; } - if (name.size() >= 4 && name.substr(name.size() - 4) == ".tmp") { + if (name.ends_with(".tmp")) { fs::remove(entry.path(), ec); ec.clear(); continue; @@ -1950,7 +2106,8 @@ std::string build_layout_output_text(const std::vector& atlases, bool multipack, const std::vector& sprites, const std::vector>& aliases, - bool debug) { + bool debug, + const fs::path& root) { std::ostringstream output; if (debug) { output << "# Sprat Layout Debug Info\n"; @@ -1960,6 +2117,7 @@ std::string build_layout_output_text(const std::vector& atlases, output << "# Aliases: " << aliases.size() << "\n"; output << "# Multipack: " << (multipack ? "true" : "false") << "\n"; } + output << "root " << to_quoted(root.string()) << "\n"; output << "scale " << std::setprecision(k_output_precision) << scale << "\n"; if (extrude > 0) { output << "extrude " << extrude << "\n"; @@ -1967,13 +2125,21 @@ std::string build_layout_output_text(const std::vector& atlases, if (multipack) { output << "multipack true\n"; } + // Pre-group sprite indices by atlas_index to avoid O(sprites * atlases) scan. + std::vector> sprites_by_atlas(atlases.size()); + for (size_t si = 0; si < sprites.size(); ++si) { + int ai = sprites[si].atlas_index; + if (ai >= 0 && static_cast(ai) < atlases.size()) { + sprites_by_atlas[static_cast(ai)].push_back(si); + } + } for (size_t i = 0; i < atlases.size(); ++i) { output << "atlas " << atlases[i].width << "," << atlases[i].height << "\n"; - for (const auto& s : sprites) { - if (s.atlas_index != static_cast(i)) { - continue; - } - std::string path = s.path; + for (size_t si : sprites_by_atlas[i]) { + const auto& s = sprites[si]; + fs::path sprite_path(s.path); + fs::path relative_path = fs::relative(sprite_path, root); + std::string path = relative_path.string(); // Standardize path separators to forward slashes for output consistency std::replace(path.begin(), path.end(), '\\', '/'); output << "sprite " << to_quoted(path) << " " @@ -1990,9 +2156,17 @@ std::string build_layout_output_text(const std::vector& atlases, } } for (const auto& alias_pair : aliases) { - const auto& alias_path = alias_pair.first; - const auto& canonical_path = alias_pair.second; - output << "alias " << to_quoted(alias_path) << " " << to_quoted(canonical_path) << "\n"; + fs::path alias_fs_path(alias_pair.first); + fs::path relative_alias_path = fs::relative(alias_fs_path, root); + std::string alias_str = relative_alias_path.string(); + std::replace(alias_str.begin(), alias_str.end(), '\\', '/'); + + fs::path canonical_fs_path(alias_pair.second); + fs::path relative_canonical_path = fs::relative(canonical_fs_path, root); + std::string canonical_str = relative_canonical_path.string(); + std::replace(canonical_str.begin(), canonical_str.end(), '\\', '/'); + + output << "alias " << to_quoted(alias_str) << " " << to_quoted(canonical_str) << "\n"; } return output.str(); } @@ -2095,9 +2269,17 @@ bool try_pack(std::unique_ptr& root, std::vector& sprites, int pad return true; } -enum class FrameSort : std::uint8_t { Name, None }; +enum class FrameSort : std::uint8_t { Name, None, Stable }; + +enum class StableMetric : std::uint8_t { + Area, + MaxSide, + Height, + Width, + Perimeter +}; -bool parse_frame_sort_from_string(const std::string& value, FrameSort& out) { +bool parse_frame_sort_from_string(const std::string& value, FrameSort& out, StableMetric& out_metric) { std::string lower = to_lower_copy(value); if (lower == "name") { out = FrameSort::Name; @@ -2107,6 +2289,16 @@ bool parse_frame_sort_from_string(const std::string& value, FrameSort& out) { out = FrameSort::None; return true; } + if (lower == "stable" || lower.starts_with("stable:")) { + out = FrameSort::Stable; + const std::string metric_str = lower.size() > 7 ? lower.substr(7) : "area"; + if (metric_str == "area") { out_metric = StableMetric::Area; return true; } + if (metric_str == "maxside") { out_metric = StableMetric::MaxSide; return true; } + if (metric_str == "height") { out_metric = StableMetric::Height; return true; } + if (metric_str == "width") { out_metric = StableMetric::Width; return true; } + if (metric_str == "perimeter") { out_metric = StableMetric::Perimeter; return true; } + return false; + } return false; } @@ -2180,6 +2372,60 @@ bool sort_sprites_by_mode(std::vector& sprites, SortMode mode) { return false; } +void sort_sprites_stable(std::vector& sprites, StableMetric metric) { + auto path_cmp = [](const Sprite& a, const Sprite& b) { + return sprat::core::compare_natural(a.path, b.path) < 0; + }; + switch (metric) { + case StableMetric::Area: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + const long long area_a = static_cast(a.w) * a.h; + const long long area_b = static_cast(b.w) * b.h; + if (area_a != area_b) { return area_a > area_b; } + if (a.h != b.h) { return a.h > b.h; } + if (a.w != b.w) { return a.w > b.w; } + return path_cmp(a, b); + }); + break; + case StableMetric::MaxSide: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + const int max_a = std::max(a.w, a.h); + const int max_b = std::max(b.w, b.h); + if (max_a != max_b) { return max_a > max_b; } + const long long area_a = static_cast(a.w) * a.h; + const long long area_b = static_cast(b.w) * b.h; + if (area_a != area_b) { return area_a > area_b; } + return path_cmp(a, b); + }); + break; + case StableMetric::Height: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + if (a.h != b.h) { return a.h > b.h; } + if (a.w != b.w) { return a.w > b.w; } + return path_cmp(a, b); + }); + break; + case StableMetric::Width: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + if (a.w != b.w) { return a.w > b.w; } + if (a.h != b.h) { return a.h > b.h; } + return path_cmp(a, b); + }); + break; + case StableMetric::Perimeter: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + const int p_a = a.w + a.h; + const int p_b = b.w + b.h; + if (p_a != p_b) { return p_a > p_b; } + const long long area_a = static_cast(a.w) * a.h; + const long long area_b = static_cast(b.w) * b.h; + if (area_a != area_b) { return area_a > area_b; } + return path_cmp(a, b); + }); + break; + } +} + struct Rect { int x = 0; int y = 0; @@ -2198,9 +2444,33 @@ bool rect_contains(const Rect& a, const Rect& b) { b.y + b.h <= a.y + a.h; } +void append_pruned_free_rect(std::vector& out, const Rect& candidate) { + if (candidate.w <= 0 || candidate.h <= 0) { + return; + } + + for (const Rect& existing : out) { + if (rect_contains(existing, candidate)) { + return; + } + } + + size_t write = 0; + for (size_t i = 0; i < out.size(); ++i) { + if (!rect_contains(candidate, out[i])) { + if (write != i) { + out[write] = out[i]; + } + ++write; + } + } + out.resize(write); + out.push_back(candidate); +} + bool split_free_rect(const Rect& free_rect, const Rect& used_rect, std::vector& out) { if (!rects_intersect(free_rect, used_rect)) { - out.push_back(free_rect); + append_pruned_free_rect(out, free_rect); return true; } @@ -2210,51 +2480,28 @@ bool split_free_rect(const Rect& free_rect, const Rect& used_rect, std::vector free_rect.x) { - out.push_back({free_rect.x, free_rect.y, used_rect.x - free_rect.x, free_rect.h}); + append_pruned_free_rect(out, {free_rect.x, free_rect.y, used_rect.x - free_rect.x, free_rect.h}); } if (used_right < free_right) { - out.push_back({used_right, free_rect.y, free_right - used_right, free_rect.h}); + append_pruned_free_rect(out, {used_right, free_rect.y, free_right - used_right, free_rect.h}); } if (used_rect.y > free_rect.y) { int x0 = std::max(free_rect.x, used_rect.x); int x1 = std::min(free_right, used_right); if (x1 > x0) { - out.push_back({x0, free_rect.y, x1 - x0, used_rect.y - free_rect.y}); + append_pruned_free_rect(out, {x0, free_rect.y, x1 - x0, used_rect.y - free_rect.y}); } } if (used_bottom < free_bottom) { int x0 = std::max(free_rect.x, used_rect.x); int x1 = std::min(free_right, used_right); if (x1 > x0) { - out.push_back({x0, used_bottom, x1 - x0, free_bottom - used_bottom}); + append_pruned_free_rect(out, {x0, used_bottom, x1 - x0, free_bottom - used_bottom}); } } return true; } -void prune_free_rects(std::vector& free_rects) { - size_t i = 0; - while (i < free_rects.size()) { - bool removed_i = false; - size_t j = i + 1; - while (j < free_rects.size()) { - if (rect_contains(free_rects[i], free_rects[j])) { - free_rects.erase(free_rects.begin() + static_cast(j)); - continue; - } - if (rect_contains(free_rects[j], free_rects[i])) { - free_rects.erase(free_rects.begin() + static_cast(i)); - removed_i = true; - break; - } - ++j; - } - if (!removed_i) { - ++i; - } - } -} - bool pack_compact_maxrects( std::vector& sprites, int width_limit, @@ -2274,6 +2521,7 @@ bool pack_compact_maxrects( int used_w = 0; int used_h = 0; + std::vector next_free; for (auto& s : sprites) { int rw = 0; @@ -2368,22 +2616,14 @@ bool pack_compact_maxrects( used_w = std::max(used.x + used.w, used_w); used_h = std::max(used.y + used.h, used_h); - std::vector next_free; - next_free.reserve(free_rects.size() * 2); + next_free.clear(); for (const auto& fr : free_rects) { if (!split_free_rect(fr, used, next_free)) { return false; } } - free_rects.clear(); - free_rects.reserve(next_free.size()); - for (const auto& r : next_free) { - if (r.w > 0 && r.h > 0) { - free_rects.push_back(r); - } - } - prune_free_rects(free_rects); + std::swap(free_rects, next_free); } out_width = used_w; @@ -2415,6 +2655,7 @@ bool pack_compact_maxrects_partial( std::vector free_rects; free_rects.push_back({0, 0, width_limit, max_height}); + std::vector next_free; for (const auto& src : sprites) { Sprite s = src; @@ -2516,21 +2757,13 @@ bool pack_compact_maxrects_partial( } out.packed_area += sprite_area; - std::vector next_free; - next_free.reserve(free_rects.size() * 2); + next_free.clear(); for (const auto& fr : free_rects) { if (!split_free_rect(fr, used, next_free)) { return false; } } - free_rects.clear(); - free_rects.reserve(next_free.size()); - for (const auto& r : next_free) { - if (r.w > 0 && r.h > 0) { - free_rects.push_back(r); - } - } - prune_free_rects(free_rects); + std::swap(free_rects, next_free); out.packed.push_back(s); } @@ -2628,6 +2861,94 @@ bool pack_fast_shelf( return out_width > 0 && out_height > 0; } +// Pack sprites into a uniform grid where every cell is max_sprite_width x max_sprite_height. +// Sprites are placed left-to-right, top-to-bottom. The number of columns is chosen to make +// the atlas as square as possible, subject to width_limit / height_limit (0 = no limit). +bool pack_grid( + std::vector& sprites, + int padding, + int width_limit, + int height_limit, + int& out_width, + int& out_height +) { + if (sprites.empty()) { + return false; + } + + const int ref_w = sprites[0].w; + const int ref_h = sprites[0].h; + for (const auto& s : sprites) { + if (s.w != ref_w || s.h != ref_h) { + std::cerr << tr("Error: grid mode requires all sprites to be the same size; ") + << s.path << " is " << s.w << "x" << s.h + << tr(", expected ") << ref_w << "x" << ref_h << '\n'; + return false; + } + } + + int cell_w = 0; + int cell_h = 0; + if (!checked_add_int(ref_w, padding, cell_w) || !checked_add_int(ref_h, padding, cell_h)) { + return false; + } + if (cell_w <= 0 || cell_h <= 0) { + return false; + } + if ((width_limit > 0 && cell_w > width_limit) || (height_limit > 0 && cell_h > height_limit)) { + return false; + } + + int n = static_cast(sprites.size()); + int max_cols = (width_limit > 0) ? (width_limit / cell_w) : n; + if (max_cols <= 0) { + return false; + } + + int cols = static_cast(std::ceil(std::sqrt(static_cast(n)))); + if (cols <= 0) { + cols = 1; + } + cols = std::min(cols, max_cols); + + // Widen if height limit is exceeded + if (height_limit > 0) { + while (cols < max_cols) { + int rows_needed = (n + cols - 1) / cols; + if (static_cast(rows_needed) * cell_h <= static_cast(height_limit)) { + break; + } + ++cols; + } + int rows_needed = (n + cols - 1) / cols; + if (static_cast(rows_needed) * cell_h > static_cast(height_limit)) { + return false; + } + } + + for (int i = 0; i < n; ++i) { + int col = i % cols; + int row = i / cols; + long long x_ll = static_cast(col) * cell_w; + long long y_ll = static_cast(row) * cell_h; + if (x_ll > std::numeric_limits::max() || y_ll > std::numeric_limits::max()) { + return false; + } + sprites[i].x = static_cast(x_ll); + sprites[i].y = static_cast(y_ll); + } + + int rows = (n + cols - 1) / cols; + long long total_w_ll = static_cast(cols) * cell_w; + long long total_h_ll = static_cast(rows) * cell_h; + if (total_w_ll > std::numeric_limits::max() || total_h_ll > std::numeric_limits::max()) { + return false; + } + out_width = static_cast(total_w_ll); + out_height = static_cast(total_h_ll); + return true; +} + bool compute_tight_atlas_bounds(const std::vector& sprites, int& out_width, int& out_height) { out_width = 0; out_height = 0; @@ -2722,6 +3043,59 @@ bool pack_atlases( return true; } + // Grid mode: uniform-cell layout, split into equal-capacity atlases when needed. + if (mode == Mode::GRID) { + const int ref_w = sprites[0].w; + const int ref_h = sprites[0].h; + for (const auto& s : sprites) { + if (s.w != ref_w || s.h != ref_h) { + std::cerr << tr("Error: grid mode requires all sprites to be the same size; ") + << s.path << " is " << s.w << "x" << s.h + << tr(", expected ") << ref_w << "x" << ref_h << '\n'; + return false; + } + } + int cell_w = 0; + int cell_h = 0; + if (!checked_add_int(ref_w, padding, cell_w) || !checked_add_int(ref_h, padding, cell_h)) { + return false; + } + if (cell_w <= 0 || cell_h <= 0 || cell_w > max_w || cell_h > max_h) { + return false; + } + int cols = max_w / cell_w; + int rows_per_atlas = max_h / cell_h; + if (cols <= 0 || rows_per_atlas <= 0) { + return false; + } + int capacity = cols * rows_per_atlas; + int n = static_cast(sprites.size()); + int atlas_idx = 0; + for (int base = 0; base < n; base += capacity, ++atlas_idx) { + int count = std::min(capacity, n - base); + int atlas_rows = (count + cols - 1) / cols; + long long aw = static_cast(cols) * cell_w; + long long ah = static_cast(atlas_rows) * cell_h; + if (aw > std::numeric_limits::max() || ah > std::numeric_limits::max()) { + return false; + } + out_atlases.push_back({static_cast(aw), static_cast(ah)}); + for (int i = 0; i < count; ++i) { + int col = i % cols; + int row = i / cols; + long long x_ll = static_cast(col) * cell_w; + long long y_ll = static_cast(row) * cell_h; + if (x_ll > std::numeric_limits::max() || y_ll > std::numeric_limits::max()) { + return false; + } + sprites[base + i].x = static_cast(x_ll); + sprites[base + i].y = static_cast(y_ll); + sprites[base + i].atlas_index = atlas_idx; + } + } + return true; + } + std::vector remaining = sprites; std::vector all_packed; int atlas_index = 0; @@ -2817,6 +3191,9 @@ bool pack_atlases( if (candidate.packed_count != best.packed_count) { return candidate.packed_count > best.packed_count; } + if (candidate.packed_area != best.packed_area) { + return candidate.packed_area > best.packed_area; + } return pick_better_layout_candidate( candidate.area, candidate.used_w, candidate.used_h, true, best.area, best.used_w, best.used_h, @@ -2901,44 +3278,77 @@ bool pack_atlases( return true; } -int run_spratlayout(int argc, char** argv) { -#ifdef _WIN32 - if (_setmode(_fileno(stdin), _O_BINARY) == -1) { - std::cerr << tr("Failed to set stdin to binary mode\n"); - } - if (_setmode(_fileno(stdout), _O_BINARY) == -1) { - std::cerr << tr("Failed to set stdout to binary mode\n"); - } -#endif - bool debug = env_flag_enabled("SPRAT_DEBUG"); +// Maximum allowed Hamming distance between two dHashes to consider sprites perceptually equal. +static constexpr int k_dhash_threshold = 5; + +// Compute a 64-bit dHash for an RGBA pixel buffer. +// Algorithm: sample a 9x8 greyscale grid (nearest-neighbor), then for each of the 8 rows +// compare 8 adjacent column pairs; bit=1 if left < right. Returns 64-bit hash. +// Alpha-premultiplied luma: grey = (0.299*R + 0.587*G + 0.114*B) * (A/255.0) +static uint64_t compute_dhash(const unsigned char* rgba, int w, int h) { + if (rgba == nullptr || w <= 0 || h <= 0) { + return 0; + } + // Sample a 9x8 grid (9 cols, 8 rows) + double grid[8][9]; + for (int row = 0; row < 8; ++row) { + for (int col = 0; col < 9; ++col) { + int px = (col * (w - 1)) / 8; + int py = (row * (h - 1)) / 7; + if (px < 0) px = 0; + if (px >= w) px = w - 1; + if (py < 0) py = 0; + if (py >= h) py = h - 1; + const unsigned char* p = rgba + (static_cast(py) * static_cast(w) + static_cast(px)) * 4; + double a = p[3] / 255.0; + grid[row][col] = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * a; + } + } + uint64_t hash = 0; + int bit = 0; + for (int row = 0; row < 8; ++row) { + for (int col = 0; col < 8; ++col) { + if (grid[row][col] < grid[row][col + 1]) { + hash |= (uint64_t(1) << bit); + } + ++bit; + } + } + return hash; +} + +// All settings derived from parsing argv. Fields that express an override +// (has_*_override == true) indicate that the corresponding value was +// explicitly supplied on the command line; profile loading will only fill in +// fields whose override flag is false. +struct LayoutArgs { + bool debug = false; fs::path folder; std::string requested_profile_name; std::string profiles_config_path; + // raw override state (preset is resolved into has_mode/optimize_override + // before try_parse_args returns) bool has_mode_override = false; - Mode mode_override = Mode::COMPACT; + Mode mode_override = k_default_mode; bool has_optimize_override = false; - OptimizeTarget optimize_override = OptimizeTarget::GPU; - Mode mode = Mode::COMPACT; - OptimizeTarget optimize_target = OptimizeTarget::GPU; + OptimizeTarget optimize_override = k_default_optimize_target; int max_width_limit = 0; - int max_height_limit = 0; bool has_max_width_limit = false; + int max_height_limit = 0; bool has_max_height_limit = false; int padding = 0; bool has_padding_override = false; int extrude = 0; bool has_extrude_override = false; - int max_combinations = 0; - bool has_max_combinations_override = false; int source_resolution_width = 0; int source_resolution_height = 0; + bool has_source_resolution = false; int target_resolution_width = 0; int target_resolution_height = 0; - bool has_source_resolution = false; bool has_target_resolution = false; ResolutionReference resolution_reference = ResolutionReference::Largest; bool has_resolution_reference_override = false; - double scale = 1.0; + double scale = 0.0; bool has_scale_override = false; bool trim_transparent = false; bool has_trim_override = false; @@ -2949,13 +3359,23 @@ int run_spratlayout(int argc, char** argv) { std::string deduplicateMode = "none"; bool has_deduplicate_override = false; FrameSort frame_sort = FrameSort::Name; + StableMetric stable_metric = StableMetric::Area; bool has_frame_sort_override = false; unsigned int thread_limit = 0; bool has_threads_override = false; + bool show_profiles_config = false; + bool stdin_list = false; +}; + +// Parses argv into args. Returns -1 to signal the caller should continue, or +// 0/1 as an exit code for --help/--version/error cases. +int try_parse_args(int argc, char** argv, LayoutArgs& args) { + args.debug = env_flag_enabled("SPRAT_DEBUG"); + bool has_preset = false; + PresetDefinition preset_definition{Mode::FAST, OptimizeTarget::GPU}; - // parse args for (int i = 1; i < argc; ++i) { - std::string arg = argv[i]; + const std::string arg = argv[i]; if (arg == "--help" || arg == "-h") { print_usage(); return 0; @@ -2963,146 +3383,144 @@ int run_spratlayout(int argc, char** argv) { std::cout << tr("spratlayout version ") << SPRAT_VERSION << "\n"; return 0; } else if (arg == "--debug") { - debug = true; + args.debug = true; } else if (arg == "--profile" && i + 1 < argc) { - requested_profile_name = argv[++i]; + args.requested_profile_name = argv[++i]; + } else if (arg == "--preset" && i + 1 < argc) { + const std::string value = argv[++i]; + if (!parse_preset_from_string(value, preset_definition)) { + std::cerr << tr("Invalid preset: ") << value + << tr(". Valid presets: fast, quality, small, pot\n"); + return 1; + } + has_preset = true; } else if (arg == "--profiles-config" && i + 1 < argc) { - profiles_config_path = argv[++i]; + args.profiles_config_path = argv[++i]; + } else if (arg == "--default-profiles-config") { + args.show_profiles_config = true; } else if (arg == "--mode" && i + 1 < argc) { - std::string value = argv[++i]; + const std::string value = argv[++i]; std::string error; - if (!parse_mode_from_string(value, mode_override, error)) { + if (!parse_mode_from_string(value, args.mode_override, error)) { std::cerr << tr("Invalid mode value: ") << value << "\n"; return 1; } - has_mode_override = true; + args.has_mode_override = true; } else if (arg == "--optimize" && i + 1 < argc) { - std::string value = argv[++i]; + const std::string value = argv[++i]; std::string error; - if (!parse_optimize_target_from_string(value, optimize_override, error)) { + if (!parse_optimize_target_from_string(value, args.optimize_override, error)) { std::cerr << tr("Invalid optimize value: ") << value << "\n"; return 1; } - has_optimize_override = true; + args.has_optimize_override = true; } else if (arg == "--max-width" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_positive_int(value, max_width_limit)) { + const std::string value = argv[++i]; + if (!parse_positive_int(value, args.max_width_limit)) { std::cerr << tr("Invalid max width value: ") << value << "\n"; return 1; } - has_max_width_limit = true; + args.has_max_width_limit = true; } else if (arg == "--max-height" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_positive_int(value, max_height_limit)) { + const std::string value = argv[++i]; + if (!parse_positive_int(value, args.max_height_limit)) { std::cerr << tr("Invalid max height value: ") << value << "\n"; return 1; } - has_max_height_limit = true; + args.has_max_height_limit = true; } else if (arg == "--no-max-width") { - max_width_limit = 0; - has_max_width_limit = true; + args.max_width_limit = 0; + args.has_max_width_limit = true; } else if (arg == "--no-max-height") { - max_height_limit = 0; - has_max_height_limit = true; + args.max_height_limit = 0; + args.has_max_height_limit = true; } else if (arg == "--padding" && i + 1 < argc) { - std::string value = argv[++i]; - try { - size_t idx = 0; - padding = std::stoi(value, &idx); - if (idx != value.size()) { - std::cerr << tr("Invalid padding value: ") << value << "\n"; - return 1; - } - } catch (const std::exception&) { + const std::string value = argv[++i]; + if (!parse_non_negative_int(value, args.padding)) { std::cerr << tr("Invalid padding value: ") << value << "\n"; return 1; } - padding = std::max(padding, 0); - has_padding_override = true; + args.has_padding_override = true; } else if (arg == "--extrude" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_non_negative_int(value, extrude)) { + const std::string value = argv[++i]; + if (!parse_non_negative_int(value, args.extrude)) { std::cerr << tr("Invalid extrude value: ") << value << "\n"; return 1; } - has_extrude_override = true; - } else if (arg == "--max-combinations" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_non_negative_int(value, max_combinations)) { - std::cerr << tr("Invalid max combinations value: ") << value << "\n"; - return 1; - } - has_max_combinations_override = true; + args.has_extrude_override = true; } else if (arg == "--source-resolution" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_resolution(value, source_resolution_width, source_resolution_height)) { + const std::string value = argv[++i]; + if (!parse_resolution(value, args.source_resolution_width, args.source_resolution_height)) { std::cerr << tr("Invalid source resolution value: ") << value << "\n"; return 1; } - has_source_resolution = true; + args.has_source_resolution = true; } else if (arg == "--target-resolution" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_resolution(value, target_resolution_width, target_resolution_height)) { + const std::string value = argv[++i]; + if (!parse_resolution(value, args.target_resolution_width, args.target_resolution_height)) { std::cerr << tr("Invalid target resolution value: ") << value << "\n"; return 1; } - has_target_resolution = true; + args.has_target_resolution = true; } else if (arg == "--resolution-reference" && i + 1 < argc) { - if (has_resolution_reference_override) { + if (args.has_resolution_reference_override) { std::cerr << tr("Error: --resolution-reference can only be provided once\n"); return 1; } - std::string value = argv[++i]; + const std::string value = argv[++i]; std::string error; - if (!parse_resolution_reference_from_string(value, resolution_reference, error)) { + if (!parse_resolution_reference_from_string(value, args.resolution_reference, error)) { std::cerr << tr("Invalid resolution reference value: ") << value << "\n"; return 1; } - has_resolution_reference_override = true; + args.has_resolution_reference_override = true; } else if (arg == "--scale" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_scale_factor(value, scale)) { + const std::string value = argv[++i]; + if (!parse_scale_factor(value, args.scale)) { std::cerr << tr("Invalid scale value: ") << value << "\n"; return 1; } - has_scale_override = true; + args.has_scale_override = true; } else if (arg == "--trim-transparent") { - trim_transparent = true; - has_trim_override = true; + args.trim_transparent = true; + args.has_trim_override = true; } else if (arg == "--rotate") { - allow_rotate = true; - has_rotate_override = true; + args.allow_rotate = true; + args.has_rotate_override = true; } else if (arg == "--multipack") { - multipack = true; - has_multipack_override = true; + args.multipack = true; + args.has_multipack_override = true; } else if (arg == "--deduplicate" && i + 1 < argc) { - deduplicateMode = argv[++i]; - if (deduplicateMode != "none" && deduplicateMode != "exact" && deduplicateMode != "perceptual") { - std::cerr << tr("Invalid deduplication mode: ") << deduplicateMode << "\n"; + args.deduplicateMode = argv[++i]; + if (args.deduplicateMode != "none" && args.deduplicateMode != "exact" + && args.deduplicateMode != "perceptual") { + std::cerr << tr("Invalid deduplication mode: ") << args.deduplicateMode << "\n"; std::cerr << tr("Valid modes: none, exact, perceptual\n"); return 1; } - has_deduplicate_override = true; + args.has_deduplicate_override = true; } else if (arg == "--sort" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_frame_sort_from_string(value, frame_sort)) { + const std::string value = argv[++i]; + if (!parse_frame_sort_from_string(value, args.frame_sort, args.stable_metric)) { std::cerr << tr("Invalid sort value: ") << value << "\n"; return 1; } - has_frame_sort_override = true; - } else if (arg == "--threads") { - std::string value = argv[++i]; - if (!parse_positive_uint(value, thread_limit)) { + args.has_frame_sort_override = true; + } else if (arg == "--threads" && i + 1 < argc) { + const std::string value = argv[++i]; + if (!parse_positive_uint(value, args.thread_limit)) { std::cerr << tr("Invalid thread count: ") << value << "\n"; return 1; } - has_threads_override = true; + args.has_threads_override = true; + } else if (arg == "--stdin-list") { + args.stdin_list = true; } else if (arg.starts_with("-")) { std::cerr << tr("Unknown argument: ") << arg << "\n"; return 1; } else { - if (folder.empty()) { - folder = arg; + if (args.folder.empty()) { + args.folder = arg; } else { std::cerr << tr("Error: Too many arguments: ") << arg << "\n"; return 1; @@ -3110,11 +3528,104 @@ int run_spratlayout(int argc, char** argv) { } } - if (folder.empty()) { + // Apply --preset: fold into the override flags if not already set + // explicitly by --mode / --optimize. + if (has_preset) { + if (!args.has_mode_override) { + args.mode_override = preset_definition.mode; + args.has_mode_override = true; + } + if (!args.has_optimize_override) { + args.optimize_override = preset_definition.optimize_target; + args.has_optimize_override = true; + } + } + + return -1; // continue +} + +int run_spratlayout(int argc, char** argv) { +#ifdef _WIN32 + // Set stdout to binary mode to avoid \r\n translation in layout output. + // Suppress failure: when running as a subprocess of a GUI application the + // pipe handle may not support _setmode; this is non-fatal. + _setmode(_fileno(stdout), _O_BINARY); +#endif + LayoutArgs args; + const int early_exit = try_parse_args(argc, argv, args); + if (early_exit >= 0) { + return early_exit; + } +#ifdef _WIN32 + // Set stdin to binary mode only when --stdin-list is active so path data + // read from stdin is not corrupted by \r\n translation. + if (args.stdin_list) { + _setmode(_fileno(stdin), _O_BINARY); + } +#endif + + if (args.show_profiles_config) { + const fs::path exec_dir_local = sprat::core::get_executable_dir(argv[0]); + const auto candidates = build_default_profiles_config_candidates(exec_dir_local); + for (const fs::path& candidate : candidates) { + std::error_code ec; + if (fs::exists(candidate, ec) && !ec) { + std::cout << candidate.string() << "\n"; + return 0; + } + } + std::cout << k_global_profiles_config_path << "\n"; + return 0; + } + + if (args.folder.empty() && !args.stdin_list) { print_usage(); return 1; } + // Unpack parsed args into named locals so the rest of the function is + // unchanged. Fields with has_*_override flags are resolved to their + // effective values here; profile loading may still override them below. + bool debug = args.debug; + fs::path folder = std::move(args.folder); + const std::string requested_profile_name = std::move(args.requested_profile_name); + const std::string profiles_config_path = std::move(args.profiles_config_path); + const bool has_mode_override = args.has_mode_override; + const bool has_optimize_override = args.has_optimize_override; + Mode mode = args.has_mode_override ? args.mode_override : k_default_mode; + OptimizeTarget optimize_target = args.has_optimize_override ? args.optimize_override : k_default_optimize_target; + int max_width_limit = args.max_width_limit; + int max_height_limit = args.max_height_limit; + bool has_max_width_limit = args.has_max_width_limit; + bool has_max_height_limit = args.has_max_height_limit; + int padding = args.has_padding_override ? args.padding : k_default_padding; + bool has_padding_override = args.has_padding_override; + int extrude = args.has_extrude_override ? args.extrude : k_default_extrude; + bool has_extrude_override = args.has_extrude_override; + int source_resolution_width = args.source_resolution_width; + int source_resolution_height = args.source_resolution_height; + int target_resolution_width = args.target_resolution_width; + int target_resolution_height = args.target_resolution_height; + bool has_source_resolution = args.has_source_resolution; + bool has_target_resolution = args.has_target_resolution; + ResolutionReference resolution_reference = args.resolution_reference; + bool has_resolution_reference_override = args.has_resolution_reference_override; + double scale = args.has_scale_override ? args.scale : k_default_scale; + bool has_scale_override = args.has_scale_override; + bool trim_transparent = args.has_trim_override ? args.trim_transparent : k_default_trim_transparent; + bool has_trim_override = args.has_trim_override; + bool allow_rotate = args.has_rotate_override ? args.allow_rotate : false; + bool has_rotate_override = args.has_rotate_override; + bool multipack = args.has_multipack_override ? args.multipack : false; + bool has_multipack_override = args.has_multipack_override; + const std::string deduplicateMode = std::move(args.deduplicateMode); + const FrameSort frame_sort = args.frame_sort; + const StableMetric stable_metric = args.stable_metric; + const bool has_frame_sort_override = args.has_frame_sort_override; + unsigned int thread_limit = args.has_threads_override ? args.thread_limit : k_default_threads; + bool has_threads_override = args.has_threads_override; + const bool stdin_list = args.stdin_list; + std::vector profile_definitions; std::unordered_map profile_map; std::string selected_profile_name = k_default_profile_name; @@ -3123,35 +3634,6 @@ int run_spratlayout(int argc, char** argv) { selected_profile_name = requested_profile_name; } - if (!has_mode_override) { - mode = k_default_mode; - } else { - mode = mode_override; - } - if (!has_optimize_override) { - optimize_target = k_default_optimize_target; - } else { - optimize_target = optimize_override; - } - if (!has_padding_override) { - padding = k_default_padding; - } - if (!has_extrude_override) { - extrude = k_default_extrude; - } - if (!has_max_combinations_override) { - max_combinations = k_default_max_combinations; - } - if (!has_scale_override) { - scale = k_default_scale; - } - if (!has_trim_override) { - trim_transparent = k_default_trim_transparent; - } - if (!has_threads_override) { - thread_limit = k_default_threads; - } - fs::path cwd = fs::current_path(); fs::path exec_dir = sprat::core::get_executable_dir(argv[0]); @@ -3164,7 +3646,7 @@ int run_spratlayout(int argc, char** argv) { } config_candidates.push_back(std::move(config_candidate)); } else { - config_candidates = build_default_profiles_config_candidates(cwd, exec_dir); + config_candidates = build_default_profiles_config_candidates(exec_dir); } bool loaded_profile_file = false; @@ -3255,9 +3737,6 @@ int run_spratlayout(int argc, char** argv) { if (!has_extrude_override && selected_profile.extrude) { extrude = *selected_profile.extrude; } - if (!has_max_combinations_override && selected_profile.max_combinations) { - max_combinations = *selected_profile.max_combinations; - } if (!has_scale_override && selected_profile.scale) { scale = *selected_profile.scale; } @@ -3298,7 +3777,7 @@ int run_spratlayout(int argc, char** argv) { if (profile_debug) { std::cerr << "[profile-debug] applied_profile=" << selected_profile.name << "\n"; std::cerr << "[profile-debug] mode=" - << (mode == Mode::FAST ? "fast" : (mode == Mode::COMPACT ? "compact" : "pot")) + << (mode == Mode::FAST ? "fast" : (mode == Mode::COMPACT ? "compact" : (mode == Mode::GRID ? "grid" : "pot"))) << " optimize=" << (optimize_target == OptimizeTarget::GPU ? "gpu" : "space") << " padding=" << padding @@ -3332,9 +3811,16 @@ int run_spratlayout(int argc, char** argv) { } InputContext input_context; - - // Check if we should read from stdin (when folder is "-") - if (folder == "-") { + + if (stdin_list) { +#ifdef _WIN32 + _setmode(_fileno(stdin), _O_TEXT); +#endif + if (!load_list_from_stdin(input_context, cwd)) { + std::cerr << tr("Error: Failed to initialize stdin list mode\n"); + return 1; + } + } else if (folder == "-") { if (!load_content_from_stdin(input_context)) { std::cerr << tr("Error: Failed to load content from stdin\n"); return 1; @@ -3353,6 +3839,82 @@ int run_spratlayout(int argc, char** argv) { prune_cache_family(cache_path, k_cache_max_age_seconds, k_cache_max_layout_files, k_cache_max_seed_files); std::vector sources; + std::unordered_set excluded_source_paths; + auto add_excluded_source = [&](const fs::path& path, const std::string* relative_key = nullptr) { + excluded_source_paths.insert(normalize_path_key(path)); + if (relative_key != nullptr && !relative_key->empty()) { + excluded_source_paths.insert(*relative_key); + } + }; + auto is_excluded_source = [&](const fs::path& path, const fs::path* root) { + if (excluded_source_paths.contains(normalize_path_key(path))) { + return true; + } + if (root != nullptr) { + std::error_code ec; + fs::path relative = fs::relative(path, *root, ec); + if (!ec && excluded_source_paths.contains(relative.lexically_normal().string())) { + return true; + } + } + return false; + }; + auto load_exclusion_file = [&](const fs::path& file_path, const fs::path& base_root, bool strict) -> bool { + std::ifstream in(file_path); + if (!in) { + return !strict; + } + std::string line; + size_t line_number = 0; + while (std::getline(in, line)) { + ++line_number; + std::string trimmed = trim_copy(line); + if (trimmed.empty() || trimmed.front() == '#') { + continue; + } + + std::string path_text; + if (trimmed.size() >= 7 && trimmed.compare(0, 7, "exclude") == 0 && + (trimmed.size() == 7 || std::isspace(static_cast(trimmed[7])))) { + size_t pos = 7; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos])) != 0) { + ++pos; + } + if (pos < trimmed.size() && trimmed[pos] == '"') { + if (!parse_quoted_path_argument(trimmed, pos, path_text)) { + if (strict) { + std::cerr << tr("Invalid exclude path at line ") << line_number + << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + continue; + } + } else if (pos < trimmed.size()) { + path_text = trimmed.substr(pos); + } + } else { + path_text = trimmed; + } + + if (path_text.empty()) { + if (strict) { + std::cerr << tr("Invalid exclude path at line ") << line_number + << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + continue; + } + + fs::path excluded_path(path_text); + std::optional relative_key; + if (excluded_path.is_relative()) { + relative_key = excluded_path.lexically_normal().string(); + excluded_path = base_root / excluded_path; + } + add_excluded_source(excluded_path, relative_key ? &*relative_key : nullptr); + } + return true; + }; auto add_source = [&](const fs::path& image_path, bool strict) -> bool { if (!is_supported_image_extension(image_path)) { if (strict) { @@ -3361,6 +3923,11 @@ int run_spratlayout(int argc, char** argv) { } return true; } + const fs::path* exclusion_root = + input_context.type == InputType::Directory ? &input_context.working_folder : nullptr; + if (is_excluded_source(image_path, exclusion_root)) { + return true; + } ImageMeta meta; if (!read_image_meta(image_path, meta)) { if (strict) { @@ -3378,7 +3945,8 @@ int run_spratlayout(int argc, char** argv) { }; if (input_context.type == InputType::Directory) { - for (const auto& entry : fs::directory_iterator(input_context.working_folder)) { + load_exclusion_file(input_context.working_folder / ".spratlayoutignore", input_context.working_folder, false); + for (const auto& entry : fs::recursive_directory_iterator(input_context.working_folder)) { if (!entry.is_regular_file()) { continue; } @@ -3407,45 +3975,108 @@ int run_spratlayout(int argc, char** argv) { } } } else { - std::ifstream list_file(input_context.working_folder); - if (!list_file) { - std::cerr << tr("Failed to open list file: ") << input_context.working_folder << "\n"; - return 1; - } - std::string line; - size_t line_number = 0; - while (std::getline(list_file, line)) { - ++line_number; - std::string trimmed = trim_copy(line); - if (trimmed.empty() || trimmed.front() == '#') { - continue; + // Parse a list-format stream (used for both ListFile and StdinList). + // base_dir is used to resolve relative paths when no "root" directive is present. + auto parse_list_stream = [&](std::istream& stream, const fs::path& base_dir) -> bool { + std::string line; + size_t line_number = 0; + fs::path list_root; // optional root override from "root" directive + while (std::getline(stream, line)) { + ++line_number; + std::string trimmed = trim_copy(line); + if (trimmed.empty() || trimmed.front() == '#') { + continue; + } + if (trimmed.size() >= 7 && trimmed.compare(0, 7, "exclude") == 0 && + (trimmed.size() == 7 || std::isspace(static_cast(trimmed[7])))) { + size_t pos = 7; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos])) != 0) { + ++pos; + } + std::string excluded_path_text; + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + if (!sprat::core::parse_quoted(trimmed, pos, excluded_path_text, error)) { + std::cerr << tr("Invalid exclude path at line ") << line_number << tr(": ") << error << "\n"; + return false; + } + } else if (pos < trimmed.size()) { + excluded_path_text = trimmed.substr(pos); + } + if (excluded_path_text.empty()) { + std::cerr << tr("Invalid exclude path at line ") << line_number << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + fs::path excluded_path(excluded_path_text); + if (excluded_path.is_relative()) { + const fs::path& base = !list_root.empty() ? list_root : base_dir; + excluded_path = base / excluded_path; + } + add_excluded_source(excluded_path); + continue; + } + // "root " directive: sets the base directory for resolving relative paths + if (trimmed.size() >= 4 && trimmed.compare(0, 4, "root") == 0 && + (trimmed.size() == 4 || std::isspace(static_cast(trimmed[4])))) { + size_t pos = 4; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) ++pos; + std::string root_str; + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + sprat::core::parse_quoted(trimmed, pos, root_str, error); + } else if (pos < trimmed.size()) { + root_str = trimmed.substr(pos); + } + if (!root_str.empty()) { + fs::path rp(root_str); + if (rp.is_relative()) { + rp = base_dir / rp; + } + list_root = rp; + } + continue; + } + fs::path entry_path(trimmed); + if (entry_path.is_relative()) { + const fs::path& base = !list_root.empty() ? list_root : base_dir; + entry_path = base / entry_path; + } + if (!fs::exists(entry_path) || !fs::is_regular_file(entry_path)) { + std::cerr << tr("Invalid image path at line ") << line_number << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + if (!add_source(entry_path, true)) { + return false; + } } - fs::path entry_path(trimmed); - if (entry_path.is_relative()) { - entry_path = input_context.working_folder.parent_path() / entry_path; + return true; + }; + + if (input_context.type == InputType::StdinList) { + if (!parse_list_stream(std::cin, input_context.working_folder)) { + return 1; } - if (!fs::exists(entry_path) || !fs::is_regular_file(entry_path)) { - std::cerr << tr("Invalid image path at line ") << line_number << tr(": ") << to_quoted(trimmed) << "\n"; + } else { + std::ifstream list_file(input_context.working_folder); + if (!list_file) { + std::cerr << tr("Failed to open list file: ") << input_context.working_folder << "\n"; return 1; } - if (!add_source(entry_path, true)) { + if (!parse_list_stream(list_file, input_context.working_folder.parent_path())) { return 1; } } } - const bool is_stdin_or_list = - (input_context.type == InputType::ListFile || input_context.type == InputType::StdinTar); bool do_sort = false; if (has_frame_sort_override) { do_sort = (frame_sort == FrameSort::Name); - } else { - do_sort = !is_stdin_or_list; } - const bool enforce_name_order = (has_frame_sort_override && frame_sort == FrameSort::Name); + const bool enforce_name_order = (has_frame_sort_override && frame_sort == FrameSort::Name); + const bool enforce_stable_order = (has_frame_sort_override && frame_sort == FrameSort::Stable); - if (do_sort) { + if (do_sort || enforce_stable_order) { std::ranges::sort(sources, [](const ImageSource& lhs, const ImageSource& rhs) { int cmp = sprat::core::compare_natural(lhs.path, rhs.path); if (cmp != 0) { @@ -3463,13 +4094,13 @@ int run_spratlayout(int argc, char** argv) { return 1; } - const bool is_file = !do_sort; + const bool is_file = !do_sort && !enforce_stable_order; const std::string layout_signature = build_layout_signature( selected_profile_name, mode, optimize_target, max_width_limit, max_height_limit, - padding, extrude, max_combinations, scale, trim_transparent, allow_rotate, is_file, deduplicateMode, sources); + padding, extrude, scale, trim_transparent, allow_rotate, is_file, deduplicateMode, sources); const std::string layout_seed_signature = build_layout_seed_signature( selected_profile_name, mode, optimize_target, max_width_limit, max_height_limit, - extrude, max_combinations, scale, trim_transparent, allow_rotate, is_file, sources); + extrude, scale, trim_transparent, allow_rotate, is_file, sources); const fs::path output_cache_path = build_output_cache_path(cache_path, layout_signature); const fs::path seed_cache_path = build_seed_cache_path(cache_path, layout_seed_signature); if (!is_file_older_than_seconds(output_cache_path, k_cache_max_age_seconds)) { @@ -3484,45 +4115,99 @@ int run_spratlayout(int argc, char** argv) { load_image_cache(cache_path, cache_entries); prune_stale_cache_entries(cache_entries, now_unix, k_cache_max_age_seconds); - std::vector sprites; - for (const auto& source : sources) { + struct SpriteLoadResult { + bool ok = false; + bool from_cache = false; + bool failed = false; + std::string fail_reason; + Sprite sprite; + std::string cache_key; + ImageCacheEntry new_entry; + }; + + const size_t source_count = sources.size(); + std::vector load_results(source_count); + + auto process_source = [&](size_t i) { + const auto& source = sources[i]; const std::string& path = source.path; const ImageMeta& meta = source.meta; + SpriteLoadResult& result = load_results[i]; const std::string cache_key = path + (trim_transparent ? "|1" : "|0"); + result.cache_key = cache_key; + + // Step 4a: cache hit auto cache_it = cache_entries.find(cache_key); if (cache_it != cache_entries.end()) { const ImageCacheEntry& cached = cache_it->second; if (cached.trim_transparent == trim_transparent && cached.file_size == meta.file_size && cached.mtime_ticks == meta.mtime_ticks) { - Sprite s; - s.path = path; - s.w = cached.w; - s.h = cached.h; - s.trim_left = cached.trim_left; - s.trim_top = cached.trim_top; - s.trim_right = cached.trim_right; - s.trim_bottom = cached.trim_bottom; - sprites.push_back(std::move(s)); - cache_it->second.cached_at_unix = now_unix; - continue; + // If deduplication is requested and the relevant hash is missing, + // fall through to reload so we can compute the hash. + bool need_hash = false; + if (deduplicateMode == "exact" && cached.content_hash == 0) { + need_hash = true; + } else if (deduplicateMode == "perceptual" && cached.perceptual_hash == 0) { + need_hash = true; + } + if (!need_hash) { + Sprite s; + s.path = path; + s.w = cached.w; + s.h = cached.h; + s.trim_left = cached.trim_left; + s.trim_top = cached.trim_top; + s.trim_right = cached.trim_right; + s.trim_bottom = cached.trim_bottom; + result.ok = true; + result.from_cache = true; + result.sprite = std::move(s); + return; + } } } Sprite loaded_sprite; loaded_sprite.path = path; if (!trim_transparent) { - int w; - int h; - int channels; - if (stbi_info(path.c_str(), &w, &h, &channels) == 0) { - continue; + // Step 4b: when deduplication is active, load pixel data to compute hashes. + uint64_t entry_content_hash = 0; + uint64_t entry_perceptual_hash = 0; + int w = 0; + int h = 0; + if (deduplicateMode != "none") { + int channels = 0; + unsigned char* px = stbi_load(path.c_str(), &w, &h, &channels, 4); + if (px == nullptr) { + result.failed = true; + result.fail_reason = stbi_failure_reason(); + return; + } + // FNV-1a over the full RGBA buffer + const size_t nbytes = static_cast(w) * static_cast(h) * 4; + uint64_t fnv = 14695981039346656037ULL; + for (size_t bi = 0; bi < nbytes; ++bi) { + fnv ^= px[bi]; + fnv *= 1099511628211ULL; + } + entry_content_hash = fnv; + entry_perceptual_hash = compute_dhash(px, w, h); + stbi_image_free(px); + } else { + int channels = 0; + if (stbi_info(path.c_str(), &w, &h, &channels) == 0) { + result.failed = true; + result.fail_reason = stbi_failure_reason(); + return; + } } loaded_sprite.w = w; loaded_sprite.h = h; - sprites.push_back(loaded_sprite); - cache_entries[cache_key] = { + result.ok = true; + result.sprite = loaded_sprite; + result.new_entry = { .trim_transparent=trim_transparent, .file_size=meta.file_size, .mtime_ticks=meta.mtime_ticks, @@ -3532,9 +4217,11 @@ int run_spratlayout(int argc, char** argv) { .trim_top=loaded_sprite.trim_top, .trim_right=loaded_sprite.trim_right, .trim_bottom=loaded_sprite.trim_bottom, - .cached_at_unix=now_unix + .cached_at_unix=now_unix, + .content_hash=entry_content_hash, + .perceptual_hash=entry_perceptual_hash }; - continue; + return; } int w = 0; @@ -3542,14 +4229,18 @@ int run_spratlayout(int argc, char** argv) { int channels = 0; unsigned char* data = stbi_load(path.c_str(), &w, &h, &channels, 4); if (data == nullptr) { - std::cerr << tr("Warning: Failed to load sprite ") << to_quoted(path) << tr(" (Reason: ") << stbi_failure_reason() << tr(")\n"); - continue; + result.failed = true; + result.fail_reason = stbi_failure_reason(); + return; } int min_x = 0; int min_y = 0; int max_x = -1; int max_y = -1; + // Step 4c: compute hashes over trimmed region before freeing pixel data. + uint64_t entry_content_hash = 0; + uint64_t entry_perceptual_hash = 0; if (compute_trim_bounds(data, w, h, min_x, min_y, max_x, max_y)) { loaded_sprite.trim_left = min_x; loaded_sprite.trim_top = min_y; @@ -3557,6 +4248,32 @@ int run_spratlayout(int argc, char** argv) { loaded_sprite.trim_bottom = (h - 1) - max_y; loaded_sprite.w = max_x - min_x + 1; loaded_sprite.h = max_y - min_y + 1; + + if (deduplicateMode != "none") { + // FNV-1a over the trimmed pixel region + uint64_t fnv = 14695981039346656037ULL; + for (int ry = min_y; ry <= max_y; ++ry) { + const unsigned char* row = data + (static_cast(ry) * static_cast(w) + static_cast(min_x)) * 4; + for (int rx = 0; rx < (max_x - min_x + 1); ++rx) { + for (int c = 0; c < 4; ++c) { + fnv ^= row[static_cast(rx) * 4 + static_cast(c)]; + fnv *= 1099511628211ULL; + } + } + } + entry_content_hash = fnv; + + // Extract trimmed region for dHash + const int tw = max_x - min_x + 1; + const int th = max_y - min_y + 1; + std::vector trimmed(static_cast(tw) * static_cast(th) * 4); + for (int ry = 0; ry < th; ++ry) { + const unsigned char* src = data + (static_cast(min_y + ry) * static_cast(w) + static_cast(min_x)) * 4; + unsigned char* dst = trimmed.data() + static_cast(ry) * static_cast(tw) * 4; + std::memcpy(dst, src, static_cast(tw) * 4); + } + entry_perceptual_hash = compute_dhash(trimmed.data(), tw, th); + } } else { // Fully transparent image: keep a 1x1 transparent region. loaded_sprite.trim_left = 0; @@ -3565,11 +4282,18 @@ int run_spratlayout(int argc, char** argv) { loaded_sprite.trim_bottom = std::max(0, h - 1); loaded_sprite.w = 1; loaded_sprite.h = 1; + + if (deduplicateMode != "none") { + // Sentinel: all fully-transparent sprites are equivalent. + entry_content_hash = 0xFFFFFFFFFFFFFFFFULL; + entry_perceptual_hash = 0xFFFFFFFFFFFFFFFFULL; + } } stbi_image_free(data); - sprites.push_back(loaded_sprite); - cache_entries[cache_key] = { + result.ok = true; + result.sprite = loaded_sprite; + result.new_entry = { .trim_transparent=trim_transparent, .file_size=meta.file_size, .mtime_ticks=meta.mtime_ticks, @@ -3579,12 +4303,147 @@ int run_spratlayout(int argc, char** argv) { .trim_top=loaded_sprite.trim_top, .trim_right=loaded_sprite.trim_right, .trim_bottom=loaded_sprite.trim_bottom, - .cached_at_unix=now_unix + .cached_at_unix=now_unix, + .content_hash=entry_content_hash, + .perceptual_hash=entry_perceptual_hash }; + }; + + unsigned int load_worker_count = + thread_limit > 0 ? thread_limit : std::thread::hardware_concurrency(); + if (load_worker_count == 0) load_worker_count = 1; +#ifdef __EMSCRIPTEN__ + load_worker_count = 1; +#endif + load_worker_count = std::min( + load_worker_count, static_cast(source_count)); + + if (load_worker_count <= 1 || source_count <= 1) { + for (size_t i = 0; i < source_count; ++i) process_source(i); + } else { + std::vector workers; + workers.reserve(load_worker_count); + for (unsigned int wi = 0; wi < load_worker_count; ++wi) { + workers.emplace_back([&, wi]() { + const size_t begin = (source_count * wi) / load_worker_count; + const size_t end = (source_count * (wi + 1)) / load_worker_count; + for (size_t i = begin; i < end; ++i) process_source(i); + }); + } + for (auto& t : workers) t.join(); + } + + // Serial collection pass: merge results in source order. + // cache_entries writes are deferred here to avoid concurrent map mutations. + std::vector sprites; + sprites.reserve(source_count); + for (size_t i = 0; i < source_count; ++i) { + const SpriteLoadResult& r = load_results[i]; + if (r.failed) { + std::cerr << tr("Warning: Failed to load sprite ") + << to_quoted(sources[i].path) + << tr(" (Reason: ") << r.fail_reason << tr(")\n"); + continue; + } + if (!r.ok) continue; + sprites.push_back(r.sprite); + if (r.from_cache) { + auto it = cache_entries.find(r.cache_key); + if (it != cache_entries.end()) it->second.cached_at_unix = now_unix; + } else { + cache_entries[r.cache_key] = r.new_entry; + } } save_image_cache(cache_path, cache_entries); + // Step 5: Deduplication pass + std::vector> layout_aliases; + if (deduplicateMode == "exact") { + // O(N) hash-map dedup keyed by (content_hash, w, h) + struct DedupKey { + uint64_t hash; + int w; + int h; + bool operator==(const DedupKey& o) const { + return hash == o.hash && w == o.w && h == o.h; + } + }; + struct DedupKeyHash { + size_t operator()(const DedupKey& k) const { + size_t h = std::hash{}(k.hash); + h ^= std::hash{}(k.w) + 0x9e3779b9u + (h << 6u) + (h >> 2u); + h ^= std::hash{}(k.h) + 0x9e3779b9u + (h << 6u) + (h >> 2u); + return h; + } + }; + std::unordered_map canonical_map; + std::vector deduped; + deduped.reserve(sprites.size()); + for (const auto& s : sprites) { + const std::string ck = s.path + (trim_transparent ? "|1" : "|0"); + const auto it = cache_entries.find(ck); + const uint64_t h = (it != cache_entries.end()) ? it->second.content_hash : 0; + if (h == 0) { + deduped.push_back(s); + continue; + } + DedupKey key{h, s.w, s.h}; + auto [ins_it, inserted] = canonical_map.emplace(key, s.path); + if (inserted) { + deduped.push_back(s); + } else { + layout_aliases.push_back({s.path, ins_it->second}); + } + } + sprites = std::move(deduped); + } else if (deduplicateMode == "perceptual") { + // O(N²) pairwise + union-find dedup + const size_t N = sprites.size(); + // Gather hashes + std::vector phash(N, 0); + for (size_t i = 0; i < N; ++i) { + const std::string ck = sprites[i].path + (trim_transparent ? "|1" : "|0"); + const auto it = cache_entries.find(ck); + if (it != cache_entries.end()) { + phash[i] = it->second.perceptual_hash; + } + } + // Union-find + std::vector parent(N); + std::iota(parent.begin(), parent.end(), 0); + std::function find = [&](size_t x) -> size_t { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + }; + for (size_t i = 0; i < N; ++i) { + if (phash[i] == 0) continue; + for (size_t j = i + 1; j < N; ++j) { + if (phash[j] == 0) continue; + if (sprites[i].w != sprites[j].w || sprites[i].h != sprites[j].h) continue; + if (popcount64(phash[i] ^ phash[j]) <= k_dhash_threshold) { + size_t ri = find(i), rj = find(j); + if (ri != rj) parent[rj] = ri; + } + } + } + // Build deduped list + std::unordered_map canonical_map; // root -> first sprite index + canonical_map.reserve(N); + std::vector deduped; + deduped.reserve(N); + for (size_t i = 0; i < N; ++i) { + size_t root = find(i); + auto [ins_it, inserted] = canonical_map.emplace(root, i); + if (inserted) { + deduped.push_back(sprites[i]); + } else { + layout_aliases.push_back({sprites[i].path, sprites[ins_it->second].path}); + } + } + sprites = std::move(deduped); + } + if (sprites.empty()) { std::cerr << tr("Error: no valid images found\n"); return 1; @@ -3652,6 +4511,10 @@ int run_spratlayout(int argc, char** argv) { return 1; } + if (enforce_stable_order) { + sort_sprites_stable(sprites, stable_metric); + } + bool reused_layout_seed = false; bool have_layout_seed = false; LayoutSeedCache seed_cache; @@ -3673,7 +4536,7 @@ int run_spratlayout(int argc, char** argv) { atlases.push_back({atlas_width, atlas_height}); for (auto& s : sprites) { s.atlas_index = 0; } } else if (multipack) { - if (!pack_atlases(sprites, width_upper_bound, height_upper_bound, padding, mode, optimize_target, allow_rotate, enforce_name_order, atlases)) { + if (!pack_atlases(sprites, width_upper_bound, height_upper_bound, padding, mode, optimize_target, allow_rotate, enforce_name_order || enforce_stable_order, atlases)) { std::cerr << tr("Error: failed to compute multipack layout\n"); return 1; } @@ -3701,15 +4564,37 @@ int run_spratlayout(int argc, char** argv) { return 1; } + // Pre-build sorted sprite arrays for each sort mode. + const bool enforce_sort_order_pot = enforce_name_order || enforce_stable_order; + std::array, k_sort_mode_count> pot_sorted; + pot_sorted[0] = sprites; + if (!(enforce_sort_order_pot && sort_modes[0] != SortMode::None)) { + sort_sprites_by_mode(pot_sorted[0], sort_modes[0]); + } + for (size_t si = 1; si < sort_modes.size(); ++si) { + pot_sorted[si] = pot_sorted[0]; // copy already-allocated vector + if (!(enforce_sort_order_pot && sort_modes[si] != SortMode::None)) { + sort_sprites_by_mode(pot_sorted[si], sort_modes[si]); + } + } + // First, find an upper bound that can pack, then search all POT // rectangles up to that area and pick the least wasteful successful fit. int side = std::max(min_pot_width, min_pot_height); - std::vector best_sprites = sprites; - int best_w = 0; - int best_h = 0; - size_t best_area = 0; + int best_gpu_w = 0; + int best_gpu_h = 0; + size_t best_gpu_area = 0; + bool have_best_gpu = false; + std::vector best_gpu_sprites; + + int best_space_w = 0; + int best_space_h = 0; + size_t best_space_area = 0; + bool have_best_space = false; + std::vector best_space_sprites; + size_t max_candidate_area = 0; - bool have_best = false; + std::vector trial_sprites; while (true) { if (max_width_limit > 0 && side > max_width_limit) { @@ -3720,26 +4605,26 @@ int run_spratlayout(int argc, char** argv) { std::cerr << tr("Error: no POT layout fits within max height\n"); return 1; } - for (SortMode sort_mode : sort_modes) { - if (enforce_name_order && sort_mode != SortMode::None) { + for (size_t si = 0; si < sort_modes.size(); ++si) { + if (enforce_sort_order_pot && sort_modes[si] != SortMode::None) { continue; } - std::vector trial_sprites = sprites; - sort_sprites_by_mode(trial_sprites, sort_mode); + trial_sprites.assign(pot_sorted[si].begin(), pot_sorted[si].end()); root = std::make_unique(0, 0, side, side); if (!try_pack(root, trial_sprites, padding, allow_rotate)) { continue; } size_t area = static_cast(side) * static_cast(side); - best_sprites = std::move(trial_sprites); - best_w = side; - best_h = side; - best_area = area; + best_gpu_sprites = trial_sprites; + best_space_sprites = std::move(trial_sprites); + best_gpu_w = best_space_w = side; + best_gpu_h = best_space_h = side; + best_gpu_area = best_space_area = area; max_candidate_area = area; - have_best = true; + have_best_gpu = have_best_space = true; break; } - if (have_best) { + if (have_best_gpu) { break; } if (side > std::numeric_limits::max() / 2) { @@ -3751,13 +4636,13 @@ int run_spratlayout(int argc, char** argv) { std::vector pot_widths; std::vector pot_heights; - for (int w = min_pot_width; w > 0 && std::cmp_less_equal(w, best_area); w *= 2) { + for (int w = min_pot_width; w > 0 && std::cmp_less_equal(w, max_candidate_area); w *= 2) { pot_widths.push_back(w); if (w > std::numeric_limits::max() / 2) { break; } } - for (int h = min_pot_height; h > 0 && std::cmp_less_equal(h, best_area); h *= 2) { + for (int h = min_pot_height; h > 0 && std::cmp_less_equal(h, max_candidate_area); h *= 2) { pot_heights.push_back(h); if (h > std::numeric_limits::max() / 2) { break; @@ -3776,39 +4661,73 @@ int run_spratlayout(int argc, char** argv) { if (max_height_limit > 0 && h > max_height_limit) { continue; } - if (!pick_better_layout_candidate(area, w, h, have_best, best_area, best_w, best_h, optimize_target)) { + const bool could_beat_gpu = pick_better_layout_candidate(area, w, h, have_best_gpu, best_gpu_area, best_gpu_w, best_gpu_h, OptimizeTarget::GPU); + const bool could_beat_space = pick_better_layout_candidate(area, w, h, have_best_space, best_space_area, best_space_w, best_space_h, OptimizeTarget::SPACE); + if (!could_beat_gpu && !could_beat_space) { continue; } - for (SortMode sort_mode : sort_modes) { - if (enforce_name_order && sort_mode != SortMode::None) { + for (size_t si = 0; si < sort_modes.size(); ++si) { + if (enforce_sort_order_pot && sort_modes[si] != SortMode::None) { continue; } - std::vector trial_sprites = sprites; - sort_sprites_by_mode(trial_sprites, sort_mode); + trial_sprites.assign(pot_sorted[si].begin(), pot_sorted[si].end()); root = std::make_unique(0, 0, w, h); if (!try_pack(root, trial_sprites, padding, allow_rotate)) { continue; } - best_sprites = std::move(trial_sprites); - best_w = w; - best_h = h; - best_area = area; - have_best = true; + if (could_beat_gpu) { + best_gpu_sprites = trial_sprites; + best_gpu_w = w; + best_gpu_h = h; + best_gpu_area = area; + have_best_gpu = true; + } + if (could_beat_space) { + if (could_beat_gpu) { + best_space_sprites = best_gpu_sprites; + } else { + best_space_sprites = std::move(trial_sprites); + } + best_space_w = w; + best_space_h = h; + best_space_area = area; + have_best_space = true; + } break; } } } - if (!have_best) { + if (!have_best_gpu && !have_best_space) { std::cerr << tr("Error: failed to compute pot layout\n"); return 1; } - sprites = std::move(best_sprites); - atlas_width = best_w; - atlas_height = best_h; + if (optimize_target == OptimizeTarget::GPU) { + if (have_best_gpu) { + sprites = std::move(best_gpu_sprites); + atlas_width = best_gpu_w; + atlas_height = best_gpu_h; + } else { + std::cerr << tr("Warning: no GPU-optimal POT layout found, using space-optimal fallback\n"); + sprites = std::move(best_space_sprites); + atlas_width = best_space_w; + atlas_height = best_space_h; + } + } else { + if (have_best_space) { + sprites = std::move(best_space_sprites); + atlas_width = best_space_w; + atlas_height = best_space_h; + } else { + std::cerr << tr("Warning: no space-optimal POT layout found, using GPU-optimal fallback\n"); + sprites = std::move(best_gpu_sprites); + atlas_width = best_gpu_w; + atlas_height = best_gpu_h; + } + } atlases.push_back({atlas_width, atlas_height}); for (auto& s : sprites) { s.atlas_index = 0; } } else if (mode == Mode::COMPACT) { @@ -3816,18 +4735,6 @@ int run_spratlayout(int argc, char** argv) { std::cerr << tr("Error: compact bounds are invalid\n"); return 1; } - const size_t combination_budget = max_combinations > 0 - ? static_cast(max_combinations) - : std::numeric_limits::max(); - std::atomic combinations_tested{0}; - auto consume_combination_budget = [&]() -> bool { - if (combination_budget == std::numeric_limits::max()) { - combinations_tested.fetch_add(1, std::memory_order_relaxed); - return true; - } - const size_t previous = combinations_tested.fetch_add(1, std::memory_order_relaxed); - return previous < combination_budget; - }; unsigned int worker_count = thread_limit > 0 ? thread_limit : std::thread::hardware_concurrency(); if (worker_count == 0) { worker_count = 1; @@ -3837,17 +4744,17 @@ int run_spratlayout(int argc, char** argv) { worker_count = 1; #endif + const bool enforce_sort_order_compact = enforce_name_order || enforce_stable_order; std::array, k_sort_mode_count> sorted_sprites_by_mode; - int sort_idx = 0; - for (SortMode sm : sort_modes) { - if (enforce_name_order && sm != SortMode::None) { - // We must still populate the index for the array, but we can skip sorting - sorted_sprites_by_mode[sort_idx] = sprites; - } else { - sorted_sprites_by_mode[sort_idx] = sprites; - sort_sprites_by_mode(sorted_sprites_by_mode[sort_idx], sm); + sorted_sprites_by_mode[0] = sprites; + if (!(enforce_sort_order_compact && sort_modes[0] != SortMode::None)) { + sort_sprites_by_mode(sorted_sprites_by_mode[0], sort_modes[0]); + } + for (size_t sort_idx = 1; sort_idx < sort_modes.size(); ++sort_idx) { + sorted_sprites_by_mode[sort_idx] = sorted_sprites_by_mode[0]; + if (!(enforce_sort_order_compact && sort_modes[sort_idx] != SortMode::None)) { + sort_sprites_by_mode(sorted_sprites_by_mode[sort_idx], sort_modes[sort_idx]); } - ++sort_idx; } int seed_width = max_width; @@ -3899,8 +4806,8 @@ int run_spratlayout(int argc, char** argv) { } if (better_gpu && better_space) { - best_gpu_candidate = candidate; - best_space_candidate = std::move(candidate); + best_gpu_candidate = std::move(candidate); + best_space_candidate = best_gpu_candidate; return; } if (better_gpu) { @@ -3910,17 +4817,13 @@ int run_spratlayout(int argc, char** argv) { best_space_candidate = std::move(candidate); }; - bool budget_exhausted = false; - for (size_t sort_idx = 0; sort_idx < sort_modes.size() && !budget_exhausted; ++sort_idx) { - if (enforce_name_order && sort_modes[sort_idx] != SortMode::None) { + std::vector seed_sprites; + for (size_t sort_idx = 0; sort_idx < sort_modes.size(); ++sort_idx) { + if (enforce_sort_order_compact && sort_modes[sort_idx] != SortMode::None) { continue; } for (RectHeuristic rect_heuristic : rect_heuristics) { - if (!consume_combination_budget()) { - budget_exhausted = true; - break; - } - std::vector seed_sprites = sorted_sprites_by_mode[sort_idx]; + seed_sprites.assign(sorted_sprites_by_mode[sort_idx].begin(), sorted_sprites_by_mode[sort_idx].end()); int seed_used_w = 0; int seed_used_h = 0; if (!pack_compact_maxrects(seed_sprites, seed_width, padding, height_upper_bound, rect_heuristic, allow_rotate, seed_used_w, seed_used_h)) { @@ -3979,7 +4882,7 @@ int run_spratlayout(int argc, char** argv) { const int range = std::max(0, width_upper_bound - fast_target_width); const int step = std::max(k_search_step_min, range / k_search_step_divisor); const std::array offsets = k_guided_search_offsets; - const std::array anchor_widths = {seed_width, fast_target_width, max_width}; + const std::array anchor_widths = {seed_width, fast_target_width, max_width, width_upper_bound}; for (int anchor : anchor_widths) { for (int mul : offsets) { const long long width_ll = @@ -3994,11 +4897,49 @@ int run_spratlayout(int argc, char** argv) { } std::ranges::sort(width_candidates); - if (!budget_exhausted && !width_candidates.empty()) { + if (!width_candidates.empty()) { worker_count = std::min(worker_count, static_cast(width_candidates.size())); std::vector worker_gpu(worker_count); std::vector worker_space(worker_count); - auto run_guided_worker = [&](size_t begin, size_t end, LayoutCandidate& out_gpu, LayoutCandidate& out_space) { + const int min_square_side = + total_area > 0 + ? static_cast(std::ceil(std::sqrt(static_cast(total_area)))) + : 0; + auto select_better_candidate = [&](const LayoutCandidate& local_best, const LayoutCandidate& shared_best, OptimizeTarget target) -> const LayoutCandidate* { + if (!local_best.valid) { + return shared_best.valid ? &shared_best : nullptr; + } + if (!shared_best.valid) { + return &local_best; + } + if (pick_better_layout_candidate( + local_best.area, local_best.w, local_best.h, true, + shared_best.area, shared_best.w, shared_best.h, + target)) { + return &local_best; + } + return &shared_best; + }; + auto width_could_beat_best = [&](int width, const LayoutCandidate& local_best, const LayoutCandidate& shared_best, OptimizeTarget target) { + const LayoutCandidate* best = select_better_candidate(local_best, shared_best, target); + if (best == nullptr || total_area == 0) { + return true; + } + const size_t width_size = static_cast(width); + const size_t min_height_size = (total_area + width_size - 1) / width_size; + if (min_height_size > static_cast(std::numeric_limits::max())) { + return false; + } + const int min_height = static_cast(min_height_size); + const int min_max_side = std::max(min_square_side, min_height); + const int optimistic_w = std::min(width, min_max_side); + const int optimistic_h = min_max_side; + return pick_better_layout_candidate( + total_area, optimistic_w, optimistic_h, true, + best->area, best->w, best->h, + target); + }; + auto run_guided_worker = [&](std::atomic* next_width_index, size_t begin, size_t end, LayoutCandidate& out_gpu, LayoutCandidate& out_space) { LayoutCandidate local_best_gpu; LayoutCandidate local_best_space; auto consider_local = [&](LayoutCandidate&& candidate) { @@ -4021,8 +4962,8 @@ int run_spratlayout(int argc, char** argv) { return; } if (better_gpu && better_space) { - local_best_gpu = candidate; - local_best_space = std::move(candidate); + local_best_gpu = std::move(candidate); + local_best_space = local_best_gpu; return; } if (better_gpu) { @@ -4032,23 +4973,35 @@ int run_spratlayout(int argc, char** argv) { local_best_space = std::move(candidate); }; - bool local_budget_exhausted = false; - for (size_t width_index = begin; width_index < end && !local_budget_exhausted; ++width_index) { + std::vector trial_sprites; + std::vector shelf_sprites; + while (true) { + size_t width_index = begin; + if (next_width_index != nullptr) { + width_index = next_width_index->fetch_add(1, std::memory_order_relaxed); + if (width_index >= end) { + break; + } + } else if (width_index < end) { + ++begin; + } else { + break; + } + const int width = width_candidates[width_index]; + if (!width_could_beat_best(width, local_best_gpu, best_gpu_candidate, OptimizeTarget::GPU) && + !width_could_beat_best(width, local_best_space, best_space_candidate, OptimizeTarget::SPACE)) { + continue; + } + + bool continue_width_search = true; for (size_t sort_idx : k_guided_sort_indices) { - if (enforce_name_order && sort_idx != k_sort_mode_index_none) { + if (enforce_sort_order_compact && sort_idx != k_sort_mode_index_none) { continue; } - if (local_budget_exhausted) { - break; - } for (RectHeuristic rect_heuristic : k_guided_heuristics) { - if (!consume_combination_budget()) { - local_budget_exhausted = true; - break; - } - std::vector trial_sprites = sorted_sprites_by_mode[sort_idx]; + trial_sprites.assign(sorted_sprites_by_mode[sort_idx].begin(), sorted_sprites_by_mode[sort_idx].end()); int used_w = 0; int used_h = 0; if (!pack_compact_maxrects(trial_sprites, width, padding, height_upper_bound, rect_heuristic, allow_rotate, used_w, used_h)) { @@ -4062,94 +5015,17 @@ int run_spratlayout(int argc, char** argv) { candidate.h = used_h; candidate.sprites = std::move(trial_sprites); consider_local(std::move(candidate)); + if (!width_could_beat_best(width, local_best_gpu, best_gpu_candidate, OptimizeTarget::GPU) && + !width_could_beat_best(width, local_best_space, best_space_candidate, OptimizeTarget::SPACE)) { + continue_width_search = false; + break; + } } - } - } - - out_gpu = std::move(local_best_gpu); - out_space = std::move(local_best_space); - }; - - if (worker_count == 1) { - run_guided_worker(0, width_candidates.size(), worker_gpu[0], worker_space[0]); - } else { - std::vector workers; - workers.reserve(worker_count); - for (unsigned int worker_index = 0; worker_index < worker_count; ++worker_index) { - workers.emplace_back([&, worker_index]() { - const size_t begin = (width_candidates.size() * worker_index) / worker_count; - const size_t end = (width_candidates.size() * (worker_index + 1)) / worker_count; - run_guided_worker(begin, end, worker_gpu[worker_index], worker_space[worker_index]); - }); - } - for (auto& worker : workers) { - worker.join(); - } - } - for (unsigned int i = 0; i < worker_count; ++i) { - if (worker_gpu[i].valid) { - consider_candidate(std::move(worker_gpu[i])); - } - if (worker_space[i].valid) { - consider_candidate(std::move(worker_space[i])); - } - } - - budget_exhausted = (combination_budget != std::numeric_limits::max()) && - (combinations_tested.load(std::memory_order_relaxed) >= combination_budget); - } - - // Include shelf candidates from same guided widths as a cheap fallback. - if (!budget_exhausted && !width_candidates.empty()) { - worker_count = std::min(worker_count, static_cast(width_candidates.size())); - std::vector worker_gpu(worker_count); - std::vector worker_space(worker_count); - auto run_shelf_worker = [&](size_t begin, size_t end, LayoutCandidate& out_gpu, LayoutCandidate& out_space) { - LayoutCandidate local_best_gpu; - LayoutCandidate local_best_space; - auto consider_local = [&](LayoutCandidate&& candidate) { - if (!candidate.valid || candidate.w <= 0 || candidate.h <= 0) { - return; - } - const bool better_gpu = - !local_best_gpu.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - local_best_gpu.area, local_best_gpu.w, local_best_gpu.h, - OptimizeTarget::GPU); - const bool better_space = - !local_best_space.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - local_best_space.area, local_best_space.w, local_best_space.h, - OptimizeTarget::SPACE); - if (!better_gpu && !better_space) { - return; - } - if (better_gpu && better_space) { - local_best_gpu = candidate; - local_best_space = std::move(candidate); - return; - } - if (better_gpu) { - local_best_gpu = std::move(candidate); - return; - } - local_best_space = std::move(candidate); - }; - - bool local_budget_exhausted = false; - for (size_t width_index = begin; width_index < end && !local_budget_exhausted; ++width_index) { - const int width = width_candidates[width_index]; - for (size_t sort_idx : k_guided_sort_indices) { - if (enforce_name_order && sort_idx != k_sort_mode_index_none) { - continue; - } - if (!consume_combination_budget()) { - local_budget_exhausted = true; + if (!continue_width_search) { break; } - std::vector shelf_sprites = sorted_sprites_by_mode[sort_idx]; + + shelf_sprites.assign(sorted_sprites_by_mode[sort_idx].begin(), sorted_sprites_by_mode[sort_idx].end()); int shelf_w = 0; int shelf_h = 0; if (!pack_fast_shelf(shelf_sprites, width, padding, allow_rotate, shelf_w, shelf_h)) { @@ -4166,6 +5042,10 @@ int run_spratlayout(int argc, char** argv) { shelf_candidate.h = shelf_h; shelf_candidate.sprites = std::move(shelf_sprites); consider_local(std::move(shelf_candidate)); + if (!width_could_beat_best(width, local_best_gpu, best_gpu_candidate, OptimizeTarget::GPU) && + !width_could_beat_best(width, local_best_space, best_space_candidate, OptimizeTarget::SPACE)) { + break; + } } } @@ -4174,15 +5054,14 @@ int run_spratlayout(int argc, char** argv) { }; if (worker_count == 1) { - run_shelf_worker(0, width_candidates.size(), worker_gpu[0], worker_space[0]); + run_guided_worker(nullptr, 0, width_candidates.size(), worker_gpu[0], worker_space[0]); } else { + std::atomic next_width_index{0}; std::vector workers; workers.reserve(worker_count); for (unsigned int worker_index = 0; worker_index < worker_count; ++worker_index) { workers.emplace_back([&, worker_index]() { - const size_t begin = (width_candidates.size() * worker_index) / worker_count; - const size_t end = (width_candidates.size() * (worker_index + 1)) / worker_count; - run_shelf_worker(begin, end, worker_gpu[worker_index], worker_space[worker_index]); + run_guided_worker(&next_width_index, 0, width_candidates.size(), worker_gpu[worker_index], worker_space[worker_index]); }); } for (auto& worker : workers) { @@ -4201,9 +5080,19 @@ int run_spratlayout(int argc, char** argv) { const LayoutCandidate* selected_candidate = nullptr; if (optimize_target == OptimizeTarget::GPU) { - selected_candidate = best_gpu_candidate.valid ? &best_gpu_candidate : &best_space_candidate; + if (best_gpu_candidate.valid) { + selected_candidate = &best_gpu_candidate; + } else { + std::cerr << tr("Warning: no GPU-optimal layout found, using space-optimal fallback\n"); + selected_candidate = &best_space_candidate; + } } else { - selected_candidate = best_space_candidate.valid ? &best_space_candidate : &best_gpu_candidate; + if (best_space_candidate.valid) { + selected_candidate = &best_space_candidate; + } else { + std::cerr << tr("Warning: no space-optimal layout found, using GPU-optimal fallback\n"); + selected_candidate = &best_gpu_candidate; + } } if ((selected_candidate == nullptr) || !selected_candidate->valid) { std::cerr << tr("Error: failed to compute compact layout\n"); @@ -4222,9 +5111,9 @@ int run_spratlayout(int argc, char** argv) { } const ProfileDefinition& compact_profile = prewarm_it->second; const Mode prewarm_mode = - has_mode_override ? mode_override : compact_profile.mode; + has_mode_override ? args.mode_override : compact_profile.mode; const OptimizeTarget prewarm_optimize_target = - has_optimize_override ? optimize_override : compact_profile.optimize_target; + has_optimize_override ? args.optimize_override : compact_profile.optimize_target; if (prewarm_mode != Mode::COMPACT) { continue; } @@ -4240,10 +5129,6 @@ int run_spratlayout(int argc, char** argv) { has_padding_override ? padding : (compact_profile.padding ? *compact_profile.padding : 0); - const int prewarm_max_combinations = - has_max_combinations_override - ? max_combinations - : (compact_profile.max_combinations ? *compact_profile.max_combinations : 0); const double prewarm_scale = has_scale_override ? scale @@ -4260,7 +5145,6 @@ int run_spratlayout(int argc, char** argv) { prewarm_max_height, prewarm_padding, extrude, - prewarm_max_combinations, prewarm_scale, prewarm_trim_transparent, allow_rotate, @@ -4279,6 +5163,9 @@ int run_spratlayout(int argc, char** argv) { std::vector prewarm_atlases; prewarm_atlases.push_back({prewarm_candidate.w, prewarm_candidate.h}); std::vector> empty_prewarm_aliases; + const fs::path prewarm_root = (input_context.type == InputType::ListFile) + ? input_context.working_folder.parent_path() + : input_context.working_folder; const std::string prewarm_output = build_layout_output_text( prewarm_atlases, prewarm_scale, @@ -4287,7 +5174,8 @@ int run_spratlayout(int argc, char** argv) { false, prewarm_candidate.sprites, empty_prewarm_aliases, - false + false, + prewarm_root ); save_output_cache( build_output_cache_path(cache_path, prewarm_signature), @@ -4298,6 +5186,24 @@ int run_spratlayout(int argc, char** argv) { } atlases.push_back({atlas_width, atlas_height}); for (auto& s : sprites) { s.atlas_index = 0; } + } else if (mode == Mode::GRID) { + std::vector sorted_sprites = sprites; + if (enforce_name_order) { + sort_sprites_by_mode(sorted_sprites, SortMode::None); + } else if (enforce_stable_order) { + sort_sprites_stable(sorted_sprites, stable_metric); + } + int grid_width = 0; + int grid_height = 0; + if (!pack_grid(sorted_sprites, padding, width_upper_bound, height_upper_bound, grid_width, grid_height)) { + std::cerr << tr("Error: failed to compute grid layout\n"); + return 1; + } + sprites = std::move(sorted_sprites); + atlas_width = grid_width; + atlas_height = grid_height; + atlases.push_back({atlas_width, atlas_height}); + for (auto& s : sprites) { s.atlas_index = 0; } } else { int target_width = max_width; if (total_area > 0) { @@ -4324,9 +5230,10 @@ int run_spratlayout(int argc, char** argv) { } std::vector sorted_sprites = sprites; - const bool enforce_fast_order = has_frame_sort_override ? (frame_sort == FrameSort::Name) : do_sort; - if (enforce_fast_order) { + if (enforce_name_order) { sort_sprites_by_mode(sorted_sprites, SortMode::None); + } else if (enforce_stable_order) { + sort_sprites_stable(sorted_sprites, stable_metric); } else { sort_sprites_by_mode(sorted_sprites, SortMode::Height); } @@ -4357,14 +5264,38 @@ int run_spratlayout(int argc, char** argv) { } } - if (padding > 0 && !multipack) { - if (!compute_tight_atlas_bounds(sprites, atlas_width, atlas_height)) { - std::cerr << tr("Error: failed to compute final atlas bounds\n"); - return 1; - } - if (!atlases.empty()) { - atlases[0].width = atlas_width; - atlases[0].height = atlas_height; + if (padding > 0 && mode != Mode::GRID) { + if (multipack) { + for (size_t ai = 0; ai < atlases.size(); ++ai) { + int tight_w = 0; + int tight_h = 0; + for (const auto& s : sprites) { + if (s.atlas_index != static_cast(ai)) { + continue; + } + int x1 = 0; + int y1 = 0; + if (!checked_add_int(s.x, s.w, x1) || !checked_add_int(s.y, s.h, y1)) { + std::cerr << tr("Error: failed to compute final atlas bounds\n"); + return 1; + } + tight_w = std::max(x1, tight_w); + tight_h = std::max(y1, tight_h); + } + if (tight_w > 0 && tight_h > 0) { + atlases[ai].width = tight_w; + atlases[ai].height = tight_h; + } + } + } else { + if (!compute_tight_atlas_bounds(sprites, atlas_width, atlas_height)) { + std::cerr << tr("Error: failed to compute final atlas bounds\n"); + return 1; + } + if (!atlases.empty()) { + atlases[0].width = atlas_width; + atlases[0].height = atlas_height; + } } } @@ -4392,8 +5323,9 @@ int run_spratlayout(int argc, char** argv) { save_layout_seed_cache(seed_cache_path, next_seed); } - // Placeholder aliases vector (deduplication not yet fully implemented) - const std::vector> layout_aliases; + const fs::path output_root = (input_context.type == InputType::ListFile) + ? input_context.working_folder.parent_path() + : input_context.working_folder; const std::string output_text = build_layout_output_text( atlases, scale, @@ -4402,13 +5334,13 @@ int run_spratlayout(int argc, char** argv) { multipack, sprites, layout_aliases, - debug + debug, + output_root ); #ifdef _WIN32 - if (_setmode(_fileno(stdout), _O_BINARY) == -1) { - std::cerr << tr("Failed to set stdout to binary mode\n"); - } + // Suppress failure: non-fatal when running embedded or as a GUI subprocess. + _setmode(_fileno(stdout), _O_BINARY); #endif std::cout << output_text; diff --git a/src/commands/spratpack_command.cpp b/src/commands/spratpack_command.cpp index 6b36d7a..920b313 100644 --- a/src/commands/spratpack_command.cpp +++ b/src/commands/spratpack_command.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #ifdef _WIN32 #ifndef NOMINMAX @@ -32,6 +33,7 @@ #include #include #include +#include #include #include #include "core/layout_parser.h" @@ -50,6 +52,17 @@ namespace { constexpr size_t NUM_CHANNELS = 4; + +enum class ScaleFilter { nearest, bilinear, bicubic, mitchell }; + +stbir_filter to_stbir_filter(ScaleFilter f) { + switch (f) { + case ScaleFilter::bilinear: return STBIR_FILTER_TRIANGLE; + case ScaleFilter::bicubic: return STBIR_FILTER_CATMULLROM; + case ScaleFilter::mitchell: return STBIR_FILTER_MITCHELL; + default: return STBIR_FILTER_POINT_SAMPLE; + } +} constexpr size_t CHANNEL_R = 0; constexpr size_t CHANNEL_G = 1; constexpr size_t CHANNEL_B = 2; @@ -266,15 +279,16 @@ void dilate_sprite_colors( return; } - // Make a copy of the atlas for reading during dilate - std::vector atlas_snapshot = atlas; + // Two-buffer approach: read_buf holds the previous pass state, atlas is the write target. + // After each pass we copy only the affected sprite region back instead of the full atlas. + std::vector read_buf = atlas; - auto get_pixel = [&](int px, int py, size_t channel) -> unsigned char { + auto get_pixel = [&](const std::vector& buf, int px, int py, size_t channel) -> unsigned char { if (px < 0 || py < 0 || px >= atlas_width || py >= atlas_height) { return 0; } size_t offset = (static_cast(py) * atlas_width + px) * NUM_CHANNELS + channel; - return atlas_snapshot[offset]; + return buf[offset]; }; auto set_pixel_rgb = [&](int px, int py, unsigned char r, unsigned char g, unsigned char b) { @@ -292,31 +306,41 @@ void dilate_sprite_colors( continue; } + // Clamp the dilate region to atlas bounds (sprite bbox expanded by 1 pixel) + const int region_y0 = std::max(0, s.y - 1); + const int region_y1 = std::min(atlas_height - 1, s.y + s.h); + const int region_x0 = std::max(0, s.x - 1); + const int region_x1 = std::min(atlas_width - 1, s.x + s.w); + const size_t region_row_bytes = static_cast(region_x1 - region_x0 + 1) * NUM_CHANNELS; + // For each pass, dilate colors from opaque pixels to transparent neighbors for (int pass = 0; pass < radius; ++pass) { - // Take snapshot after previous pass - atlas_snapshot = atlas; + // Copy only the affected region from atlas to read_buf + for (int y = region_y0; y <= region_y1; ++y) { + size_t row_offset = (static_cast(y) * atlas_width + region_x0) * NUM_CHANNELS; + std::memcpy(&read_buf[row_offset], &atlas[row_offset], region_row_bytes); + } // Check pixels around (and outside) each sprite - for (int y = s.y - 1; y <= s.y + s.h; ++y) { - for (int x = s.x - 1; x <= s.x + s.w; ++x) { + for (int y = region_y0; y <= region_y1; ++y) { + for (int x = region_x0; x <= region_x1; ++x) { // Only process transparent pixels - if (get_pixel(x, y, CHANNEL_A) != 0) { + if (get_pixel(read_buf, x, y, CHANNEL_A) != 0) { continue; } // Check 4 cardinal directions for opaque neighbors - const int dx[] = {-1, 1, 0, 0}; - const int dy[] = {0, 0, -1, 1}; + constexpr int dx[] = {-1, 1, 0, 0}; + constexpr int dy[] = {0, 0, -1, 1}; for (int dir = 0; dir < 4; ++dir) { int nx = x + dx[dir]; int ny = y + dy[dir]; - unsigned char alpha = get_pixel(nx, ny, CHANNEL_A); + unsigned char alpha = get_pixel(read_buf, nx, ny, CHANNEL_A); if (alpha != 0) { // Found opaque neighbor, copy its RGB - unsigned char r = get_pixel(nx, ny, CHANNEL_R); - unsigned char g = get_pixel(nx, ny, CHANNEL_G); - unsigned char b = get_pixel(nx, ny, CHANNEL_B); + unsigned char r = get_pixel(read_buf, nx, ny, CHANNEL_R); + unsigned char g = get_pixel(read_buf, nx, ny, CHANNEL_G); + unsigned char b = get_pixel(read_buf, nx, ny, CHANNEL_B); set_pixel_rgb(x, y, r, g, b); break; // Only copy from first opaque neighbor } @@ -351,8 +375,8 @@ std::vector compress_to_dds( // Compute compressed size (DXT1: width*height/2, DXT5: width*height) size_t compressed_bytes = (format == "dxt1" || format == "DXT1") - ? (width * height) / 2 - : width * height; + ? (static_cast(width) * static_cast(height)) / 2 + : static_cast(width) * static_cast(height); // Build minimal DDS header (128 bytes) struct DdsHeader { @@ -425,7 +449,7 @@ void print_usage() { << tr("Writes PNG to stdout for single-atlas input; TAR to stdout for multipack input.\n") << tr("\n") << tr("Options:\n") - << tr(" -o, --output PATTERN Output filename pattern (e.g. atlas_%d.png)\n") + << tr(" -a, --atlas PATTERN Output filename pattern (e.g. atlas_%d.png)\n") << tr(" --atlas-index N Pick a specific atlas index to output\n") << tr(" --extrude N Repeat edge pixels N times (overrides layout)\n") << tr(" --dilate N Bleed opaque pixels into transparent neighbors (N passes)\n") @@ -433,6 +457,8 @@ void print_usage() { << tr(" --frame-lines Draw rectangle outlines for each sprite\n") << tr(" --line-width N Outline thickness in pixels (default: 1)\n") << tr(" --line-color R,G,B[,A] Outline color channels (0-255, default: 255,0,0,255)\n") + << tr(" --scale-filter FILTER Resampling filter when source and target sizes differ:\n") + << tr(" nearest (default), bilinear, bicubic, mitchell\n") << tr(" --threads N Number of worker threads\n") << tr(" --debug Enable detailed error reporting and debug visualization\n") << tr(" --protect Protect output with basic obfuscation\n") @@ -446,6 +472,7 @@ int run_spratpack(int argc, char** argv) { bool protect = false; bool use_zopfli = false; bool draw_frame_lines = false; + ScaleFilter scale_filter = ScaleFilter::nearest; int line_width = 1; constexpr unsigned char DEFAULT_COLOR_RED = 255; constexpr unsigned char DEFAULT_COLOR_ALPHA = 255; @@ -473,7 +500,7 @@ int run_spratpack(int argc, char** argv) { protect = true; } else if (arg == "--zopfli") { use_zopfli = true; - } else if ((arg == "--output" || arg == "-o") && i + 1 < argc) { + } else if ((arg == "--atlas" || arg == "-a" || arg == "--output" || arg == "-o") && i + 1 < argc) { output_pattern = argv[++i]; } else if (arg == "--atlas-index" && i + 1 < argc) { std::string value = argv[++i]; @@ -523,6 +550,20 @@ int run_spratpack(int argc, char** argv) { std::cerr << tr("Invalid line color: ") << value << "\n"; return 1; } + } else if (arg == "--scale-filter" && i + 1 < argc) { + std::string value = argv[++i]; + if (value == "nearest") { + scale_filter = ScaleFilter::nearest; + } else if (value == "bilinear") { + scale_filter = ScaleFilter::bilinear; + } else if (value == "bicubic") { + scale_filter = ScaleFilter::bicubic; + } else if (value == "mitchell") { + scale_filter = ScaleFilter::mitchell; + } else { + std::cerr << tr("Invalid scale filter: ") << value << tr(" (must be nearest, bilinear, bicubic, or mitchell)\n"); + return 1; + } } else if (arg == "--threads" && i + 1 < argc) { std::string value = argv[++i]; int parsed = 0; @@ -545,6 +586,27 @@ int run_spratpack(int argc, char** argv) { return 1; } + // Resolve relative sprite paths using the root directory from the layout. + if (layout.has_root && !layout.root.empty()) { + std::filesystem::path root_path(layout.root); + for (auto& sprite : layout.sprites) { + std::filesystem::path sp(sprite.path); + if (sp.is_relative()) { + sprite.path = (root_path / sp).string(); + } + } + for (auto& alias : layout.aliases) { + std::filesystem::path ap(alias.first); + if (ap.is_relative()) { + alias.first = (root_path / ap).string(); + } + std::filesystem::path cp(alias.second); + if (cp.is_relative()) { + alias.second = (root_path / cp).string(); + } + } + } + if (requested_atlas_index >= 0 && static_cast(requested_atlas_index) >= layout.atlases.size()) { std::cerr << tr("Error: requested atlas index ") << requested_atlas_index << tr(" out of range (total: ") << layout.atlases.size() << ")\n"; @@ -589,6 +651,14 @@ int run_spratpack(int argc, char** argv) { } } + // Pre-group sprites by atlas index + std::vector> sprites_by_atlas(layout.atlases.size()); + for (const auto& s : layout.sprites) { + if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { + sprites_by_atlas[static_cast(s.atlas_index)].push_back(s); + } + } + for (size_t atlas_idx = 0; atlas_idx < layout.atlases.size(); ++atlas_idx) { if (requested_atlas_index >= 0 && static_cast(requested_atlas_index) != atlas_idx) { continue; @@ -596,12 +666,7 @@ int run_spratpack(int argc, char** argv) { const int atlas_width = layout.atlases[atlas_idx].width; const int atlas_height = layout.atlases[atlas_idx].height; - std::vector atlas_sprites; - for (const auto& s : layout.sprites) { - if (s.atlas_index == static_cast(atlas_idx)) { - atlas_sprites.push_back(s); - } - } + const std::vector& atlas_sprites = sprites_by_atlas[atlas_idx]; size_t pixel_count = 0; size_t byte_count = 0; @@ -669,8 +734,13 @@ int run_spratpack(int argc, char** argv) { return false; } - const bool copy_rows_direct = !s.rotated && (source_w == s.w && source_h == s.h); - if (copy_rows_direct) { + // Unrotated destination dimensions (rotation swaps w/h in the atlas slot) + const int dest_w = s.rotated ? s.h : s.w; + const int dest_h = s.rotated ? s.w : s.h; + const bool needs_scale = (source_w != dest_w || source_h != dest_h); + + // Fast path: no rotation, no scaling + if (!s.rotated && !needs_scale) { const size_t row_bytes = static_cast(s.w) * NUM_CHANNELS; for (int row = 0; row < s.h; ++row) { const size_t dest_pixels = static_cast(s.y + row) * atlas_width + s.x; @@ -679,22 +749,58 @@ int run_spratpack(int argc, char** argv) { const size_t src_offset = src_pixels * NUM_CHANNELS; std::memcpy(atlas_data.data() + dest_offset, image_ptr.get() + src_offset, row_bytes); } + return true; + } + + // Source pointer and stride within the full loaded image + const unsigned char* src_ptr = image_ptr.get() + + (static_cast(source_y) * w + source_x) * NUM_CHANNELS; + const int src_stride_bytes = w * static_cast(NUM_CHANNELS); + + // Scale if source and destination sizes differ + std::vector scaled_buf; + const unsigned char* blit_ptr = src_ptr; + int blit_stride_bytes = src_stride_bytes; + + if (needs_scale) { + scaled_buf.resize(static_cast(dest_w) * dest_h * NUM_CHANNELS); + STBIR_RESIZE resize; + stbir_resize_init(&resize, + src_ptr, source_w, source_h, src_stride_bytes, + scaled_buf.data(), dest_w, dest_h, 0, + STBIR_RGBA, STBIR_TYPE_UINT8_SRGB); + stbir_set_filters(&resize, + to_stbir_filter(scale_filter), to_stbir_filter(scale_filter)); + if (!stbir_resize_extended(&resize)) { + error_out = "Error: Failed to resize image " + to_quoted(s.path); + return false; + } + blit_ptr = scaled_buf.data(); + blit_stride_bytes = dest_w * static_cast(NUM_CHANNELS); + } + + // Blit to atlas, handling 90° CW rotation if needed + if (!s.rotated) { + const size_t row_bytes = static_cast(dest_w) * NUM_CHANNELS; + for (int row = 0; row < dest_h; ++row) { + const size_t dest_off = + (static_cast(s.y + row) * atlas_width + s.x) * NUM_CHANNELS; + const size_t src_off = + static_cast(row) * static_cast(blit_stride_bytes); + std::memcpy(atlas_data.data() + dest_off, blit_ptr + src_off, row_bytes); + } } else { + // atlas(s.x+col, s.y+row) <- source(px=row, py=dest_h-1-col) for (int row = 0; row < s.h; ++row) { for (int col = 0; col < s.w; ++col) { - int sample_x, sample_y; - if (!s.rotated) { - sample_x = source_x + ((col * source_w) / s.w); - sample_y = source_y + ((row * source_h) / s.h); - } else { - sample_x = source_x + ((row * source_w) / s.h); - sample_y = source_y + (source_h - 1 - ((col * source_h) / s.w)); - } - const size_t dest_pixels = static_cast(s.y + row) * atlas_width + (s.x + col); - const size_t dest_offset = dest_pixels * NUM_CHANNELS; - const size_t src_pixels = static_cast(sample_y) * w + sample_x; - const size_t src_offset = src_pixels * NUM_CHANNELS; - std::memcpy(atlas_data.data() + dest_offset, image_ptr.get() + src_offset, NUM_CHANNELS); + const int px = row; + const int py = dest_h - 1 - col; + const size_t dest_off = + (static_cast(s.y + row) * atlas_width + (s.x + col)) * NUM_CHANNELS; + const size_t src_off = + static_cast(py) * static_cast(blit_stride_bytes) + + static_cast(px) * NUM_CHANNELS; + std::memcpy(atlas_data.data() + dest_off, blit_ptr + src_off, NUM_CHANNELS); } } } diff --git a/src/commands/spratunpack_command.cpp b/src/commands/spratunpack_command.cpp index 0ee4d40..7174a1d 100644 --- a/src/commands/spratunpack_command.cpp +++ b/src/commands/spratunpack_command.cpp @@ -1,8 +1,9 @@ // spratunpack.cpp // MIT License (c) 2026 Pedro -// Compile: g++ -std=c++17 -O2 src/spratunpack.cpp -o spratunpack +// Compile: g++ -std=c++20 -O2 src/spratunpack.cpp -o spratunpack #include +#include #include #include #include @@ -88,15 +89,16 @@ class SpriteUnpacker { SpriteUnpacker(Config config) : config_(std::move(config)) {} bool run() { - if (!load_frames()) { return false; -} - if (!load_image()) { return false; -} - + if (!load_frames()) { + return false; + } + if (!load_image()) { + return false; + } if (config_.stdout_mode) { return unpack_to_stdout(); - } return unpack_to_dir(); - + } + return unpack_to_dir(); } private: @@ -376,34 +378,39 @@ class SpriteUnpacker { // Detect if frames is an object or array constexpr size_t JSON_FRAMES_KEY_OFFSET = 9; // strlen("\"frames\":") size_t search_pos = frames_start + JSON_FRAMES_KEY_OFFSET; - while (search_pos < content.size() && (std::isspace(content[search_pos]) != 0)) { search_pos++; -} - + while (search_pos < content.size() && (std::isspace(content[search_pos]) != 0)) { + search_pos++; + } + if (search_pos < content.size() && content[search_pos] == '[') { return parse_json_array(content.substr(search_pos)); - } return parse_json_object(content.substr(search_pos)); - + } + return parse_json_object(content.substr(search_pos)); } bool parse_json_object(const std::string& content) { size_t pos = 0; while (true) { size_t key_start = content.find('\"', pos); - if (key_start == std::string::npos) { break; -} + if (key_start == std::string::npos) { + break; + } size_t key_end = content.find('\"', key_start + 1); - if (key_end == std::string::npos) { break; -} + if (key_end == std::string::npos) { + break; + } std::string key = content.substr(key_start + 1, key_end - key_start - 1); - + size_t obj_start = content.find('{', key_end); - if (obj_start == std::string::npos) { break; -} - + if (obj_start == std::string::npos) { + break; + } + size_t obj_end = find_closing_bracket(content, obj_start, '{', '}'); - if (obj_end == std::string::npos) { break; -} + if (obj_end == std::string::npos) { + break; + } std::string obj_content = content.substr(obj_start, obj_end - obj_start + 1); SpriteFrame frame; @@ -421,12 +428,14 @@ class SpriteUnpacker { size_t pos = 0; while (true) { size_t obj_start = content.find('{', pos); - if (obj_start == std::string::npos) { break; -} - + if (obj_start == std::string::npos) { + break; + } + size_t obj_end = find_closing_bracket(content, obj_start, '{', '}'); - if (obj_end == std::string::npos) { break; -} + if (obj_end == std::string::npos) { + break; + } std::string obj_content = content.substr(obj_start, obj_end - obj_start + 1); @@ -485,31 +494,35 @@ class SpriteUnpacker { if (pos == std::string::npos) { return 0; } - + size_t val_start = pos + key.length(); while (val_start < content.size() && ((std::isspace(content[val_start]) != 0) || content[val_start] == ':')) { val_start++; } - + size_t val_end = val_start; while (val_end < content.size() && (std::isdigit(content[val_end]) != 0)) { val_end++; } - + if (val_start == val_end) { return 0; } - return std::stoi(content.substr(val_start, val_end - val_start)); + int result = 0; + std::from_chars(content.data() + val_start, content.data() + val_end, result); + return result; } static size_t find_closing_bracket(const std::string& s, size_t start, char open, char close) { int depth = 0; for (size_t i = start; i < s.size(); i++) { - if (s[i] == open) { depth++; + if (s[i] == open) { + depth++; } else if (s[i] == close) { depth--; - if (depth == 0) { return i; -} + if (depth == 0) { + return i; + } } } return std::string::npos; @@ -556,7 +569,6 @@ class SpriteUnpacker { } // Use a callback to write to stdout directly - std::vector archive_buffer; auto write_callback = [](struct archive* /*unused*/, void* /*client_data*/, const void* buffer, size_t length) -> la_ssize_t { std::cout.write(static_cast(buffer), length); if (std::cout.fail()) { return -1; @@ -588,35 +600,46 @@ class SpriteUnpacker { return true; } - static bool unpack_to_tar(const fs::path& /*tar_path*/) { - // Not implemented for now, similar to stdout - return false; - } - - bool write_sprite_to_archive_entry(struct archive* a, const SpriteFrame& frame) { + std::vector extract_sprite_pixels(const SpriteFrame& frame) { const auto& bounds = frame.frame; + + // Validate frame rectangle against loaded atlas dimensions. + if (bounds.w <= 0 || bounds.h <= 0 || + bounds.x < 0 || bounds.y < 0 || + bounds.x + bounds.w > width_ || bounds.y + bounds.h > height_) { + return {}; + } + const int out_w = frame.rotated ? bounds.h : bounds.w; const int out_h = frame.rotated ? bounds.w : bounds.h; - + std::vector sprite_data(static_cast(out_w) * out_h * NUM_CHANNELS); - for (int oy = 0; oy < out_h; oy++) { - for (int ox = 0; ox < out_w; ox++) { - int atlas_x = 0; - int atlas_y = 0; - if (frame.rotated) { - atlas_x = bounds.x + (out_h - 1 - oy); - atlas_y = bounds.y + ox; - } else { - atlas_x = bounds.x + ox; - atlas_y = bounds.y + oy; + if (!frame.rotated) { + const size_t row_bytes = static_cast(out_w) * NUM_CHANNELS; + for (int oy = 0; oy < out_h; oy++) { + const size_t dst_offset = static_cast(oy) * out_w * NUM_CHANNELS; + const size_t src_offset = (static_cast(bounds.y + oy) * width_ + bounds.x) * NUM_CHANNELS; + std::memcpy(&sprite_data[dst_offset], &image_data_[src_offset], row_bytes); + } + } else { + for (int oy = 0; oy < out_h; oy++) { + for (int ox = 0; ox < out_w; ox++) { + const int atlas_x = bounds.x + (out_h - 1 - oy); + const int atlas_y = bounds.y + ox; + const size_t dst_idx = (static_cast(oy) * out_w + ox) * NUM_CHANNELS; + const size_t src_idx = (static_cast(atlas_y) * width_ + atlas_x) * NUM_CHANNELS; + std::memcpy(&sprite_data[dst_idx], &image_data_[src_idx], NUM_CHANNELS); } - - const size_t dst_idx = (static_cast(oy) * out_w + ox) * NUM_CHANNELS; - const size_t src_idx = (static_cast(atlas_y) * width_ + atlas_x) * NUM_CHANNELS; - - std::memcpy(&sprite_data[dst_idx], &image_data_[src_idx], NUM_CHANNELS); } } + return sprite_data; + } + + bool write_sprite_to_archive_entry(struct archive* a, const SpriteFrame& frame) { + const int out_w = frame.rotated ? frame.frame.h : frame.frame.w; + const int out_h = frame.rotated ? frame.frame.w : frame.frame.h; + + std::vector sprite_data = extract_sprite_pixels(frame); // Encode as PNG in memory std::vector png_buffer; @@ -664,36 +687,38 @@ class SpriteUnpacker { } bool save_sprite_image(const SpriteFrame& frame) { - const auto& bounds = frame.frame; - const int out_w = frame.rotated ? bounds.h : bounds.w; - const int out_h = frame.rotated ? bounds.w : bounds.h; - - std::vector sprite_data(static_cast(out_w) * out_h * NUM_CHANNELS); - for (int oy = 0; oy < out_h; oy++) { - for (int ox = 0; ox < out_w; ox++) { - int atlas_x = 0; - int atlas_y = 0; - if (frame.rotated) { - atlas_x = bounds.x + (out_h - 1 - oy); - atlas_y = bounds.y + ox; - } else { - atlas_x = bounds.x + ox; - atlas_y = bounds.y + oy; - } + const int out_w = frame.rotated ? frame.frame.h : frame.frame.w; + const int out_h = frame.rotated ? frame.frame.w : frame.frame.h; - const size_t dst_idx = (static_cast(oy) * out_w + ox) * NUM_CHANNELS; - const size_t src_idx = (static_cast(atlas_y) * width_ + atlas_x) * NUM_CHANNELS; - - std::memcpy(&sprite_data[dst_idx], &image_data_[src_idx], NUM_CHANNELS); - } + std::vector sprite_data = extract_sprite_pixels(frame); + if (sprite_data.empty()) { + std::cerr << tr("Error: Frame ") << to_quoted(frame.name) << tr(" references pixels outside the atlas bounds\n"); + return false; + } + + // Reject frame names that escape the output directory (path traversal guard). + fs::path name_path(frame.name); + if (name_path.is_absolute()) { + std::cerr << tr("Error: Frame name is an absolute path: ") << to_quoted(frame.name) << "\n"; + return false; + } + fs::path output_path = (config_.output_dir / name_path).lexically_normal(); + fs::path rel = output_path.lexically_relative(config_.output_dir.lexically_normal()); + if (rel.empty() || rel.begin()->string() == "..") { + std::cerr << tr("Error: Frame name escapes output directory: ") << to_quoted(frame.name) << "\n"; + return false; } - fs::path output_path = config_.output_dir / frame.name; if (output_path.extension().empty()) { output_path += ".png"; } - - fs::create_directories(output_path.parent_path()); + + std::error_code ec; + fs::create_directories(output_path.parent_path(), ec); + if (ec) { + std::cerr << tr("Error: Failed to create output directory for ") << to_quoted(frame.name) << "\n"; + return false; + } return stbi_write_png(output_path.string().c_str(), out_w, out_h, NUM_CHANNELS, diff --git a/src/core/cli_parse.cpp b/src/core/cli_parse.cpp index 4f2bea3..2f782d0 100644 --- a/src/core/cli_parse.cpp +++ b/src/core/cli_parse.cpp @@ -1,9 +1,9 @@ #include "cli_parse.h" +#include #include -#include -#include #include +#include namespace sprat::core { @@ -13,7 +13,7 @@ bool parse_positive_int(const std::string& value, int& out) { if (ec != std::errc() || ptr != value.data() + value.size()) { return false; } - if (parsed <= 0 || parsed > std::numeric_limits::max()) { + if (parsed <= 0) { return false; } out = parsed; @@ -26,7 +26,7 @@ bool parse_non_negative_int(const std::string& value, int& out) { if (ec != std::errc() || ptr != value.data() + value.size()) { return false; } - if (parsed < 0 || parsed > std::numeric_limits::max()) { + if (parsed < 0) { return false; } out = parsed; @@ -34,20 +34,24 @@ bool parse_non_negative_int(const std::string& value, int& out) { } bool parse_non_negative_uint(const std::string& value, unsigned int& out) { - int parsed = 0; - if (!parse_non_negative_int(value, parsed)) { + if (value.empty() || value[0] == '-') { + return false; + } + unsigned int parsed = 0; + const auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), parsed); + if (ec != std::errc() || ptr != value.data() + value.size()) { return false; } - out = static_cast(parsed); + out = parsed; return true; } bool parse_positive_uint(const std::string& value, unsigned int& out) { - int parsed = 0; - if (!parse_positive_int(value, parsed)) { + unsigned int parsed = 0; + if (!parse_non_negative_uint(value, parsed) || parsed == 0) { return false; } - out = static_cast(parsed); + out = parsed; return true; } @@ -55,13 +59,9 @@ bool parse_int(const std::string& token, int& out) { if (token.empty()) { return false; } - std::istringstream iss(token); int value = 0; - char extra = '\0'; - if (!(iss >> value)) { - return false; - } - if (iss >> extra) { + const auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), value); + if (ec != std::errc() || ptr != token.data() + token.size()) { return false; } out = value; @@ -72,13 +72,11 @@ bool parse_double(const std::string& token, double& out) { if (token.empty()) { return false; } - std::istringstream iss(token); - double value = 0.0; - char extra = '\0'; - if (!(iss >> value)) { - return false; - } - if (iss >> extra) { + const char* begin = token.c_str(); + char* end = nullptr; + errno = 0; + const double value = std::strtod(begin, &end); + if (end != begin + token.size() || errno == ERANGE) { return false; } if (!std::isfinite(value)) { @@ -136,7 +134,7 @@ bool parse_quoted(std::string_view input, size_t& pos, std::string& out, std::st std::string to_quoted(const std::string& s) { std::string result = "\""; for (char c : s) { - if (c == '"' || c == '\\' ) { + if (c == '"' || c == '\\') { result += '\\'; } result += c; diff --git a/src/core/layout_parser.cpp b/src/core/layout_parser.cpp index fffc7a2..d06b518 100644 --- a/src/core/layout_parser.cpp +++ b/src/core/layout_parser.cpp @@ -1,9 +1,27 @@ #include "layout_parser.h" #include "cli_parse.h" +#include #include #include #include +#include + +// Returns true for line prefixes that appear in the combined raw-layout format +// produced by spratconvert but carry no meaning for the basic layout parser. +// Adding a new prefix here is the only change needed if the format gains a +// new ignored token type. +static bool is_combined_format_passthrough(const std::string& line) { + constexpr std::array k_prefixes = { + "path", "- marker", "- frame", "animation", "fps" + }; + for (const auto& prefix : k_prefixes) { + if (line.starts_with(prefix)) { + return true; + } + } + return false; +} namespace sprat::core { @@ -261,6 +279,23 @@ bool parse_layout(std::istream& in, Layout& out, std::string& error) { return false; } parsed.atlases.push_back({w, h}); + } else if (line.starts_with("root")) { + if (parsed.has_root) { + error = "Duplicate root line"; + return false; + } + size_t pos = 4; + while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) { + ++pos; + } + std::string root_path; + std::string root_error; + if (!parse_quoted(line, pos, root_path, root_error)) { + error = "Invalid root line: " + root_error; + return false; + } + parsed.root = root_path; + parsed.has_root = true; } else if (line.starts_with("scale")) { if (parsed.has_scale) { error = "Duplicate scale line"; @@ -311,8 +346,9 @@ bool parse_layout(std::istream& in, Layout& out, std::string& error) { return false; } parsed.aliases.push_back({alias_path, canonical_path}); - } else if (line.starts_with("path") || line.starts_with("- marker") || line.starts_with("- frame") || line.starts_with("animation") || line.starts_with("fps")) { - // These lines are valid in the combined raw layout format but not needed for basic layout parsing + } else if (is_combined_format_passthrough(line)) { + // These lines are valid in the combined raw layout format but carry no + // meaning for the basic layout parser. See is_combined_format_passthrough. continue; } else { // If the line is just whitespace, skip it diff --git a/src/core/layout_parser.h b/src/core/layout_parser.h index 9581018..589027c 100644 --- a/src/core/layout_parser.h +++ b/src/core/layout_parser.h @@ -45,6 +45,8 @@ struct Atlas { struct Layout { std::vector atlases; + std::string root; + bool has_root = false; double scale = 1.0; bool has_scale = false; int extrude = 0; @@ -56,10 +58,6 @@ struct Layout { std::vector> aliases; // (alias_path, canonical_path) pairs }; -bool parse_int(const std::string& token, int& out); -bool parse_double(const std::string& token, double& out); -bool parse_pair(const std::string& token, int& a, int& b); -bool parse_quoted(std::string_view input, size_t& pos, std::string& out, std::string& error); bool parse_sprite_line(const std::string& line, Sprite& out, std::string& error); bool parse_atlas_line(const std::string& line, int& width, int& height); bool parse_scale_line(const std::string& line, double& scale); diff --git a/src/core/stb_impl.cpp b/src/core/stb_impl.cpp index e5d4124..5e13a4f 100644 --- a/src/core/stb_impl.cpp +++ b/src/core/stb_impl.cpp @@ -1,4 +1,6 @@ #define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_WRITE_IMPLEMENTATION +#define STB_IMAGE_RESIZE_IMPLEMENTATION #include "stb_image.h" #include "stb_image_write.h" +#include "stb_image_resize2.h" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 21aaf79..96c5321 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,6 +74,12 @@ add_test( $ ) +add_test( + NAME unity + COMMAND ${BASH_EXE} ${CMAKE_CURRENT_SOURCE_DIR}/unity_test.sh + $ +) + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/sort_behavior_test.sh") add_test( NAME sort_behavior @@ -103,3 +109,23 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/compact_seed_search_regression_test.sh") else() message(WARNING "Skipping compact_seed_search_regression test: script not found") endif() + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/recursive_dir_test.sh") + add_test( + NAME recursive_dir + COMMAND ${BASH_EXE} ${CMAKE_CURRENT_SOURCE_DIR}/recursive_dir_test.sh + $ + ) +else() + message(WARNING "Skipping recursive_dir test: script not found") +endif() + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/spratlayout_exclude_test.sh") + add_test( + NAME spratlayout_exclude + COMMAND ${BASH_EXE} ${CMAKE_CURRENT_SOURCE_DIR}/spratlayout_exclude_test.sh + $ + ) +else() + message(WARNING "Skipping spratlayout_exclude test: script not found") +endif() diff --git a/tests/convert_test.sh b/tests/convert_test.sh index 7590623..18cff65 100755 --- a/tests/convert_test.sh +++ b/tests/convert_test.sh @@ -39,8 +39,8 @@ sprite "./frames/a\"q.png" 0,0 8,8 LAYOUTQ "$convert_bin" --list-transforms > "$tmp_dir/list.txt" -for fmt in json csv xml css; do - if ! grep -q "^${fmt}\b" "$tmp_dir/list.txt"; then +for fmt in JSON CSV XML CSS; do + if ! grep -qi "^${fmt}\b" "$tmp_dir/list.txt"; then echo "Missing transform in list: $fmt" >&2 exit 1 fi @@ -52,6 +52,7 @@ grep -q '"height": 32' "$tmp_dir/out.json" grep -q '"multipack": false' "$tmp_dir/out.json" grep -q '"extrude": 0' "$tmp_dir/out.json" grep -q '"path": "./frames/b.png"' "$tmp_dir/out.json" +grep -q '"atlas_index": 0' "$tmp_dir/out.json" "$convert_bin" --transform csv < "$layout_file" > "$tmp_dir/out.csv" grep -q '^index,name,path,atlas_index,atlas_path,x,y,w,h,pivot_x,pivot_y,trim_left,trim_top,trim_right,trim_bottom,marker_count,markers_json,rotation$' "$tmp_dir/out.csv" @@ -63,33 +64,22 @@ grep -q '' "$tmp_dir/out.xml" grep -q 'trim_left="1" trim_top="2" trim_right="3" trim_bottom="4"' "$tmp_dir/out.xml" "$convert_bin" --transform css < "$layout_file" > "$tmp_dir/out.css" -grep -Fq '.sprite-1 {' "$tmp_dir/out.css" +grep -Fq '.sprite-b {' "$tmp_dir/out.css" grep -q '^ background-position: -16px -0px;$' "$tmp_dir/out.css" -custom_transform="$tmp_dir/custom.transform" +# ── custom transform (Jsonnet) ─────────────────────────────────────────────── +custom_transform="$tmp_dir/custom.jsonnet" cat > "$custom_transform" <<'CUSTOM' -[meta] -name=custom -[/meta] - -[header] -BEGIN {{atlas_width}}x{{atlas_height}} count={{sprite_count}} -[/header] - -[sprites] - [sprite] -{{index}}|{{path}}|{{x}},{{y}} {{w}}x{{h}} rotated={{rotated}} - [/sprite] -[/sprites] - -[separator] -; -[/separator] - -[footer] - -END -[/footer] +local sprat = std.extVar("sprat"); +local sprite_line(s) = + "" + s.index + "|" + s.path + "|" + s.x + "," + s.y + " " + s.w + "x" + s.h + " rotated=" + s.rotated; +{ + name: "custom", + extension: "", + content: + "BEGIN " + sprat.atlas_width + "x" + sprat.atlas_height + " count=" + sprat.sprite_count + "\n" + + std.join("\n;\n", [sprite_line(s) for s in sprat.sprites]) + "\n\nEND\n", +} CUSTOM "$convert_bin" --transform "$(fix_path "$custom_transform")" < "$layout_file" > "$tmp_dir/out.custom" @@ -97,6 +87,24 @@ grep -q '^BEGIN 64x32 count=2' "$tmp_dir/out.custom" grep -q '0|./frames/a.png|0,0 16x16 rotated=false' "$tmp_dir/out.custom" grep -q '1|./frames/b.png|16,0 8x8 rotated=true' "$tmp_dir/out.custom" +# ── source_size transform (Jsonnet) ───────────────────────────────────────── +source_size_transform="$tmp_dir/source_size.jsonnet" +cat > "$source_size_transform" <<'SRCSIZE' +local sprat = std.extVar("sprat"); +local sprite_line(s) = "" + s.index + "|" + s.source_w + "x" + s.source_h + "|" + s.has_trim; +{ + name: "source_size", + extension: "", + content: std.join("\n", [sprite_line(s) for s in sprat.sprites]) + "\n", +} +SRCSIZE + +"$convert_bin" --transform "$(fix_path "$source_size_transform")" < "$layout_file" > "$tmp_dir/out.source_size" +# sprite a: no trim, source size equals packed size (16x16) +grep -q '0|16x16|false' "$tmp_dir/out.source_size" +# sprite b: trim_left=1 trim_top=2 trim_right=3 trim_bottom=4, packed 8x8 => source 12x14 +grep -q '1|12x14|true' "$tmp_dir/out.source_size" + markers_file="$tmp_dir/markers.txt" cat > "$markers_file" <<'MARKERS' path "./frames/a.png" @@ -115,26 +123,39 @@ animation "idle" - frame 1 ANIMS -extras_transform="$tmp_dir/extras.transform" +# ── extras transform (Jsonnet) ─────────────────────────────────────────────── +extras_transform="$tmp_dir/extras.jsonnet" cat > "$extras_transform" <<'EXTRAS' -[meta] -name=extras -[/meta] - -[header] -markers={{has_markers}} animations={{has_animations}} -markers_path={{markers_path}} -animations_path={{animations_path}} -marker_count={{marker_count}} -animation_count={{animation_count}} - -[/header] - -[sprites] - [sprite] -{{index}}|{{name}}|{{path}}|{{sprite_markers_count}}|{{markers_json}} - [/sprite] -[/sprites] +local sprat = std.extVar("sprat"); + +local fmt_marker(m) = + '{"name":' + std.manifestJson(m.name) + ',"type":' + std.manifestJson(m.type) + + ',"x":' + m.x + ',"y":' + m.y + + (if m.type == "circle" then ',"radius":' + m.radius else "") + + (if m.type == "rectangle" then ',"w":' + m.w + ',"h":' + m.h else "") + + (if m.type == "polygon" then + ',"vertices":[' + std.join(",", ['{"x":' + v.x + ',"y":' + v.y + '}' for v in m.vertices]) + ']' + else "") + + "}"; + +local fmt_markers_json(markers) = "[" + std.join(",", [fmt_marker(m) for m in markers]) + "]"; + +local sprite_line(s) = + "" + s.index + "|" + s.name + "|" + s.path + "|" + + std.length(s.markers) + "|" + fmt_markers_json(s.markers); + +local header = + "markers=" + sprat.has_markers + " animations=" + sprat.has_animations + "\n" + + "markers_path=" + sprat.markers_path + "\n" + + "animations_path=" + sprat.animations_path + "\n" + + "marker_count=" + sprat.marker_count + "\n" + + "animation_count=" + sprat.animation_count + "\n\n"; + +{ + name: "extras", + extension: "", + content: header + std.join("", [sprite_line(s) + "\n" for s in sprat.sprites]), +} EXTRAS "$convert_bin" --transform "$(fix_path "$extras_transform")" --markers "$(fix_path "$markers_file")" --animations "$(fix_path "$animations_file")" < "$layout_file" > "$tmp_dir/out.extras" @@ -146,94 +167,42 @@ grep -q '^animation_count=2$' "$tmp_dir/out.extras" grep -Fq '0|a|./frames/a.png|2|[{"name":"hit","type":"point","x":3,"y":5},{"name":"hurt","type":"circle","x":6,"y":7,"radius":4}]' "$tmp_dir/out.extras" grep -Fq '1|b|./frames/b.png|1|[{"name":"foot","type":"rectangle","x":1,"y":2,"w":3,"h":4}]' "$tmp_dir/out.extras" -iter_transform="$tmp_dir/iter.transform" +# ── iter transform (Jsonnet) ───────────────────────────────────────────────── +iter_transform="$tmp_dir/iter.jsonnet" cat > "$iter_transform" <<'ITER' -[meta] -name=iter -[/meta] - -[header] -BEGIN - -[/header] - -[if_markers] -M_ON - -[/if_markers] - -[markers_header] -M_BEGIN - -[/markers_header] - -[markers] - [marker] -M{{marker_index}}={{marker_name}}@{{marker_sprite_index}}:{{marker_sprite_name}} - - [/marker] -[/markers] - -[markers_separator] -| -[/markers_separator] - -[markers_footer] -M_END - -[/markers_footer] - -[if_no_markers] -M_EMPTY - -[/if_no_markers] - -[sprites] - [sprite] -S{{index}}={{path}} - - [/sprite] -[/sprites] - -[separator] -; - -[/separator] - -[if_animations] -A_ON - -[/if_animations] - -[animations_header] -A_BEGIN - -[/animations_header] - -[animations] - [animation] -A{{animation_index}}={{animation_name}}:[{{sprite_indexes}}] - - [/animation] -[/animations] - -[animations_separator] -| -[/animations_separator] - -[animations_footer] -A_END - -[/animations_footer] - -[if_no_animations] -A_EMPTY - -[/if_no_animations] - -[footer] -END -[/footer] +local sprat = std.extVar("sprat"); + +local markers_content = + if sprat.has_markers then + "M_ON\n\nM_BEGIN\n\n" + + std.join("|", [ + "M" + m.index + "=" + m.name + "@" + m.sprite_index + ":" + m.sprite_name + "\n" + for m in sprat.markers + ]) + + "M_END\n\n" + else + "M_EMPTY\n\n"; + +local sprites_content = + std.join("\n;\n", ["S" + s.index + "=" + s.path for s in sprat.sprites]) + "\n\n"; + +local anims_content = + if sprat.has_animations then + "A_ON\n\nA_BEGIN\n\n" + + std.join("|", [ + "A" + a.index + "=" + a.name + ":[" + + std.join(",", ["" + idx for idx in a.frame_indices]) + "]\n" + for a in sprat.animations + ]) + + "A_END\n\n" + else + "A_EMPTY\n\n"; + +{ + name: "iter", + extension: "", + content: "BEGIN\n\n" + markers_content + sprites_content + anims_content + "END\n", +} ITER "$convert_bin" --transform "$(fix_path "$iter_transform")" --markers "$(fix_path "$markers_file")" --animations "$(fix_path "$animations_file")" < "$layout_file" > "$tmp_dir/out.iter.full" @@ -277,66 +246,54 @@ fi grep -q '"animations": \[' "$tmp_dir/out.builtin.json" grep -q '"sprites": \[' "$tmp_dir/out.builtin.json" grep -q '"name": "a"' "$tmp_dir/out.builtin.json" -grep -q '"markers": \[{"name":"hit","type":"point","x":3,"y":5},{"name":"hurt","type":"circle","x":6,"y":7,"radius":4}\]' "$tmp_dir/out.builtin.json" +# Marker fields appear on separate lines in pretty-printed JSON output +grep -q '"name": "hit"' "$tmp_dir/out.builtin.json" +grep -q '"type": "point"' "$tmp_dir/out.builtin.json" +grep -q '"radius": 4' "$tmp_dir/out.builtin.json" grep -q '"name": "run"' "$tmp_dir/out.builtin.json" grep -q '"fps": 8' "$tmp_dir/out.builtin.json" -grep -q '"sprite_indexes": \[0,1\]' "$tmp_dir/out.builtin.json" -if grep -q '"index":' "$tmp_dir/out.builtin.json"; then - echo "builtin json transform should not include index fields in sprite/animation objects" >&2 - exit 1 -fi -atlases_line="$(grep -n '"atlases": \[' "$tmp_dir/out.builtin.json" | head -n1 | cut -d: -f1)" -animations_line="$(grep -n '"animations": \[' "$tmp_dir/out.builtin.json" | head -n1 | cut -d: -f1)" -if [ "$animations_line" -le "$atlases_line" ]; then - echo "animations section must be after atlases in json transform" >&2 - exit 1 +# sprite_indexes and sprite_names are pretty-printed arrays; check field presence and values +grep -q '"sprite_indexes"' "$tmp_dir/out.builtin.json" +grep -q '"sprite_names"' "$tmp_dir/out.builtin.json" +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +run = next(a for a in d['animations'] if a['name'] == 'run') +assert run['sprite_indexes'] == [0, 1], 'sprite_indexes mismatch' +assert run['sprite_names'] == ['a', 'b'], 'sprite_names mismatch' +a_sp = next(s for s in d['sprites'] if s['name'] == 'a') +hit = next(m for m in a_sp['markers'] if m['name'] == 'hit') +assert hit['type'] == 'point' and hit['x'] == 3 and hit['y'] == 5, 'hit marker mismatch' +hurt = next(m for m in a_sp['markers'] if m['name'] == 'hurt') +assert hurt['type'] == 'circle' and hurt['x'] == 6 and hurt['y'] == 7 and hurt['radius'] == 4, 'hurt marker mismatch' +" "$(fix_path "$tmp_dir/out.builtin.json")" fi +# Both atlases and animations sections must be present (alphabetical order puts animations first) +grep -q '"atlases"' "$tmp_dir/out.builtin.json" +grep -q '"animations"' "$tmp_dir/out.builtin.json" -json_auto_escape_transform="$tmp_dir/json_auto_escape.transform" +# ── JSON auto-escape transform (Jsonnet) ───────────────────────────────────── +json_auto_escape_transform="$tmp_dir/json_auto_escape.jsonnet" cat > "$json_auto_escape_transform" <<'JSONAUTO' -[meta] -name=json_auto_escape -extension=.json -[/meta] - -[header] -{"markers":[ -[/header] - -[sprites] - [sprite] -{"name":"{{name}}","path":"{{path}}"} - [/sprite] -[/sprites] - -[separator] -, -[/separator] - -[if_markers] -[/if_markers] - -[markers] - [marker] -{"name":"{{marker_name}}","type":"{{marker_type}}"} - [/marker] -[/markers] - -[markers_separator] -, -[/markers_separator] - -[if_no_markers] -],"sprites":[ -[/if_no_markers] - -[markers_footer] -],"sprites":[ -[/markers_footer] - -[footer] -]} -[/footer] +local sprat = std.extVar("sprat"); + +local sprite_part(s) = + '{"name":' + std.manifestJson(s.name) + ',"path":' + std.manifestJson(s.path) + '}'; + +local marker_part(m) = + '{"name":' + std.manifestJson(m.name) + ',"type":' + std.manifestJson(m.type) + '}'; + +{ + name: "json_auto_escape", + extension: ".json", + content: + '{"markers":[\n' + + std.join(",\n", [marker_part(m) for m in sprat.markers]) + + '\n],"sprites":[\n' + + std.join(",\n", [sprite_part(s) for s in sprat.sprites]) + + '\n]}', +} JSONAUTO markers_quotes_file="$tmp_dir/markers.quotes.txt" @@ -355,4 +312,168 @@ grep -Fq '"name":"a\"q"' "$tmp_dir/out.json.autoescape" grep -Fq '"path":"./frames/a\"q.png"' "$tmp_dir/out.json.autoescape" grep -Fq '"name":"hit\"zone"' "$tmp_dir/out.json.autoescape" +# --- Animation alias test --- +animations_alias_file="$tmp_dir/animations_alias.txt" +cat > "$animations_alias_file" <<'ALIASANIMS' +animation "run" +- frame "./frames/a.png" +- frame "b" +animation "idle" +- frame 1 +animation "run-alias" alias "run" flip h +ALIASANIMS + +"$convert_bin" --transform json --animations "$(fix_path "$animations_alias_file")" < "$layout_file" > "$tmp_dir/out.alias.json" +grep -q '"name": "run-alias"' "$tmp_dir/out.alias.json" +grep -q '"alias": "run"' "$tmp_dir/out.alias.json" +grep -q '"flip": "h"' "$tmp_dir/out.alias.json" +if grep -q '"flip": "v' "$tmp_dir/out.alias.json"; then + echo "alias with h-only flip should not have v in flip value" >&2 + exit 1 +fi +# With pretty-printed JSON, use python3 to validate alias vs regular animation fields +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +alias = next(a for a in d['animations'] if a['name'] == 'run-alias') +assert 'fps' not in alias, 'alias entry should not have fps field' +assert 'sprite_indexes' not in alias, 'alias entry should not have sprite_indexes field' +run = next(a for a in d['animations'] if a['name'] == 'run') +assert run['fps'] == 8, 'run fps mismatch' +assert run['sprite_indexes'] == [0, 1], 'run sprite_indexes mismatch' +" "$(fix_path "$tmp_dir/out.alias.json")" +fi +# regular animations unaffected +grep -q '"name": "run"' "$tmp_dir/out.alias.json" +grep -q '"fps": 8' "$tmp_dir/out.alias.json" +grep -q '"sprite_indexes"' "$tmp_dir/out.alias.json" + +# ── Aseprite non-contiguous animation (consecutive_runs) ───────────────────── +noncontig_layout="$tmp_dir/noncontig.txt" +cat > "$noncontig_layout" <<'NCLAYOUT' +atlas 128,16 +scale 1 +sprite "f0.png" 0,0 16,16 +sprite "f1.png" 16,0 16,16 +sprite "f2.png" 32,0 16,16 +sprite "f3.png" 48,0 16,16 +sprite "f4.png" 64,0 16,16 +NCLAYOUT + +noncontig_anims="$tmp_dir/noncontig_anims.txt" +cat > "$noncontig_anims" <<'NCANIMS' +animation "jump" +- frame "f0.png" +- frame "f2.png" +- frame "f4.png" +animation "walk" +- frame "f0.png" +- frame "f1.png" +- frame "f2.png" +NCANIMS + +"$convert_bin" --transform aseprite --animations "$(fix_path "$noncontig_anims")" < "$noncontig_layout" > "$tmp_dir/out.aseprite.nc.json" +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +tags = d['meta']['frameTags'] +jump_tags = [t for t in tags if t['name'] == 'jump'] +assert len(jump_tags) == 3, 'jump: expected 3 frameTags, got ' + str(len(jump_tags)) +assert jump_tags[0]['from'] == 0 and jump_tags[0]['to'] == 0, 'jump tag 0 mismatch' +assert jump_tags[1]['from'] == 2 and jump_tags[1]['to'] == 2, 'jump tag 1 mismatch' +assert jump_tags[2]['from'] == 4 and jump_tags[2]['to'] == 4, 'jump tag 2 mismatch' +walk_tags = [t for t in tags if t['name'] == 'walk'] +assert len(walk_tags) == 1, 'walk: expected 1 frameTag, got ' + str(len(walk_tags)) +assert walk_tags[0]['from'] == 0 and walk_tags[0]['to'] == 2, 'walk tag mismatch' +" "$(fix_path "$tmp_dir/out.aseprite.nc.json")" +fi + +# ── Multi-atlas (multipack) layout ─────────────────────────────────────────── +multipack_layout="$tmp_dir/multipack.txt" +cat > "$multipack_layout" <<'MPLAYOUT' +multipack true +atlas 32,32 +scale 1 +sprite "p0.png" 0,0 16,16 +atlas 32,32 +sprite "p1.png" 0,0 16,16 +MPLAYOUT + +"$convert_bin" --transform json --atlas 'atlas_%d.png' < "$multipack_layout" > "$tmp_dir/out.multipack.json" +grep -q '"multipack": true' "$tmp_dir/out.multipack.json" +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +assert d['multipack'] == True, 'multipack should be true' +assert len(d['atlases']) == 2, 'expected 2 atlases, got ' + str(len(d['atlases'])) +p0 = next(s for s in d['sprites'] if s['name'] == 'p0') +p1 = next(s for s in d['sprites'] if s['name'] == 'p1') +assert p0['atlas_index'] == 0, 'p0 should be atlas_index 0, got ' + str(p0['atlas_index']) +assert p1['atlas_index'] == 1, 'p1 should be atlas_index 1, got ' + str(p1['atlas_index']) +" "$(fix_path "$tmp_dir/out.multipack.json")" +fi + +# ── Empty animations file ───────────────────────────────────────────────────── +empty_anims_file="$tmp_dir/empty_anims.txt" +cat > "$empty_anims_file" <<'EMPTYANIMS' +fps 12 +# no animation definitions follow +EMPTYANIMS + +"$convert_bin" --transform "$(fix_path "$iter_transform")" --animations "$(fix_path "$empty_anims_file")" < "$layout_file" > "$tmp_dir/out.iter.emptyanims" +grep -q '^A_EMPTY$' "$tmp_dir/out.iter.emptyanims" +if grep -q '^A_ON$' "$tmp_dir/out.iter.emptyanims"; then + echo "empty animations file should not activate animation branch" >&2 + exit 1 +fi +"$convert_bin" --transform json --animations "$(fix_path "$empty_anims_file")" < "$layout_file" > "$tmp_dir/out.json.emptyanims" +if grep -q '"animations"' "$tmp_dir/out.json.emptyanims"; then + echo "empty animations file should not produce animations key in JSON output" >&2 + exit 1 +fi +grep -q '"sprites"' "$tmp_dir/out.json.emptyanims" + +# --- --output-dir group mode test --- +# Create two lightweight group transforms in the active transforms directory. +transforms_dir="$("$convert_bin" --transforms-dir)" +grp_a="$transforms_dir/tstsuite.txt.jsonnet" +grp_b="$transforms_dir/tstsuite.json.jsonnet" +trap 'rm -rf "$tmp_dir"; rm -f "$grp_a" "$grp_b"' EXIT + +cat > "$grp_a" <<'GRPA' +local sprat = std.extVar("sprat"); +{ + name: "tstsuite_txt", + extension: ".txt", + content: "stem=" + sprat.output_stem + "\n" + + std.join("\n", [s.name for s in sprat.sprites]) + "\n", +} +GRPA + +cat > "$grp_b" <<'GRPB' +local sprat = std.extVar("sprat"); +{ + name: "tstsuite_json", + extension: ".json", + content: '{"stem":"' + sprat.output_stem + '","sprites":[' + + std.join(",", ['"' + s.name + '"' for s in sprat.sprites]) + ']}', +} +GRPB + +group_out="$tmp_dir/group_out" +"$convert_bin" --transform tstsuite --output-dir "$(fix_path "$group_out")" < "$layout_file" +test -f "$group_out/txt.txt" +test -f "$group_out/json.json" +grep -q 'stem=txt' "$group_out/txt.txt" +grep -q '"stem":"json"' "$group_out/json.json" + +# --output-dir single mode: write to file using stem-derived name +single_out="$tmp_dir/single_out" +"$convert_bin" --transform tstsuite.json --output-dir "$(fix_path "$single_out")" < "$layout_file" +test -f "$single_out/json.json" +grep -q '"stem":"json"' "$single_out/json.json" + echo "convert_test.sh: ok" diff --git a/tests/output_pattern_test.sh b/tests/output_pattern_test.sh index aa7988e..4183657 100644 --- a/tests/output_pattern_test.sh +++ b/tests/output_pattern_test.sh @@ -26,49 +26,68 @@ scale 1 LAYOUT # spratconvert: valid integer substitution and escaped percent. -"$spratconvert_bin" --transform json --output 'atlas_%d%%.png' < "$layout_multi" > "$tmp_dir/convert_valid.json" +"$spratconvert_bin" --transform json --atlas 'atlas_%d%%.png' < "$layout_multi" > "$tmp_dir/convert_valid.json" grep -q '"path": "atlas_0%.png"' "$tmp_dir/convert_valid.json" grep -q '"path": "atlas_1%.png"' "$tmp_dir/convert_valid.json" +# spratconvert: backward compatibility for --output +"$spratconvert_bin" --transform json --output 'compat_%d.png' < "$layout_multi" > "$tmp_dir/convert_compat.json" +grep -q '"path": "compat_0.png"' "$tmp_dir/convert_compat.json" + +# spratconvert: backward compatibility for -o +"$spratconvert_bin" --transform json -o 'short_%d.png' < "$layout_multi" > "$tmp_dir/convert_short.json" +grep -q '"path": "short_0.png"' "$tmp_dir/convert_short.json" + +# spratconvert: new short flag -a +"$spratconvert_bin" --transform json -a 'atlas_a_%d.png' < "$layout_multi" > "$tmp_dir/convert_atlas_a.json" +grep -q '"path": "atlas_a_0.png"' "$tmp_dir/convert_atlas_a.json" + # spratconvert: reject unsupported placeholders. -if "$spratconvert_bin" --transform json --output 'atlas_%s.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_bad_spec.err"; then - echo "spratconvert accepted unsupported output placeholder %s" >&2 +if "$spratconvert_bin" --transform json --atlas 'atlas_%s.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_bad_spec.err"; then + echo "spratconvert accepted unsupported atlas placeholder %s" >&2 exit 1 fi grep -q "Invalid output pattern" "$tmp_dir/convert_bad_spec.err" # spratconvert: reject missing %d for multi-atlas layouts. -if "$spratconvert_bin" --transform json --output 'atlas.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_no_placeholder.err"; then - echo "spratconvert accepted output pattern without %d for multi-atlas layout" >&2 +if "$spratconvert_bin" --transform json --atlas 'atlas.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_no_placeholder.err"; then + echo "spratconvert accepted atlas pattern without %d for multi-atlas layout" >&2 exit 1 fi grep -q "must include %d" "$tmp_dir/convert_no_placeholder.err" # spratconvert: single atlas can use a literal filename. -"$spratconvert_bin" --transform json --output 'atlas.png' < "$layout_single" > "$tmp_dir/convert_single.json" +"$spratconvert_bin" --transform json --atlas 'atlas.png' < "$layout_single" > "$tmp_dir/convert_single.json" grep -q '"path": "atlas.png"' "$tmp_dir/convert_single.json" # spratpack: valid integer substitution writes both atlas files. ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%d.png' < "$layout_multi" + "$spratpack_bin" --atlas 'atlas_%d.png' < "$layout_multi" ) test -f "$tmp_dir/atlas_0.png" test -f "$tmp_dir/atlas_1.png" +# spratpack: backward compatibility for --output +( + cd "$tmp_dir" + "$spratpack_bin" --output 'compat_pack_%d.png' < "$layout_multi" +) +test -f "$tmp_dir/compat_pack_0.png" + # spratpack: escaped percent is supported. ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%d%%.png' < "$layout_single" + "$spratpack_bin" --atlas 'atlas_%d%%.png' < "$layout_single" ) test -f "$tmp_dir/atlas_0%.png" # spratpack: reject unsupported placeholders. if ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%s.png' < "$layout_single" + "$spratpack_bin" --atlas 'atlas_%s.png' < "$layout_single" ) > /dev/null 2> "$tmp_dir/pack_bad_spec.err"; then - echo "spratpack accepted unsupported output placeholder %s" >&2 + echo "spratpack accepted unsupported atlas placeholder %s" >&2 exit 1 fi grep -q "Invalid output pattern" "$tmp_dir/pack_bad_spec.err" @@ -76,9 +95,9 @@ grep -q "Invalid output pattern" "$tmp_dir/pack_bad_spec.err" # spratpack: reject missing %d for multi-atlas outputs. if ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas.png' < "$layout_multi" + "$spratpack_bin" --atlas 'atlas.png' < "$layout_multi" ) > /dev/null 2> "$tmp_dir/pack_no_placeholder.err"; then - echo "spratpack accepted output pattern without %d for multi-atlas layout" >&2 + echo "spratpack accepted atlas pattern without %d for multi-atlas layout" >&2 exit 1 fi grep -q "must include %d" "$tmp_dir/pack_no_placeholder.err" @@ -86,9 +105,9 @@ grep -q "must include %d" "$tmp_dir/pack_no_placeholder.err" # spratpack: reject trailing %. if ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%' < "$layout_single" + "$spratpack_bin" --atlas 'atlas_%' < "$layout_single" ) > /dev/null 2> "$tmp_dir/pack_trailing_percent.err"; then - echo "spratpack accepted trailing % in output pattern" >&2 + echo "spratpack accepted trailing % in atlas pattern" >&2 exit 1 fi grep -q "trailing '%'" "$tmp_dir/pack_trailing_percent.err" diff --git a/tests/profile_resolution_test.sh b/tests/profile_resolution_test.sh index a2eee8c..630cee1 100644 --- a/tests/profile_resolution_test.sh +++ b/tests/profile_resolution_test.sh @@ -100,10 +100,8 @@ assert_matches_explicit() { fi } -cleanup() { - rm -rf "$tmp_dir" -} -trap cleanup EXIT +exe_dir="$(dirname "$spratlayout_bin")" +exe_cfg="$exe_dir/spratprofiles.cfg" appdata_root="$tmp_dir/appdata" userprofile_root="$tmp_dir/userprofile" @@ -111,23 +109,38 @@ home_root="$tmp_dir/home" mkdir -p "$appdata_root" "$userprofile_root" "$home_root" appdata_cfg="$appdata_root/sprat/spratprofiles.cfg" -cwd_cfg="$tmp_dir/spratprofiles.cfg" export APPDATA="$(cygpath -m "$appdata_root")" export USERPROFILE="$(cygpath -m "$userprofile_root")" export HOME="$(cygpath -m "$home_root")" -# 1) User config (APPDATA) should be preferred when present. -write_cfg "$appdata_cfg" 3 -write_cfg "$cwd_cfg" 7 -assert_matches_explicit "$appdata_cfg" "appdata" +# Save and remove any existing exe-dir config so we can control it during tests. +exe_cfg_backed_up=0 +if [ -f "$exe_cfg" ]; then + cp "$exe_cfg" "${exe_cfg}.bak" + exe_cfg_backed_up=1 + rm -f "$exe_cfg" +fi + +cleanup() { + rm -rf "$tmp_dir" + if [ "$exe_cfg_backed_up" -eq 1 ]; then + mv "${exe_cfg}.bak" "$exe_cfg" + fi +} +trap cleanup EXIT -# 2) Current directory config should be used when user config is absent. -rm -f "$appdata_cfg" -write_cfg "$cwd_cfg" 7 -assert_matches_explicit "$cwd_cfg" "cwd" +# 1) Exe-dir config should be preferred over user config (APPDATA) when present. +write_cfg "$exe_cfg" 2 +write_cfg "$appdata_cfg" 3 +assert_matches_explicit "$exe_cfg" "exe_dir" -# 3) Precedence: APPDATA > current directory. -write_cfg "$cwd_cfg" 9 +# 2) User config (APPDATA) should be used when exe-dir config is absent. +rm -f "$exe_cfg" write_cfg "$appdata_cfg" 5 -assert_matches_explicit "$appdata_cfg" "precedence" +assert_matches_explicit "$appdata_cfg" "appdata" + +# 3) Precedence: exe-dir > APPDATA. +write_cfg "$appdata_cfg" 7 +write_cfg "$exe_cfg" 9 +assert_matches_explicit "$exe_cfg" "precedence" diff --git a/tests/recursive_dir_test.sh b/tests/recursive_dir_test.sh new file mode 100644 index 0000000..484269c --- /dev/null +++ b/tests/recursive_dir_test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: recursive_dir_test.sh " >&2 + exit 1 +fi + +spratlayout_bin="$1" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +# Path conversion for Windows +if [[ "$(uname)" == MINGW* || "$(uname)" == MSYS* ]]; then + tmp_dir_win="$(cygpath -m "$tmp_dir")" + fix_path() { + echo "${1/$tmp_dir/$tmp_dir_win}" + } +else + fix_path() { + echo "$1" + } +fi + +mkdir -p "$tmp_dir/frames/subdir" + +create_png() { + local path="$1" + local w="$2" + local h="$3" + # Create a minimal valid PNG using printf if ImageMagick is not available + # Or just use spratconvert if we had it, but we want to be independent if possible. + # Actually, the repo has a lot of PNGs. Let's just copy one if it exists. + local source_png + source_png=$(find . -name "*.png" | head -n 1) + if [ -n "$source_png" ]; then + cp "$source_png" "$path" + elif command -v magick >/dev/null; then + magick -size "${w}x${h}" xc:red "$path" + elif command -v convert >/dev/null; then + convert -size "${w}x${h}" xc:red "$path" + else + echo "Error: ImageMagick not found and no source PNG found in repo" >&2 + exit 1 + fi +} + +create_png "$tmp_dir/frames/top.png" 10 10 +create_png "$tmp_dir/frames/subdir/nested.png" 10 10 + +# Helper to extract paths from spratlayout output +extract_sprite_paths() { + grep "^sprite " | sed -E 's/^sprite ("[^"]*").*$/\1/' +} + +echo "Running recursive directory test..." +OUTPUT=$("$spratlayout_bin" "$(fix_path "$tmp_dir/frames")") + +if ! echo "$OUTPUT" | grep -q "nested.png"; then + echo "FAILED: Did not find nested.png in subdirectory" + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi + +if ! echo "$OUTPUT" | grep -q "top.png"; then + echo "FAILED: Did not find top.png in top-level directory" + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi + +echo "Recursive directory test passed!" diff --git a/tests/sort_behavior_test.sh b/tests/sort_behavior_test.sh index 2d740c7..3c43831 100755 --- a/tests/sort_behavior_test.sh +++ b/tests/sort_behavior_test.sh @@ -45,7 +45,7 @@ create_png "$tmp_dir/frames/a.png" 10 10 create_png "$tmp_dir/frames/c.png" 20 20 create_png "$tmp_dir/frames/d.png" 30 30 -# Natural sorting tests +# Natural sorting tests (used with --sort name) create_png "$tmp_dir/frames/walk_2.png" 10 10 create_png "$tmp_dir/frames/walk_10.png" 10 10 create_png "$tmp_dir/frames/walk 2.png" 10 10 @@ -60,24 +60,19 @@ extract_sprite_paths() { grep "^sprite " | sed -E 's/^sprite ("[^"]*").*$/\1/' } -# Test 1: Default behavior for directory (should be naturally sorted by name) -echo "Test 1: Default behavior (natural name sort)" -"$spratlayout_bin" "$(fix_path "$tmp_dir/frames")" --mode fast | extract_sprite_paths | sed "s|$(fix_path "$tmp_dir/frames/")||g" > "$tmp_dir/out_default.txt" - -cat > "$tmp_dir/expected_subset.txt" < "$tmp_dir/out_default.txt" +# In fast mode sprites are height-sorted: b(40) > d(30) > c(20) > a(10) +cat > "$tmp_dir/expected_default.txt" < "$tmp_dir/out_walk_only.txt" - -if ! diff -u "$tmp_dir/expected_subset.txt" "$tmp_dir/out_walk_only.txt"; then - echo "FAILED: Default behavior should be natural name sort" +if ! diff -u "$tmp_dir/expected_default.txt" "$tmp_dir/out_default.txt"; then + echo "FAILED: Default behavior should allow optimization (height sort in FAST mode), not sort by name" exit 1 fi @@ -90,7 +85,7 @@ $(fix_path "$tmp_dir/frames/b.png") $(fix_path "$tmp_dir/frames/d.png") $(fix_path "$tmp_dir/frames/a.png") EOF -"$spratlayout_bin" "$(fix_path "$list_file")" --mode fast --sort none | extract_sprite_paths | sed "s|$(fix_path "$tmp_dir/frames/")||g" > "$tmp_dir/out_none.txt" +"$spratlayout_bin" "$(fix_path "$list_file")" --mode fast --sort none | extract_sprite_paths | sed -e "s|$(fix_path "$tmp_dir/frames/")||g" -e "s|frames/||g" > "$tmp_dir/out_none.txt" # Height order: b (40), d (30), c (20), a (10) cat > "$tmp_dir/expected_none.txt" < "$list_file_default" < "$tmp_dir/out_list_default.txt" +"$spratlayout_bin" "$(fix_path "$list_file_default")" --mode fast | extract_sprite_paths | sed -e "s|$(fix_path "$tmp_dir/frames/")||g" -e "s|frames/||g" > "$tmp_dir/out_list_default.txt" # Height order: b (40), d (30), c (20), a (10) cat > "$tmp_dir/expected_list_default.txt" < "$tmp_dir/expected_list_default.txt" < "$list_file_name" < "$tmp_dir/out_name.txt" +"$spratlayout_bin" "$(fix_path "$list_file_name")" --mode fast --sort name | extract_sprite_paths | sed -e "s|$(fix_path "$tmp_dir/frames/")||g" -e "s|frames/||g" > "$tmp_dir/out_name.txt" cat > "$tmp_dir/expected_name.txt" < "$tmp_dir/out_walk.txt" + +cat > "$tmp_dir/expected_walk.txt" <" >&2 + exit 1 +fi + +spratlayout_bin="$1" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +if command -v magick >/dev/null; then + create_image_cmd="magick" +elif command -v convert >/dev/null; then + create_image_cmd="convert" +else + echo "Error: ImageMagick not found" >&2 + exit 1 +fi + +mkdir -p "$tmp_dir/frames" +"$create_image_cmd" -size 8x8 xc:red "$tmp_dir/frames/a.png" +"$create_image_cmd" -size 8x8 xc:green "$tmp_dir/frames/b.png" +"$create_image_cmd" -size 8x8 xc:blue "$tmp_dir/frames/c.png" + +cat > "$tmp_dir/frames/.spratlayoutignore" <<'EOF' +# Persistently exclude this sprite from directory scans +exclude "b.png" +EOF + +dir_layout="$tmp_dir/dir_layout.txt" +"$spratlayout_bin" "$tmp_dir/frames" > "$dir_layout" + +if grep -q 'sprite "b\.png"' "$dir_layout"; then + echo "FAILED: directory scan should honor .spratlayoutignore" >&2 + cat "$dir_layout" >&2 + exit 1 +fi + +if ! grep -q 'sprite "a\.png"' "$dir_layout" || ! grep -q 'sprite "c\.png"' "$dir_layout"; then + echo "FAILED: directory scan omitted non-excluded sprites" >&2 + cat "$dir_layout" >&2 + exit 1 +fi + +cat > "$tmp_dir/frames.txt" <<'EOF' +root "frames" +exclude "c.png" +a.png +b.png +c.png +EOF + +list_layout="$tmp_dir/list_layout.txt" +"$spratlayout_bin" "$tmp_dir/frames.txt" > "$list_layout" + +if grep -q 'sprite "frames/c\.png"' "$list_layout"; then + echo "FAILED: list input should honor exclude directive" >&2 + cat "$list_layout" >&2 + exit 1 +fi + +if ! grep -q 'sprite "frames/a\.png"' "$list_layout" || ! grep -q 'sprite "frames/b\.png"' "$list_layout"; then + echo "FAILED: list input omitted non-excluded sprites" >&2 + cat "$list_layout" >&2 + exit 1 +fi + +echo "Spratlayout exclude test passed!" diff --git a/tests/unity_test.sh b/tests/unity_test.sh new file mode 100644 index 0000000..a722ff8 --- /dev/null +++ b/tests/unity_test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: unity_test.sh " >&2 + exit 1 +fi + +convert_bin="$1" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +layout_file="$tmp_dir/layout.txt" +cat > "$layout_file" <<'LAYOUT' +atlas 100,200 +scale 1 +sprite "player.png" 10,20 30,40 5,5 5,5 +LAYOUT + +markers_file="$tmp_dir/markers.txt" +cat > "$markers_file" <<'MARKERS' +path "player.png" +- marker "pivot" point 20,30 +MARKERS + +custom_transform="$tmp_dir/test.jsonnet" +cat > "$custom_transform" <<'CUSTOM' +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; +local sprite_line(s) = + "y=" + s.y + " unity_y=" + s.unity_y + + " pxn=" + lib.format_double(s.pivot_x_norm) + + " pyn=" + lib.format_double(s.pivot_y_norm) + + " pynr=" + lib.format_double(s.pivot_y_norm_raw) + + " nh=" + s.name_hash_decimal + + " nhh=" + s.name_hash_hex; +{ + name: "test", + extension: "", + content: std.join("\n", [sprite_line(s) for s in sprat.sprites]) + "\n", +} +CUSTOM + +"$convert_bin" --transform "$custom_transform" --markers "$markers_file" < "$layout_file" > "$tmp_dir/out.txt" + +grep -q "y=20" "$tmp_dir/out.txt" +grep -q "unity_y=140" "$tmp_dir/out.txt" +grep -q "pxn=0.5" "$tmp_dir/out.txt" +grep -q "pyn=0.4" "$tmp_dir/out.txt" +grep -q "pynr=0.6" "$tmp_dir/out.txt" +grep -q "nh=" "$tmp_dir/out.txt" +grep -q "nhh=" "$tmp_dir/out.txt" diff --git a/transforms/aseprite.jsonnet b/transforms/aseprite.jsonnet new file mode 100644 index 0000000..6941693 --- /dev/null +++ b/transforms/aseprite.jsonnet @@ -0,0 +1,46 @@ +// aseprite.jsonnet – Aseprite JSON Array sprite sheet format. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_obj(s) = { + filename: s.name, + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + duration: 100, +}; + +// Build frameTags from animations: each animation may be non-contiguous, so split into runs. +local frame_tag(anim) = + local runs = lib.consecutive_runs(anim.frame_indices); + [ + { name: anim.name, from: run.from, to: run.to, direction: "forward" } + for run in runs + ]; + +local frame_tags = std.flatMap(frame_tag, sprat.animations); + +local result = { + frames: [frame_obj(s) for s in sprat.sprites], + meta: { + app: "https://www.aseprite.org/", + version: "1.3", + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + frameTags: frame_tags, + layers: [], + slices: [], + }, +}; + +{ + name: "Aseprite", + description: "Aseprite JSON Array sprite sheet format (frameTags populated when animations are present)", + extension: ".json", + icon: "icons/aseprite-svgrepo-com.svg", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/css.jsonnet b/transforms/css.jsonnet new file mode 100644 index 0000000..bb07149 --- /dev/null +++ b/transforms/css.jsonnet @@ -0,0 +1,35 @@ +// css.jsonnet – CSS classes for web sprite rendering. +local sprat = std.extVar("sprat"); + +local sprite_css(s) = + '.sprite-' + s.name_css + ' {\n' + + (if s.atlas_path != "" then ' background-image: url(\'' + s.atlas_path + '\');\n' else "") + + ' background-position: -' + s.x + 'px -' + s.y + 'px;\n' + + ' width: ' + s.w + 'px;\n' + + ' height: ' + s.h + 'px;\n' + + ' /* source: ' + s.path + ' */\n' + + ' /* name: ' + s.name + ' */\n' + + ' /* atlas_index: ' + s.atlas_index + ' */\n' + + (if s.rotated then + ' transform: rotate(-90deg) translate(-100%, 0);\n transform-origin: top left;\n' + else "") + + '}\n'; + +local header = + ':root {\n' + + ' --atlas-width: ' + sprat.atlas_width + 'px;\n' + + ' --atlas-height: ' + sprat.atlas_height + 'px;\n' + + ' --atlas-scale: ' + sprat.scale + ';\n' + + '}\n\n' + + '.sprat-sprite {\n' + + ' background-repeat: no-repeat;\n' + + ' display: inline-block;\n' + + '}\n'; + +{ + name: "CSS", + description: "CSS classes for web sprite rendering", + extension: ".css", + icon: "icons/css-3-svgrepo-com.svg", + content: header + std.join("\n", [sprite_css(s) for s in sprat.sprites]), +} diff --git a/transforms/csv.jsonnet b/transforms/csv.jsonnet new file mode 100644 index 0000000..fcac808 --- /dev/null +++ b/transforms/csv.jsonnet @@ -0,0 +1,75 @@ +// csv.jsonnet – CSV rows for spreadsheets and data tools. +local sprat = std.extVar("sprat"); + +local csv_escape(s) = + local needs_quotes = std.length( + [c for c in std.stringChars(s) if c == '"' || c == ',' || c == '\n' || c == '\r'] + ) > 0; + if needs_quotes then + '"' + std.strReplace(s, '"', '""') + '"' + else + s; + +local marker_vertices_csv(verts) = + std.join("|", ["" + v.x + "," + v.y for v in verts]); + +local marker_json(m) = + '{"name":' + std.manifestJsonEx(m.name, "") + ',"type":"' + m.type + '"' + + ',"x":' + m.x + ',"y":' + m.y + + (if m.type == "circle" then ',"radius":' + m.radius else "") + + (if m.type == "rectangle" then ',"w":' + m.w + ',"h":' + m.h else "") + + (if m.type == "polygon" then + ',"vertices":[' + std.join(",", ['{"x":' + v.x + ',"y":' + v.y + '}' for v in m.vertices]) + ']' + else "") + + "}"; + +local markers_json_array(markers) = + "[" + std.join(",", [marker_json(m) for m in markers]) + "]"; + +local header = "index,name,path,atlas_index,atlas_path,x,y,w,h,pivot_x,pivot_y,trim_left,trim_top,trim_right,trim_bottom,marker_count,markers_json,rotation\n"; + +local sprite_row(s) = + "" + s.index + "," + + csv_escape(s.name) + "," + + csv_escape(s.path) + "," + + s.atlas_index + "," + + csv_escape(s.atlas_path) + "," + + s.x + "," + s.y + "," + s.w + "," + s.h + "," + + s.pivot_x + "," + s.pivot_y + "," + + s.trim_left + "," + s.trim_top + "," + s.trim_right + "," + s.trim_bottom + "," + + std.length(s.markers) + "," + + markers_json_array(s.markers) + "," + + (if s.rotated then "90" else "0") + "\n"; + +local marker_row(m) = + "marker," + m.index + "," + + csv_escape(m.name) + "," + + m.type + "," + + m.x + "," + m.y + "," + m.radius + "," + m.w + "," + m.h + "," + + marker_vertices_csv(m.vertices) + "," + + m.sprite_index + "," + + csv_escape(m.sprite_name) + "," + + csv_escape(m.sprite_path) + "\n"; + +local anim_row(a) = + if a.is_alias then + "animation," + a.index + "," + csv_escape(a.name) + ",alias," + + csv_escape(a.alias_source) + + (if a.flip != "" then "," + a.flip else "") + "\n" + else + "animation," + a.index + "," + csv_escape(a.name) + "," + a.fps + "," + + std.join("|", ["" + idx for idx in a.frame_indices]) + + (if a.flip != "" then "," + a.flip else "") + "\n"; + +local body = + std.join("", [sprite_row(s) for s in sprat.sprites]) + + std.join("", [marker_row(m) for m in sprat.markers]) + + std.join("", [anim_row(a) for a in sprat.animations]); + +{ + name: "CSV", + description: "CSV rows for spreadsheets and data tools", + extension: ".csv", + icon: "icons/csv-svgrepo-com.svg", + content: header + body, +} diff --git a/transforms/godot.jsonnet b/transforms/godot.jsonnet new file mode 100644 index 0000000..850357b --- /dev/null +++ b/transforms/godot.jsonnet @@ -0,0 +1,40 @@ +// godot.jsonnet – Godot-compatible JSON sprite sheet. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_obj(s) = { + name: s.name, + region: { x: s.x, y: s.y, w: s.w, h: s.h }, + margin: { left: s.trim_left, top: s.trim_top, right: s.trim_right, bottom: s.trim_bottom }, + source_size: { w: s.source_w, h: s.source_h }, + rotated: s.rotated, + pivot_offset: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +// Godot animations use from/to indices (first and last frame index). +local anim_obj(a) = { + name: a.name, + from: if std.length(a.frame_indices) > 0 then a.frame_indices[0] else 0, + to: if std.length(a.frame_indices) > 0 then a.frame_indices[std.length(a.frame_indices) - 1] else 0, + speed: a.fps, + loop: true, +}; + +local result = + { + image: sprat.atlas_path, + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: sprat.scale, + frames: [frame_obj(s) for s in sprat.sprites], + } + + (if sprat.has_animations then { + animations: [anim_obj(a) for a in sprat.animations], + } else {}); + +{ + name: "Godot", + description: "Godot-compatible JSON sprite sheet (load at runtime with AtlasTexture/SpriteFrames)", + extension: ".json", + icon: "icons/godot-svgrepo-com(1).svg", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/icons/aseprite-svgrepo-com.svg b/transforms/icons/aseprite-svgrepo-com.svg new file mode 100644 index 0000000..fc265f6 --- /dev/null +++ b/transforms/icons/aseprite-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/transforms/icons/css-3-svgrepo-com.svg b/transforms/icons/css-3-svgrepo-com.svg new file mode 100644 index 0000000..c1c985d --- /dev/null +++ b/transforms/icons/css-3-svgrepo-com.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/transforms/icons/csv-svgrepo-com.svg b/transforms/icons/csv-svgrepo-com.svg new file mode 100644 index 0000000..61ede9c --- /dev/null +++ b/transforms/icons/csv-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/transforms/icons/godot-svgrepo-com(1).svg b/transforms/icons/godot-svgrepo-com(1).svg new file mode 100644 index 0000000..934106d --- /dev/null +++ b/transforms/icons/godot-svgrepo-com(1).svg @@ -0,0 +1,2 @@ + +file_type_godot \ No newline at end of file diff --git a/transforms/icons/json-svgrepo-com.svg b/transforms/icons/json-svgrepo-com.svg new file mode 100644 index 0000000..23d03dc --- /dev/null +++ b/transforms/icons/json-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/transforms/icons/libgdx.svg b/transforms/icons/libgdx.svg new file mode 100644 index 0000000..fe73a92 --- /dev/null +++ b/transforms/icons/libgdx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/transforms/icons/phaser-logo.svg b/transforms/icons/phaser-logo.svg new file mode 100644 index 0000000..53fc230 --- /dev/null +++ b/transforms/icons/phaser-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/transforms/icons/plist.svg b/transforms/icons/plist.svg new file mode 100644 index 0000000..ccecadf --- /dev/null +++ b/transforms/icons/plist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/transforms/icons/unity-svgrepo-com.svg b/transforms/icons/unity-svgrepo-com.svg new file mode 100644 index 0000000..13122d0 --- /dev/null +++ b/transforms/icons/unity-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/transforms/icons/xml-svgrepo-com.svg b/transforms/icons/xml-svgrepo-com.svg new file mode 100644 index 0000000..38175c2 --- /dev/null +++ b/transforms/icons/xml-svgrepo-com.svg @@ -0,0 +1,2 @@ + +file_type_xml \ No newline at end of file diff --git a/transforms/json.jsonnet b/transforms/json.jsonnet new file mode 100644 index 0000000..dcaa890 --- /dev/null +++ b/transforms/json.jsonnet @@ -0,0 +1,41 @@ +// json.jsonnet – Generic JSON metadata format. +local sprat = std.extVar("sprat"); + +local sprite_obj(s) = { + name: s.name, + path: s.path, + atlas_index: s.atlas_index, + rect: { x: s.x, y: s.y, w: s.w, h: s.h }, + pivot: { x: s.pivot_x, y: s.pivot_y }, + trim: { left: s.trim_left, top: s.trim_top, right: s.trim_right, bottom: s.trim_bottom }, + markers: s.markers, + rotation: if s.rotated then 90 else 0, +}; + +local anim_obj(a) = + if a.is_alias then + { name: a.name, alias: a.alias_source } + + (if a.flip != "" then { flip: a.flip } else {}) + else + { name: a.name, fps: a.fps, sprite_indexes: a.frame_indices, sprite_names: [f.name for f in a.frames] } + + (if a.flip != "" then { flip: a.flip } else {}); + +local atlas_obj(at) = { width: at.width, height: at.height, path: at.path }; + +local result = { + multipack: sprat.multipack, + scale: sprat.scale, + extrude: sprat.extrude, + atlases: [atlas_obj(at) for at in sprat.atlases], + sprites: [sprite_obj(s) for s in sprat.sprites], +} + (if sprat.has_animations then { + animations: [anim_obj(a) for a in sprat.animations], +} else {}); + +{ + name: "JSON", + description: "JSON metadata for scripting and runtime loading", + extension: ".json", + icon: "icons/json-svgrepo-com.svg", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/libgdx.jsonnet b/transforms/libgdx.jsonnet new file mode 100644 index 0000000..92bf0c7 --- /dev/null +++ b/transforms/libgdx.jsonnet @@ -0,0 +1,27 @@ +// libgdx.jsonnet – LibGDX TextureAtlas format (.atlas). +local sprat = std.extVar("sprat"); + +local sprite_entry(s) = + s.name + "\n" + + " rotate: " + s.rotated + "\n" + + " xy: " + s.x + ", " + s.y + "\n" + + " size: " + s.w + ", " + s.h + "\n" + + " orig: " + s.source_w + ", " + s.source_h + "\n" + + " offset: " + s.trim_left + ", " + s.trim_bottom + "\n" + + " index: -1\n"; + +local atlas_block(at) = + at.path + "\n" + + "size: " + at.width + "," + at.height + "\n" + + "format: RGBA8888\n" + + "filter: Nearest,Nearest\n" + + "repeat: none\n\n" + + std.join("", [sprite_entry(s) for s in at.sprites]); + +{ + name: "LibGDX", + description: "LibGDX TextureAtlas format (.atlas); animation data is not part of this format", + extension: ".atlas", + icon: "icons/libgdx.svg", + content: std.join("", [atlas_block(at) for at in sprat.atlases]), +} diff --git a/transforms/phaser-anims.jsonnet b/transforms/phaser-anims.jsonnet new file mode 100644 index 0000000..281825f --- /dev/null +++ b/transforms/phaser-anims.jsonnet @@ -0,0 +1,23 @@ +// phaser-anims.jsonnet – Phaser 3 animation manager JSON. +local sprat = std.extVar("sprat"); + +local frame_ref(f) = { key: sprat.atlas_stem, frame: f.name }; + +local anim_obj(a) = { + key: a.name, + frameRate: a.fps, + repeat: -1, + frames: [frame_ref(f) for f in a.frames], +}; + +local result = { + anims: [anim_obj(a) for a in sprat.animations], +}; + +{ + name: "Phaser Animations", + description: "Phaser 3 animation manager JSON (load separately via this.anims.fromJSON(); requires --atlas so frame keys resolve to the correct texture)", + extension: ".json", + icon: "icons/phaser-logo.svg", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/phaser-array.jsonnet b/transforms/phaser-array.jsonnet new file mode 100644 index 0000000..77f7ab1 --- /dev/null +++ b/transforms/phaser-array.jsonnet @@ -0,0 +1,30 @@ +// phaser-array.jsonnet – Phaser 3 JSON Array atlas format. +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + filename: s.name, + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +local result = { + frames: [frame_obj(s) for s in sprat.sprites], + meta: { + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Phaser JSON Array", + description: "Phaser 3 atlas format (JSON Array, compatible with TexturePacker JSON Array output)", + extension: ".json", + icon: "icons/phaser-logo.svg", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/phaser-hash.jsonnet b/transforms/phaser-hash.jsonnet new file mode 100644 index 0000000..599efb0 --- /dev/null +++ b/transforms/phaser-hash.jsonnet @@ -0,0 +1,35 @@ +// phaser-hash.jsonnet – Phaser 3 JSON Hash atlas format. +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +local frames_obj = std.foldl( + function(acc, s) acc { [s.name]: frame_obj(s) }, + sprat.sprites, + {} +); + +local result = { + frames: frames_obj, + meta: { + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Phaser JSON Hash", + description: "Phaser 3 atlas format (JSON Hash, compatible with TexturePacker JSON Hash output)", + extension: ".json", + icon: "icons/phaser-logo.svg", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/plist.jsonnet b/transforms/plist.jsonnet new file mode 100644 index 0000000..70b326e --- /dev/null +++ b/transforms/plist.jsonnet @@ -0,0 +1,69 @@ +// plist.jsonnet – Cocos2d-x TextureAtlas plist format (format 2). +local sprat = std.extVar("sprat"); + +local xml_escape(s) = + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace(s, "&", "&"), + "<", "<"), + ">", ">"), + '"', """), + "'", "'"); + +local sprite_entry(s) = + local content_w = s.content_w; + local content_h = s.content_h; + local source_w = s.source_w; + local source_h = s.source_h; + local cx = std.floor((s.trim_left - s.trim_right) / 2); + local cy = std.floor((s.trim_bottom - s.trim_top) / 2); + local plist_frame = "{" + s.x + "," + s.y + "},{" + s.w + "," + s.h + "}"; + local plist_offset = "{" + cx + "," + cy + "}"; + local plist_source_color_rect = "{" + s.trim_left + "," + s.trim_top + "},{" + content_w + "," + content_h + "}"; + local plist_source_size = "{" + source_w + "," + source_h + "}"; + '\t\t' + xml_escape(s.name) + '\n' + + '\t\t\n' + + '\t\t\tframe\n' + + '\t\t\t' + plist_frame + '\n' + + '\t\t\toffset\n' + + '\t\t\t' + plist_offset + '\n' + + '\t\t\trotated\n' + + '\t\t\t' + (if s.rotated then '' else '') + '\n' + + '\t\t\tsourceColorRect\n' + + '\t\t\t' + plist_source_color_rect + '\n' + + '\t\t\tsourceSize\n' + + '\t\t\t' + plist_source_size + '\n' + + '\t\t\n'; + +local plist_atlas_size = "{" + sprat.atlas_width + "," + sprat.atlas_height + "}"; + +{ + name: "plist", + description: "Cocos2d-x TextureAtlas plist format (format 2)", + extension: ".plist", + icon: "icons/plist.svg", + content: + '\n' + + '\n' + + '\n' + + '\n' + + '\tframes\n' + + '\t\n\n' + + std.join("", [sprite_entry(s) for s in sprat.sprites]) + + '\t\n' + + '\tmetadata\n' + + '\t\n' + + '\t\tformat\n' + + '\t\t2\n' + + '\t\trealTextureFileName\n' + + '\t\t' + xml_escape(sprat.atlas_path) + '\n' + + '\t\tsize\n' + + '\t\t' + plist_atlas_size + '\n' + + '\t\ttextureFileName\n' + + '\t\t' + xml_escape(sprat.atlas_path) + '\n' + + '\t\n' + + '\n' + + '\n', +} diff --git a/transforms/sprat.libsonnet b/transforms/sprat.libsonnet new file mode 100644 index 0000000..9a48001 --- /dev/null +++ b/transforms/sprat.libsonnet @@ -0,0 +1,33 @@ +// sprat.libsonnet – shared helpers for all sprat Jsonnet transforms. +{ + // Format a double like C's %.8g: up to 8 decimal places, no trailing zeros. + // Jsonnet v0.20.0 has a known bug with %g format; we use %f + trim instead. + format_double(v):: + local s = std.format("%.8f", v); + local rtrim(str) = + if std.length(str) == 0 then "0" + else if str[std.length(str) - 1] == "0" then rtrim(std.substr(str, 0, std.length(str) - 1)) + else if str[std.length(str) - 1] == "." then std.substr(str, 0, std.length(str) - 1) + else str; + rtrim(s), + + // Split an array of frame indices into contiguous runs. + // Returns [{from: N, to: M}, ...]. + consecutive_runs(indices):: + if std.length(indices) == 0 then [] + else + local fold_result = std.foldl( + function(acc, idx) + if acc.current_end == idx - 1 then + acc { current_end: idx } + else + acc { + runs: acc.runs + [{from: acc.current_start, to: acc.current_end}], + current_start: idx, + current_end: idx, + }, + indices[1:], + { runs: [], current_start: indices[0], current_end: indices[0] } + ); + fold_result.runs + [{from: fold_result.current_start, to: fold_result.current_end}], +} diff --git a/transforms/unity.anim.jsonnet b/transforms/unity.anim.jsonnet new file mode 100644 index 0000000..736c8ad --- /dev/null +++ b/transforms/unity.anim.jsonnet @@ -0,0 +1,72 @@ +// unity.anim.jsonnet – Unity AnimationClip (.anim) per-animation files. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_entry(frame, i, fps) = + " - time: " + lib.format_double(i / fps) + "\n" + + " value: {fileID: " + frame.name_hash_decimal + + ", guid: " + sprat.output_stem_hash_hex + "0000000000000000, type: 3}\n"; + +local render_clip(anim) = + local eff_fps = if anim.fps > 0 then anim.fps else 8; + "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n" + + "--- !u!74 &740000" + anim.index + "\n" + + "AnimationClip:\n" + + " m_ObjectHideFlags: 0\n" + + " m_CorrespondingSourceObject: {fileID: 0}\n" + + " m_PrefabInstance: {fileID: 0}\n" + + " m_PrefabAsset: {fileID: 0}\n" + + " m_Name: " + anim.name + "\n" + + " serializedVersion: 6\n" + + " m_Legacy: 0\n" + + " m_Compressed: 0\n" + + " m_UseHighQualityCurve: 1\n" + + " m_RotationCurves: []\n" + + " m_CompressedRotationCurves: []\n" + + " m_EulerCurves: []\n" + + " m_PositionCurves: []\n" + + " m_ScaleCurves: []\n" + + " m_FloatCurves: []\n" + + " m_PPtrCurves:\n" + + " - curve:\n" + + std.join("", [frame_entry(anim.frames[i], i, eff_fps) for i in std.range(0, std.length(anim.frames) - 1)]) + + " attribute: m_Sprite\n" + + " path:\n" + + " classID: 212\n" + + " script: {fileID: 0}\n" + + " m_AnimationClipSettings:\n" + + " serializedVersion: 2\n" + + " m_AdditiveReferencePoseClip: {fileID: 0}\n" + + " m_AdditiveReferencePoseTime: 0\n" + + " m_StartTime: 0\n" + + " m_StopTime: " + lib.format_double(anim.duration) + "\n" + + " m_OrientationOffsetY: 0\n" + + " m_Level: 0\n" + + " m_CycleOffset: 0\n" + + " m_HasAdditiveReferencePose: 0\n" + + " m_LoopTime: 1\n" + + " m_LoopBlend: 0\n" + + " m_LoopBlendOrientation: 0\n" + + " m_LoopBlendPositionY: 0\n" + + " m_LoopBlendPositionXZ: 0\n" + + " m_KeepOriginalOrientation: 0\n" + + " m_KeepOriginalPositionY: 1\n" + + " m_KeepOriginalPositionXZ: 0\n" + + " m_HeightFromFeet: 0\n" + + " m_Mirror: 0\n" + + " m_EditorCurves: []\n" + + " m_EulerEditorCurves: []\n" + + " m_HasGenericRootTransform: 0\n" + + " m_HasMotionFloatCurves: 0\n" + + " m_Events: []\n"; + +{ + name: "Unity AnimationClip", + description: "Unity AnimationClip (.anim) sprite animation; GUIDs match the unity.meta transform output; use --output-dir to write one .anim file per animation", + extension: ".anim", + icon: "icons/unity-svgrepo-com.svg", + files: [ + { filename: anim.name + ".anim", content: render_clip(anim) } + for anim in sprat.animations + ], +} diff --git a/transforms/unity.json.jsonnet b/transforms/unity.json.jsonnet new file mode 100644 index 0000000..c826083 --- /dev/null +++ b/transforms/unity.json.jsonnet @@ -0,0 +1,37 @@ +// unity.json.jsonnet – Unity-compatible JSON sprite sheet (TexturePacker JSON Hash format). +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm }, +}; + +local frames_obj = std.foldl( + function(acc, s) acc { [s.name]: frame_obj(s) }, + sprat.sprites, + {} +); + +local result = { + frames: frames_obj, + meta: { + app: "https://github.com/pedroac/sprat-cli", + version: "1.0", + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Unity JSON", + description: "Unity-compatible JSON sprite sheet (TexturePacker JSON Hash format with normalized pivots)", + extension: ".json", + icon: "icons/unity-svgrepo-com.svg", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/unity.meta.jsonnet b/transforms/unity.meta.jsonnet new file mode 100644 index 0000000..f141262 --- /dev/null +++ b/transforms/unity.meta.jsonnet @@ -0,0 +1,115 @@ +// unity.meta.jsonnet – Unity .meta file spriteSheet section (YAML). +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local id_entry(s) = + " - first:\n" + + " 213: " + s.name_hash_decimal + "\n" + + " second: " + s.name + "\n"; + +local sprite_rect_entry(s) = + " - serializedVersion: 2\n" + + " name: " + s.name + "\n" + + " rect:\n" + + " serializedVersion: 2\n" + + " x: " + s.x + "\n" + + " y: " + s.unity_y + "\n" + + " width: " + s.content_w + "\n" + + " height: " + s.content_h + "\n" + + " alignment: 9\n" + + " pivot: {x: " + lib.format_double(s.pivot_x_norm) + ", y: " + lib.format_double(s.pivot_y_norm) + "}\n" + + " border: {x: 0, y: 0, z: 0, w: 0}\n" + + " outline: []\n" + + " physicsShape: []\n" + + " tessellationDetail: 0\n" + + " bones: []\n" + + " spriteID: " + s.name_hash_hex + "\n" + + " internalID: " + s.name_hash_decimal + "\n" + + " vertices: []\n" + + " indices:\n" + + " edges: []\n" + + " weights: []\n"; + +{ + name: "Unity Meta", + description: "Unity .meta file spriteSheet section (YAML)", + extension: ".meta", + icon: "icons/unity-svgrepo-com.svg", + content: + "fileFormatVersion: 2\n" + + "guid: " + sprat.output_stem_hash_hex + "0000000000000000\n" + + "TextureImporter:\n" + + " internalIDToNameTable:\n" + + std.join("", [id_entry(s) for s in sprat.sprites]) + + " externalObjects: {}\n" + + " serializedVersion: 13\n" + + " mipmaps:\n" + + " mipMapMode: 0\n" + + " enableMipMap: 0\n" + + " sRGBTexture: 1\n" + + " linearTexture: 0\n" + + " fadeOut: 0\n" + + " borderMipMap: 0\n" + + " mipMapsPreserveCoverage: 0\n" + + " alphaTestReferenceValue: 0.5\n" + + " mipMapFadeDistanceStart: 1\n" + + " mipMapFadeDistanceEnd: 3\n" + + " bumpmap:\n" + + " convertToNormalMap: 0\n" + + " externalNormalMap: 0\n" + + " heightScale: 0.25\n" + + " normalMapFilter: 0\n" + + " isReadable: 0\n" + + " streamingMipmaps: 0\n" + + " streamingMipmapsPriority: 0\n" + + " vTOnly: 0\n" + + " ignoreMasterTextureLimit: 0\n" + + " vtOnly: 0\n" + + " ignoreMipmapLimit: 0\n" + + " isDirectBinding: 0\n" + + " importAsync: 0\n" + + " filterMode: 0\n" + + " aniso: 1\n" + + " mipBias: 0\n" + + " textureType: 8\n" + + " textureShape: 1\n" + + " singleChannelComponent: 0\n" + + " flipbookRows: 1\n" + + " flipbookColumns: 1\n" + + " maxTextureSizeSet: 0\n" + + " compressionQuality: 50\n" + + " textureFormat: -1\n" + + " uncompressed: 0\n" + + " alphaUsage: 1\n" + + " alphaIsTransparency: 1\n" + + " spriteMode: 2\n" + + " spriteExtrude: 1\n" + + " spriteMeshType: 1\n" + + " alignment: 0\n" + + " spritePivot: {x: 0.5, y: 0.5}\n" + + " spritePixelsToUnits: 100\n" + + " spriteBorder: {x: 0, y: 0, z: 0, w: 0}\n" + + " spriteGenerateFallbackPhysicsShape: 1\n" + + " alphaTestReferenceValue: 0.5\n" + + " mipMapFadeDistanceStart: 1\n" + + " mipMapFadeDistanceEnd: 3\n" + + " spriteSheet:\n" + + " serializedVersion: 2\n" + + " sprites:\n" + + std.join("", [sprite_rect_entry(s) for s in sprat.sprites]) + + " outline: []\n" + + " physicsShape: []\n" + + " bones: []\n" + + " spriteID:\n" + + " internalID: 0\n" + + " vertices: []\n" + + " indices:\n" + + " edges: []\n" + + " weights: []\n" + + " spritePackingTag:\n" + + " pSDRemoveMatte: 0\n" + + " pSDShowRemoveMatteOption: 0\n" + + " userData:\n" + + " assetBundleName:\n" + + " assetBundleVariant:\n", +} diff --git a/transforms/xml.jsonnet b/transforms/xml.jsonnet new file mode 100644 index 0000000..abad121 --- /dev/null +++ b/transforms/xml.jsonnet @@ -0,0 +1,83 @@ +// xml.jsonnet – XML layout format for engine import pipelines. +local sprat = std.extVar("sprat"); + +local xml_escape(s) = + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace(s, "&", "&"), + "<", "<"), + ">", ">"), + '"', """), + "'", "'"); + +local marker_xml(m) = + if m.type == "point" then + '' + else if m.type == "circle" then + '' + else if m.type == "rectangle" then + '' + else if m.type == "polygon" then + '' + + std.join("|", ["" + v.x + "," + v.y for v in m.vertices]) + + '' + else ""; + +local sprite_xml(s) = + local marker_section = + if std.length(s.markers) > 0 then + "\n \n " + + std.join("\n ", [marker_xml(m) for m in s.markers]) + + "\n \n" + else ""; + '' + + marker_section + ""; + +local atlas_xml(at) = + ' \n' + + ' \n ' + + std.join("\n ", [sprite_xml(s) for s in at.sprites]) + + '\n \n '; + +local anim_xml(a) = + if a.is_alias then + ' " + else + ' '; + +local atlases_section = + ' \n' + + std.join("\n", [atlas_xml(at) for at in sprat.atlases]) + + '\n \n'; + +local animations_section = + if sprat.has_animations then + ' \n' + + std.join("\n", [anim_xml(a) for a in sprat.animations]) + + '\n \n' + else ""; + +{ + name: "XML", + description: "XML layout format for engine import pipelines", + extension: ".xml", + icon: "icons/xml-svgrepo-com.svg", + content: + '\n' + + '\n' + + atlases_section + + animations_section + + '\n', +}