Add image export roundtrip automation

This commit is contained in:
2026-06-02 10:41:34 +02:00
parent 9d05d193a7
commit 9c6b52eb8e
8 changed files with 303 additions and 6 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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();
}

View 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()

View File

@@ -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);
}