Add image export roundtrip automation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#include <stb/stb_image.h>
|
||||
|
||||
#define STB_IMAGE_WRITE_STATIC
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#include <stb/stb_image_write.h>
|
||||
|
||||
namespace pp::assets {
|
||||
|
||||
namespace {
|
||||
@@ -33,6 +37,17 @@ namespace {
|
||||
return pp::foundation::Result<std::size_t>::success(static_cast<std::size_t>(bytes));
|
||||
}
|
||||
|
||||
void append_png_bytes(void* context, void* data, int size)
|
||||
{
|
||||
if (context == nullptr || data == nullptr || size <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto* bytes = static_cast<std::vector<std::byte>*>(context);
|
||||
const auto* begin = static_cast<const std::byte*>(data);
|
||||
bytes->insert(bytes->end(), begin, begin + size);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pp::foundation::Result<Rgba8Image> decode_png_rgba8(std::span<const std::byte> bytes)
|
||||
@@ -91,4 +106,54 @@ pp::foundation::Result<Rgba8Image> decode_png_rgba8(std::span<const std::byte> b
|
||||
return pp::foundation::Result<Rgba8Image>::success(std::move(image));
|
||||
}
|
||||
|
||||
pp::foundation::Result<std::vector<std::byte>> encode_png_rgba8(
|
||||
std::uint32_t width,
|
||||
std::uint32_t height,
|
||||
std::span<const std::uint8_t> pixels)
|
||||
{
|
||||
if (width == 0 || height == 0) {
|
||||
return pp::foundation::Result<std::vector<std::byte>>::failure(
|
||||
pp::foundation::Status::invalid_argument("PNG dimensions must be greater than zero"));
|
||||
}
|
||||
|
||||
if (width > static_cast<std::uint32_t>(std::numeric_limits<int>::max())
|
||||
|| height > static_cast<std::uint32_t>(std::numeric_limits<int>::max())) {
|
||||
return pp::foundation::Result<std::vector<std::byte>>::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<std::vector<std::byte>>::failure(byte_count.status());
|
||||
}
|
||||
|
||||
if (pixels.size() != byte_count.value()) {
|
||||
return pp::foundation::Result<std::vector<std::byte>>::failure(
|
||||
pp::foundation::Status::invalid_argument("RGBA pixel payload size does not match dimensions"));
|
||||
}
|
||||
|
||||
const auto stride = static_cast<std::uint64_t>(width) * 4ULL;
|
||||
if (stride > static_cast<std::uint64_t>(std::numeric_limits<int>::max())) {
|
||||
return pp::foundation::Result<std::vector<std::byte>>::failure(
|
||||
pp::foundation::Status::out_of_range("PNG row stride exceeds encoder limits"));
|
||||
}
|
||||
|
||||
std::vector<std::byte> encoded;
|
||||
const auto result = stbi_write_png_to_func(
|
||||
append_png_bytes,
|
||||
&encoded,
|
||||
static_cast<int>(width),
|
||||
static_cast<int>(height),
|
||||
4,
|
||||
pixels.data(),
|
||||
static_cast<int>(stride));
|
||||
|
||||
if (result == 0 || encoded.empty()) {
|
||||
return pp::foundation::Result<std::vector<std::byte>>::failure(
|
||||
pp::foundation::Status::invalid_argument("RGBA pixels could not be encoded as PNG"));
|
||||
}
|
||||
|
||||
return pp::foundation::Result<std::vector<std::byte>>::success(std::move(encoded));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,4 +18,9 @@ struct Rgba8Image {
|
||||
[[nodiscard]] pp::foundation::Result<Rgba8Image> decode_png_rgba8(
|
||||
std::span<const std::byte> bytes);
|
||||
|
||||
[[nodiscard]] pp::foundation::Result<std::vector<std::byte>> encode_png_rgba8(
|
||||
std::uint32_t width,
|
||||
std::uint32_t height,
|
||||
std::span<const std::uint8_t> pixels);
|
||||
|
||||
}
|
||||
|
||||
@@ -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=$<TARGET_FILE:pano_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
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
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<std::uint8_t> 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<std::uint8_t> 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();
|
||||
}
|
||||
|
||||
68
tests/cmake/pano_cli_export_image_roundtrip.cmake
Normal file
68
tests/cmake/pano_cli_export_image_roundtrip.cmake
Normal file
@@ -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()
|
||||
@@ -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<std::uint64_t>(args.width) > max_cli_export_bytes / 4ULL / static_cast<std::uint64_t>(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<std::uint8_t> pixels(
|
||||
static_cast<std::size_t>(args.width) * static_cast<std::size_t>(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<std::size_t>(y) * args.width + x) * 4U;
|
||||
pixels[offset + 0U] = static_cast<std::uint8_t>((x * 37U) & 0xffU);
|
||||
pixels[offset + 1U] = static_cast<std::uint8_t>((y * 53U) & 0xffU);
|
||||
pixels[offset + 2U] = static_cast<std::uint8_t>(((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<const char*>(bytes.data()), static_cast<std::streamsize>(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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user