From 9c6b52eb8ef47ecebe3c2b23d5510f3e1d4a57eb Mon Sep 17 00:00:00 2001 From: omigamedev Date: Tue, 2 Jun 2026 10:41:34 +0200 Subject: [PATCH] Add image export roundtrip automation --- docs/modernization/build-inventory.md | 14 ++- docs/modernization/roadmap.md | 8 +- src/assets/image_pixels.cpp | 65 +++++++++++ src/assets/image_pixels.h | 5 + tests/CMakeLists.txt | 8 ++ tests/assets/image_pixels_tests.cpp | 34 ++++++ .../pano_cli_export_image_roundtrip.cmake | 68 +++++++++++ tools/pano_cli/main.cpp | 107 ++++++++++++++++++ 8 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 tests/cmake/pano_cli_export_image_roundtrip.cmake diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 67aeb2d..c15f630 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -98,17 +98,21 @@ Known local toolchain state: capability coverage, UI color parsing, and layout XML parse coverage. - `pano_cli inspect-image` reports PNG IHDR metadata as JSON and is covered by `pano_cli_inspect_png_metadata_smoke` with a tiny IHDR fixture. +- `pp_assets_image_pixels_tests` decodes PNG payloads, encodes RGBA8 pixels to + PNG, round-trips encoded pixels back through the decoder, and rejects corrupt + or malformed image payloads. - `pano_cli import-image` accepts a PNG path, decodes RGBA8 pixels through `pp_assets`, attaches them to a pure `pp_document` face payload, and is covered for checked-in decodable PNG import by `pano_cli_import_image_smoke` and metadata-valid truncated PNG rejection by `pano_cli_import_image_rejects_truncated_png`. +- `pano_cli export-image` writes a deterministic RGBA8 PNG through `pp_assets` + and is covered by `pano_cli_export_image_roundtrip_smoke`, which imports the + generated file back through `pano_cli import-image`. - `pano_cli inspect-project` reports validated PPI thumbnail/body byte layout, body summary fields, layer/frame descriptors, and dirty-face PNG payload metadata, and is covered by `pano_cli_inspect_project_layout_smoke` with a minimal PPI fixture. -- `pp_assets_image_pixels_tests` decodes PNG payloads to RGBA8 and rejects - corrupt image payloads. - `pp_document_ppi_import_tests` attaches decoded PPI dirty-face payloads to `pp_document` layer/frame storage and rejects payloads outside document layers. @@ -275,8 +279,10 @@ Known local toolchain state: - `pano_cli import-image` exposes file-driven PNG decode and document face-payload attachment through JSON automation and is covered by `pano_cli_import_image_smoke` and - `pano_cli_import_image_rejects_truncated_png`; export/round-trip automation - remains a future CLI task. + `pano_cli_import_image_rejects_truncated_png`. +- `pano_cli export-image` exposes deterministic RGBA8 PNG writing through JSON + automation and is covered by `pano_cli_export_image_roundtrip_smoke`; full + legacy canvas export remains a future CLI task. - `pano_cli save-project` exposes generated multi-layer, multi-frame PPI writing through JSON automation and is covered by metadata-only and dirty-face-payload round-trip smoke tests; full legacy canvas save parity remains tracked by diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index 3b1804d..1d36a18 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -335,6 +335,8 @@ input tests. `pano_cli import-image` accepts a PNG path and imports decoded RGBA8 pixels into a new pure `pp_document` face payload, with checked-in decodable PNG and truncated PNG automation coverage, +`pano_cli export-image` writes a deterministic RGBA8 PNG through `pp_assets` +and round-trips it back through file import automation, `pano_cli inspect-project` reports validated PPI thumbnail/body byte layout, body summary, layer/frame descriptors, dirty-face PNG payload metadata, and asset-level decode coverage, and @@ -785,8 +787,10 @@ Results: automation. - `pano_cli import-image` accepts a PNG file path, decodes RGBA8 pixels through `pp_assets`, attaches them to a pure `pp_document` face payload, and has - checked-in decodable-PNG plus truncated-PNG rejection smoke tests. Full - export/round-trip automation remains a future `pano_cli` task. + checked-in decodable-PNG plus truncated-PNG rejection smoke tests. +- `pano_cli export-image` writes deterministic RGBA8 PNGs through `pp_assets` + and has a save/import round-trip smoke test. Full legacy canvas export + remains a future `pano_cli` task. - `pano_cli save-project` exposes generated multi-layer, multi-frame PPI writing through JSON automation and is covered by metadata-only and dirty-face-payload save/load round-trip smoke tests. Full legacy canvas save parity remains diff --git a/src/assets/image_pixels.cpp b/src/assets/image_pixels.cpp index aedbe7a..a10e42b 100644 --- a/src/assets/image_pixels.cpp +++ b/src/assets/image_pixels.cpp @@ -9,6 +9,10 @@ #define STB_IMAGE_IMPLEMENTATION #include +#define STB_IMAGE_WRITE_STATIC +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include + namespace pp::assets { namespace { @@ -33,6 +37,17 @@ namespace { return pp::foundation::Result::success(static_cast(bytes)); } +void append_png_bytes(void* context, void* data, int size) +{ + if (context == nullptr || data == nullptr || size <= 0) { + return; + } + + auto* bytes = static_cast*>(context); + const auto* begin = static_cast(data); + bytes->insert(bytes->end(), begin, begin + size); +} + } pp::foundation::Result decode_png_rgba8(std::span bytes) @@ -91,4 +106,54 @@ pp::foundation::Result decode_png_rgba8(std::span b return pp::foundation::Result::success(std::move(image)); } +pp::foundation::Result> encode_png_rgba8( + std::uint32_t width, + std::uint32_t height, + std::span pixels) +{ + if (width == 0 || height == 0) { + return pp::foundation::Result>::failure( + pp::foundation::Status::invalid_argument("PNG dimensions must be greater than zero")); + } + + if (width > static_cast(std::numeric_limits::max()) + || height > static_cast(std::numeric_limits::max())) { + return pp::foundation::Result>::failure( + pp::foundation::Status::out_of_range("PNG dimensions exceed encoder limits")); + } + + const auto byte_count = rgba_byte_size(width, height); + if (!byte_count) { + return pp::foundation::Result>::failure(byte_count.status()); + } + + if (pixels.size() != byte_count.value()) { + return pp::foundation::Result>::failure( + pp::foundation::Status::invalid_argument("RGBA pixel payload size does not match dimensions")); + } + + const auto stride = static_cast(width) * 4ULL; + if (stride > static_cast(std::numeric_limits::max())) { + return pp::foundation::Result>::failure( + pp::foundation::Status::out_of_range("PNG row stride exceeds encoder limits")); + } + + std::vector encoded; + const auto result = stbi_write_png_to_func( + append_png_bytes, + &encoded, + static_cast(width), + static_cast(height), + 4, + pixels.data(), + static_cast(stride)); + + if (result == 0 || encoded.empty()) { + return pp::foundation::Result>::failure( + pp::foundation::Status::invalid_argument("RGBA pixels could not be encoded as PNG")); + } + + return pp::foundation::Result>::success(std::move(encoded)); +} + } diff --git a/src/assets/image_pixels.h b/src/assets/image_pixels.h index 42b67ed..cdd3aa0 100644 --- a/src/assets/image_pixels.h +++ b/src/assets/image_pixels.h @@ -18,4 +18,9 @@ struct Rgba8Image { [[nodiscard]] pp::foundation::Result decode_png_rgba8( std::span bytes); +[[nodiscard]] pp::foundation::Result> encode_png_rgba8( + std::uint32_t width, + std::uint32_t height, + std::span pixels); + } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ef1aeb6..9dca2c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -283,6 +283,14 @@ if(TARGET pano_cli) LABELS "assets;document;integration;desktop-fast" PASS_REGULAR_EXPRESSION "\"command\":\"import-image\".*\"image\":\\{\"format\":\"png\",\"width\":1,\"height\":1,\"bytes\":4\\}.*\"document\":\\{\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"facePayloads\":1\\}.*\"payload\":\\{\"face\":5,\"x\":7,\"y\":11,\"width\":1,\"height\":1,\"bytes\":4\\}") + add_test(NAME pano_cli_export_image_roundtrip_smoke + COMMAND "${CMAKE_COMMAND}" + -DPANO_CLI=$ + -DOUTPUT_PATH=${CMAKE_CURRENT_BINARY_DIR}/data/generated/export-roundtrip.png + -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/pano_cli_export_image_roundtrip.cmake") + set_tests_properties(pano_cli_export_image_roundtrip_smoke PROPERTIES + LABELS "assets;document;integration;desktop-fast") + add_test(NAME pano_cli_inspect_project_layout_smoke COMMAND pano_cli inspect-project --path "${CMAKE_CURRENT_SOURCE_DIR}/data/projects/minimal-project.ppi") set_tests_properties(pano_cli_inspect_project_layout_smoke PROPERTIES diff --git a/tests/assets/image_pixels_tests.cpp b/tests/assets/image_pixels_tests.cpp index d1b9e03..e181204 100644 --- a/tests/assets/image_pixels_tests.cpp +++ b/tests/assets/image_pixels_tests.cpp @@ -5,8 +5,10 @@ #include #include #include +#include using pp::assets::decode_png_rgba8; +using pp::assets::encode_png_rgba8; using pp::foundation::StatusCode; namespace { @@ -56,6 +58,36 @@ void rejects_corrupt_png_payload(pp::tests::Harness& h) PP_EXPECT(h, image.status().code == StatusCode::invalid_argument); } +void encodes_rgba8_pixels_to_decodable_png(pp::tests::Harness& h) +{ + const std::vector pixels { + 255, 0, 0, 255, + 0, 255, 0, 128, + }; + + const auto encoded = encode_png_rgba8(2, 1, pixels); + + PP_EXPECT(h, encoded.ok()); + const auto decoded = decode_png_rgba8(encoded.value()); + PP_EXPECT(h, decoded.ok()); + PP_EXPECT(h, decoded.value().width == 2U); + PP_EXPECT(h, decoded.value().height == 1U); + PP_EXPECT(h, decoded.value().pixels == pixels); +} + +void rejects_invalid_png_encode_inputs(pp::tests::Harness& h) +{ + const std::vector pixels { 0, 0, 0, 0 }; + + const auto no_size = encode_png_rgba8(0, 1, pixels); + const auto wrong_payload_size = encode_png_rgba8(2, 1, pixels); + + PP_EXPECT(h, !no_size.ok()); + PP_EXPECT(h, no_size.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !wrong_payload_size.ok()); + PP_EXPECT(h, wrong_payload_size.status().code == StatusCode::invalid_argument); +} + } int main() @@ -63,5 +95,7 @@ int main() pp::tests::Harness harness; harness.run("decodes_png_to_rgba8_pixels", decodes_png_to_rgba8_pixels); harness.run("rejects_corrupt_png_payload", rejects_corrupt_png_payload); + harness.run("encodes_rgba8_pixels_to_decodable_png", encodes_rgba8_pixels_to_decodable_png); + harness.run("rejects_invalid_png_encode_inputs", rejects_invalid_png_encode_inputs); return harness.finish(); } diff --git a/tests/cmake/pano_cli_export_image_roundtrip.cmake b/tests/cmake/pano_cli_export_image_roundtrip.cmake new file mode 100644 index 0000000..3de7391 --- /dev/null +++ b/tests/cmake/pano_cli_export_image_roundtrip.cmake @@ -0,0 +1,68 @@ +if(NOT DEFINED PANO_CLI) + message(FATAL_ERROR "PANO_CLI must be set") +endif() + +if(NOT DEFINED OUTPUT_PATH) + message(FATAL_ERROR "OUTPUT_PATH must be set") +endif() + +get_filename_component(output_dir "${OUTPUT_PATH}" DIRECTORY) +file(MAKE_DIRECTORY "${output_dir}") +file(REMOVE "${OUTPUT_PATH}") + +execute_process( + COMMAND "${PANO_CLI}" export-image + --path "${OUTPUT_PATH}" + --width 3 + --height 2 + RESULT_VARIABLE export_result + OUTPUT_VARIABLE export_output + ERROR_VARIABLE export_error) + +if(NOT export_result EQUAL 0) + message(FATAL_ERROR "export-image failed: ${export_output}${export_error}") +endif() + +string(FIND "${export_output}" "\"command\":\"export-image\"" export_command_index) +string(FIND "${export_output}" "\"format\":\"png\"" export_format_index) +string(FIND "${export_output}" "\"width\":3" export_width_index) +string(FIND "${export_output}" "\"height\":2" export_height_index) +string(FIND "${export_output}" "\"bytes\":24" export_bytes_index) +if(export_command_index LESS 0 + OR export_format_index LESS 0 + OR export_width_index LESS 0 + OR export_height_index LESS 0 + OR export_bytes_index LESS 0) + message(FATAL_ERROR "export-image output did not contain expected summary: ${export_output}") +endif() + +if(NOT EXISTS "${OUTPUT_PATH}") + message(FATAL_ERROR "export-image did not create ${OUTPUT_PATH}") +endif() + +execute_process( + COMMAND "${PANO_CLI}" import-image + --path "${OUTPUT_PATH}" + --document-width 16 + --document-height 8 + --face 1 + --x 2 + --y 3 + RESULT_VARIABLE import_result + OUTPUT_VARIABLE import_output + ERROR_VARIABLE import_error) + +if(NOT import_result EQUAL 0) + message(FATAL_ERROR "import-image failed after export-image: ${import_output}${import_error}") +endif() + +string(FIND "${import_output}" "\"command\":\"import-image\"" import_command_index) +string(FIND "${import_output}" "\"image\":{\"format\":\"png\",\"width\":3,\"height\":2,\"bytes\":24}" import_image_index) +string(FIND "${import_output}" "\"document\":{\"width\":16,\"height\":8,\"layers\":1,\"frames\":1,\"facePayloads\":1}" import_document_index) +string(FIND "${import_output}" "\"payload\":{\"face\":1,\"x\":2,\"y\":3,\"width\":3,\"height\":2,\"bytes\":24}" import_payload_index) +if(import_command_index LESS 0 + OR import_image_index LESS 0 + OR import_document_index LESS 0 + OR import_payload_index LESS 0) + message(FATAL_ERROR "import-image output did not contain expected exported-image summary: ${import_output}") +endif() diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 38ea175..f80486a 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -55,6 +55,12 @@ struct ImportImageArgs { std::uint32_t y = 0; }; +struct ExportImageArgs { + std::string path; + std::uint32_t width = 2; + std::uint32_t height = 2; +}; + struct ParseLayoutArgs { std::string path; }; @@ -173,6 +179,7 @@ void print_help() std::cout << "pano_cli commands:\n" << " create-document --width N --height N [--layers N] [--frames N] [--frame-duration-ms N]\n" + << " export-image --path FILE [--width N] [--height N]\n" << " inspect-image --path FILE\n" << " import-image --path FILE [--document-width N] [--document-height N] [--face N] [--x N] [--y N]\n" << " inspect-project --path FILE\n" @@ -623,6 +630,102 @@ int import_image(int argc, char** argv) return 0; } +pp::foundation::Status parse_export_image_args(int argc, char** argv, ExportImageArgs& args) +{ + for (int i = 2; i < argc; ++i) { + const std::string_view key(argv[i]); + if (key == "--path") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + + args.path = argv[++i]; + } else if (key == "--width" || key == "--height") { + if (i + 1 >= argc) { + return pp::foundation::Status::invalid_argument("missing value for option"); + } + + const auto value = pp::foundation::parse_u32(argv[++i]); + if (!value) { + return value.status(); + } + + if (key == "--width") { + args.width = value.value(); + } else { + args.height = value.value(); + } + } else { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + if (args.path.empty()) { + return pp::foundation::Status::invalid_argument("path must not be empty"); + } + + if (args.width == 0 || args.height == 0) { + return pp::foundation::Status::invalid_argument("width and height must be greater than zero"); + } + + constexpr std::uint64_t max_cli_export_bytes = 64ULL * 1024ULL * 1024ULL; + if (static_cast(args.width) > max_cli_export_bytes / 4ULL / static_cast(args.height)) { + return pp::foundation::Status::out_of_range("export image exceeds the CLI automation size limit"); + } + + return pp::foundation::Status::success(); +} + +int export_image(int argc, char** argv) +{ + ExportImageArgs args; + const auto status = parse_export_image_args(argc, argv, args); + if (!status.ok()) { + print_error("export-image", status.message); + return 2; + } + + std::vector pixels( + static_cast(args.width) * static_cast(args.height) * 4U); + for (std::uint32_t y = 0; y < args.height; ++y) { + for (std::uint32_t x = 0; x < args.width; ++x) { + const auto offset = (static_cast(y) * args.width + x) * 4U; + pixels[offset + 0U] = static_cast((x * 37U) & 0xffU); + pixels[offset + 1U] = static_cast((y * 53U) & 0xffU); + pixels[offset + 2U] = static_cast(((x + y) * 29U) & 0xffU); + pixels[offset + 3U] = 255U; + } + } + + const auto encoded = pp::assets::encode_png_rgba8(args.width, args.height, pixels); + if (!encoded) { + print_error("export-image", encoded.status().message); + return 2; + } + + std::ofstream stream(args.path, std::ios::binary); + if (!stream) { + print_error("export-image", "image file could not be opened for writing"); + return 2; + } + + const auto& bytes = encoded.value(); + stream.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!stream) { + print_error("export-image", "image file could not be written"); + return 2; + } + + std::cout << "{\"ok\":true,\"command\":\"export-image\"" + << ",\"path\":\"" << json_escape(args.path) << "\"" + << ",\"image\":{\"format\":\"png\",\"width\":" << args.width + << ",\"height\":" << args.height + << ",\"bytes\":" << pixels.size() + << "},\"file\":{\"bytes\":" << bytes.size() + << "}}\n"; + return 0; +} + pp::foundation::Status parse_inspect_project_args(int argc, char** argv, InspectProjectArgs& args) { for (int i = 2; i < argc; ++i) { @@ -1554,6 +1657,10 @@ int main(int argc, char** argv) return inspect_image(argc, argv); } + if (command == "export-image") { + return export_image(argc, argv); + } + if (command == "import-image") { return import_image(argc, argv); }