From c62bc4d7445e38d4b0a4da6055574e0e1483f460 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 1 Jun 2026 08:58:28 +0200 Subject: [PATCH] Add assets PNG metadata tests --- CMakeLists.txt | 1 + docs/modernization/build-inventory.md | 6 +- docs/modernization/roadmap.md | 8 +- scripts/automation/platform-build.ps1 | 2 +- scripts/automation/platform-build.sh | 2 +- src/assets/image_metadata.cpp | 146 ++++++++++++++++++++++++++ src/assets/image_metadata.h | 34 ++++++ tests/CMakeLists.txt | 10 ++ tests/assets/image_metadata_tests.cpp | 113 ++++++++++++++++++++ 9 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 src/assets/image_metadata.cpp create mode 100644 src/assets/image_metadata.h create mode 100644 tests/assets/image_metadata_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5210901..bbd89f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,7 @@ target_link_libraries(pp_foundation add_library(pp_assets STATIC src/assets/image_format.cpp + src/assets/image_metadata.cpp src/assets/ppi_header.cpp src/assets/settings_document.cpp) target_include_directories(pp_assets diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 53d9773..600c084 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -86,9 +86,9 @@ Known local toolchain state: `platform-build` automation wrapper for `pp_foundation`, `pp_assets`, `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`, `pp_ui_core`, `pano_cli`, and their current headless test binaries, - including foundation event/logging/task queue coverage, PPI header, settings - document, paint brush/stroke coverage, UI color parsing, and layout XML - parse coverage. + including foundation event/logging/task queue coverage, PNG metadata, PPI + header, settings document, paint brush/stroke coverage, UI color parsing, and + layout XML parse coverage. - `panopainter_validate_shaders` validates the current combined GLSL shader files for one vertex stage marker, one fragment stage marker, valid marker order, and existing relative includes. diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index d0b4281..eab389f 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -309,8 +309,9 @@ input. A synchronous event dispatcher, structured logging facade, bounded FIFO task queue, and deterministic `TraceRecorder` now record component/name/thread/frame/stroke metadata with filtering, capacity, and invalid-end tests. `pp_assets` has started with PNG/JPEG signature detection, -PPI header recognition, and a pure typed settings document model, with -corrupt/truncated/unsupported and key/value limit tests. +PNG IHDR metadata parsing, PPI header recognition, and a pure typed settings +document model, with corrupt/truncated/unsupported, extreme-dimension, and +key/value limit tests. `pp_paint` has started with pure brush parameter validation/stamp evaluation, CPU reference math for the five current shader blend modes, and deterministic stroke spacing/interpolation. `pp_document` has @@ -528,7 +529,7 @@ Last verified on 2026-06-01: ```powershell cmake --preset windows-msvc-default -cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_event_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_color_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests pano_cli PanoPainter +cmake --build --preset windows-msvc-default --config Debug --target pp_foundation_binary_stream_tests pp_foundation_event_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_image_metadata_tests pp_assets_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_color_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests pano_cli PanoPainter ctest --preset desktop-fast --build-config Debug powershell -ExecutionPolicy Bypass -File scripts\automation\test.ps1 -Preset desktop-fast -Configuration Debug powershell -ExecutionPolicy Bypass -File scripts\automation\build.ps1 -Preset windows-msvc-default -Configuration Debug -Target pano_cli @@ -552,6 +553,7 @@ Results: - `pp_foundation_task_queue_tests` passed. - `pp_foundation_trace_tests` passed. - `pp_assets_image_format_tests` passed. +- `pp_assets_image_metadata_tests` passed. - `pp_assets_ppi_header_tests` passed. - `pp_assets_settings_document_tests` passed. - `pp_paint_brush_tests` passed. diff --git a/scripts/automation/platform-build.ps1 b/scripts/automation/platform-build.ps1 index b4d67c7..488036e 100644 --- a/scripts/automation/platform-build.ps1 +++ b/scripts/automation/platform-build.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [string[]]$Presets = @("android-arm64"), - [string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pp_document", "pp_renderer_api", "pp_paint_renderer", "pp_ui_core", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_event_tests", "pp_foundation_log_tests", "pp_foundation_parse_tests", "pp_foundation_task_queue_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_assets_ppi_header_tests", "pp_assets_settings_document_tests", "pp_paint_brush_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_tests", "pp_ui_core_color_tests", "pp_ui_core_layout_value_tests", "pp_ui_core_layout_xml_tests") + [string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pp_document", "pp_renderer_api", "pp_paint_renderer", "pp_ui_core", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_event_tests", "pp_foundation_log_tests", "pp_foundation_parse_tests", "pp_foundation_task_queue_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_assets_image_metadata_tests", "pp_assets_ppi_header_tests", "pp_assets_settings_document_tests", "pp_paint_brush_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_tests", "pp_ui_core_color_tests", "pp_ui_core_layout_value_tests", "pp_ui_core_layout_xml_tests") ) $ErrorActionPreference = "Stop" diff --git a/scripts/automation/platform-build.sh b/scripts/automation/platform-build.sh index a250684..5da107a 100644 --- a/scripts/automation/platform-build.sh +++ b/scripts/automation/platform-build.sh @@ -3,7 +3,7 @@ set -u preset="${1:-android-arm64}" shift || true -targets="${*:-pp_foundation pp_assets pp_paint pp_document pp_renderer_api pp_paint_renderer pp_ui_core pano_cli pp_foundation_binary_stream_tests pp_foundation_event_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_color_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" +targets="${*:-pp_foundation pp_assets pp_paint pp_document pp_renderer_api pp_paint_renderer pp_ui_core pano_cli pp_foundation_binary_stream_tests pp_foundation_event_tests pp_foundation_log_tests pp_foundation_parse_tests pp_foundation_task_queue_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_image_metadata_tests pp_assets_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_color_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" start="$(date +%s)" cmake --preset "$preset" diff --git a/src/assets/image_metadata.cpp b/src/assets/image_metadata.cpp new file mode 100644 index 0000000..21f1c97 --- /dev/null +++ b/src/assets/image_metadata.cpp @@ -0,0 +1,146 @@ +#include "assets/image_metadata.h" + +#include + +namespace pp::assets { + +namespace { + +constexpr std::byte png_signature[] { + std::byte { 0x89 }, + std::byte { 0x50 }, + std::byte { 0x4e }, + std::byte { 0x47 }, + std::byte { 0x0d }, + std::byte { 0x0a }, + std::byte { 0x1a }, + std::byte { 0x0a }, +}; + +[[nodiscard]] bool has_png_signature(std::span bytes) noexcept +{ + if (bytes.size() < 8U) { + return false; + } + + for (std::size_t i = 0; i < 8U; ++i) { + if (bytes[i] != png_signature[i]) { + return false; + } + } + + return true; +} + +[[nodiscard]] std::uint32_t read_u32_be(std::span bytes, std::size_t offset) noexcept +{ + return (static_cast(std::to_integer(bytes[offset])) << 24U) + | (static_cast(std::to_integer(bytes[offset + 1U])) << 16U) + | (static_cast(std::to_integer(bytes[offset + 2U])) << 8U) + | static_cast(std::to_integer(bytes[offset + 3U])); +} + +[[nodiscard]] pp::foundation::Result parse_png_color_type(std::byte value) noexcept +{ + switch (std::to_integer(value)) { + case 0: + return pp::foundation::Result::success(ImageColorType::grayscale); + case 2: + return pp::foundation::Result::success(ImageColorType::rgb); + case 3: + return pp::foundation::Result::success(ImageColorType::indexed); + case 4: + return pp::foundation::Result::success(ImageColorType::grayscale_alpha); + case 6: + return pp::foundation::Result::success(ImageColorType::rgba); + default: + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PNG color type is unsupported")); + } +} + +[[nodiscard]] std::uint8_t component_count(ImageColorType color_type) noexcept +{ + switch (color_type) { + case ImageColorType::grayscale: + case ImageColorType::indexed: + return 1; + case ImageColorType::grayscale_alpha: + return 2; + case ImageColorType::rgb: + return 3; + case ImageColorType::rgba: + return 4; + } + + return 0; +} + +} + +pp::foundation::Result parse_png_metadata(std::span bytes) noexcept +{ + constexpr std::size_t png_ihdr_end = 33; + if (bytes.size() < png_ihdr_end) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("PNG metadata is truncated")); + } + + if (!has_png_signature(bytes)) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PNG signature is invalid")); + } + + const auto ihdr_length = read_u32_be(bytes, 8); + if (ihdr_length != 13U || bytes[12] != std::byte { 'I' } || bytes[13] != std::byte { 'H' } + || bytes[14] != std::byte { 'D' } || bytes[15] != std::byte { 'R' }) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PNG IHDR chunk is invalid")); + } + + const auto width = read_u32_be(bytes, 16); + const auto height = read_u32_be(bytes, 20); + if (width == 0 || height == 0 || width > max_image_dimension || height > max_image_dimension) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("PNG dimensions are outside the configured range")); + } + + const auto bit_depth = std::to_integer(bytes[24]); + if (bit_depth == 0U) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PNG bit depth is invalid")); + } + + const auto color_type = parse_png_color_type(bytes[25]); + if (!color_type) { + return pp::foundation::Result::failure(color_type.status()); + } + + return pp::foundation::Result::success(ImageMetadata { + .width = width, + .height = height, + .bit_depth = bit_depth, + .components = component_count(color_type.value()), + .color_type = color_type.value(), + }); +} + +const char* image_color_type_name(ImageColorType color_type) noexcept +{ + switch (color_type) { + case ImageColorType::grayscale: + return "grayscale"; + case ImageColorType::rgb: + return "rgb"; + case ImageColorType::indexed: + return "indexed"; + case ImageColorType::grayscale_alpha: + return "grayscale_alpha"; + case ImageColorType::rgba: + return "rgba"; + } + + return "unknown"; +} + +} diff --git a/src/assets/image_metadata.h b/src/assets/image_metadata.h new file mode 100644 index 0000000..ada05e0 --- /dev/null +++ b/src/assets/image_metadata.h @@ -0,0 +1,34 @@ +#pragma once + +#include "foundation/result.h" + +#include +#include +#include + +namespace pp::assets { + +constexpr std::uint32_t max_image_dimension = 262144; + +enum class ImageColorType : std::uint8_t { + grayscale, + rgb, + indexed, + grayscale_alpha, + rgba, +}; + +struct ImageMetadata { + std::uint32_t width = 0; + std::uint32_t height = 0; + std::uint8_t bit_depth = 0; + std::uint8_t components = 0; + ImageColorType color_type = ImageColorType::rgba; +}; + +[[nodiscard]] pp::foundation::Result parse_png_metadata( + std::span bytes) noexcept; + +[[nodiscard]] const char* image_color_type_name(ImageColorType color_type) noexcept; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 61eb6c7..27326e2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -76,6 +76,16 @@ add_test(NAME pp_assets_image_format_tests COMMAND pp_assets_image_format_tests) set_tests_properties(pp_assets_image_format_tests PROPERTIES LABELS "assets;desktop-fast") +add_executable(pp_assets_image_metadata_tests + assets/image_metadata_tests.cpp) +target_link_libraries(pp_assets_image_metadata_tests PRIVATE + pp_assets + pp_test_harness) + +add_test(NAME pp_assets_image_metadata_tests COMMAND pp_assets_image_metadata_tests) +set_tests_properties(pp_assets_image_metadata_tests PROPERTIES + LABELS "assets;desktop-fast") + add_executable(pp_assets_ppi_header_tests assets/ppi_header_tests.cpp) target_link_libraries(pp_assets_ppi_header_tests PRIVATE diff --git a/tests/assets/image_metadata_tests.cpp b/tests/assets/image_metadata_tests.cpp new file mode 100644 index 0000000..8efe177 --- /dev/null +++ b/tests/assets/image_metadata_tests.cpp @@ -0,0 +1,113 @@ +#include "assets/image_metadata.h" +#include "test_harness.h" + +#include +#include +#include + +using pp::assets::ImageColorType; +using pp::assets::image_color_type_name; +using pp::assets::max_image_dimension; +using pp::assets::parse_png_metadata; +using pp::foundation::StatusCode; + +namespace { + +using PngHeader = std::array; + +PngHeader make_png_header(std::uint32_t width, std::uint32_t height, std::byte bit_depth, std::byte color_type) +{ + PngHeader bytes { + std::byte { 0x89 }, std::byte { 0x50 }, std::byte { 0x4e }, std::byte { 0x47 }, + std::byte { 0x0d }, std::byte { 0x0a }, std::byte { 0x1a }, std::byte { 0x0a }, + std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x0d }, + std::byte { 'I' }, std::byte { 'H' }, std::byte { 'D' }, std::byte { 'R' }, + std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, + std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, + bit_depth, color_type, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, + std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, + }; + + bytes[16] = static_cast((width >> 24U) & 0xffU); + bytes[17] = static_cast((width >> 16U) & 0xffU); + bytes[18] = static_cast((width >> 8U) & 0xffU); + bytes[19] = static_cast(width & 0xffU); + bytes[20] = static_cast((height >> 24U) & 0xffU); + bytes[21] = static_cast((height >> 16U) & 0xffU); + bytes[22] = static_cast((height >> 8U) & 0xffU); + bytes[23] = static_cast(height & 0xffU); + return bytes; +} + +void parses_png_ihdr_metadata(pp::tests::Harness& h) +{ + const auto rgba = make_png_header(320, 240, std::byte { 8 }, std::byte { 6 }); + const auto rgb = make_png_header(17, 9, std::byte { 8 }, std::byte { 2 }); + + const auto rgba_result = parse_png_metadata(rgba); + const auto rgb_result = parse_png_metadata(rgb); + + PP_EXPECT(h, rgba_result.ok()); + PP_EXPECT(h, rgba_result.value().width == 320U); + PP_EXPECT(h, rgba_result.value().height == 240U); + PP_EXPECT(h, rgba_result.value().bit_depth == 8U); + PP_EXPECT(h, rgba_result.value().components == 4U); + PP_EXPECT(h, rgba_result.value().color_type == ImageColorType::rgba); + PP_EXPECT(h, image_color_type_name(rgba_result.value().color_type) == std::string_view("rgba")); + + PP_EXPECT(h, rgb_result.ok()); + PP_EXPECT(h, rgb_result.value().components == 3U); + PP_EXPECT(h, rgb_result.value().color_type == ImageColorType::rgb); +} + +void maps_png_color_type_components(pp::tests::Harness& h) +{ + const auto grayscale = parse_png_metadata(make_png_header(1, 1, std::byte { 8 }, std::byte { 0 })); + const auto indexed = parse_png_metadata(make_png_header(1, 1, std::byte { 8 }, std::byte { 3 })); + const auto gray_alpha = parse_png_metadata(make_png_header(1, 1, std::byte { 8 }, std::byte { 4 })); + + PP_EXPECT(h, grayscale.ok()); + PP_EXPECT(h, grayscale.value().components == 1U); + PP_EXPECT(h, grayscale.value().color_type == ImageColorType::grayscale); + PP_EXPECT(h, indexed.ok()); + PP_EXPECT(h, indexed.value().components == 1U); + PP_EXPECT(h, indexed.value().color_type == ImageColorType::indexed); + PP_EXPECT(h, gray_alpha.ok()); + PP_EXPECT(h, gray_alpha.value().components == 2U); + PP_EXPECT(h, gray_alpha.value().color_type == ImageColorType::grayscale_alpha); +} + +void rejects_corrupt_or_extreme_png_metadata(pp::tests::Harness& h) +{ + const std::array truncated { + std::byte { 0x89 }, std::byte { 0x50 }, std::byte { 0x4e }, std::byte { 0x47 }, + std::byte { 0x0d }, std::byte { 0x0a }, std::byte { 0x1a }, std::byte { 0x0a }, + }; + auto bad_signature = make_png_header(1, 1, std::byte { 8 }, std::byte { 6 }); + bad_signature[0] = std::byte { 0x00 }; + auto bad_ihdr = make_png_header(1, 1, std::byte { 8 }, std::byte { 6 }); + bad_ihdr[15] = std::byte { 'X' }; + const auto zero_width = make_png_header(0, 1, std::byte { 8 }, std::byte { 6 }); + const auto too_large = make_png_header(max_image_dimension + 1U, 1, std::byte { 8 }, std::byte { 6 }); + const auto bad_depth = make_png_header(1, 1, std::byte { 0 }, std::byte { 6 }); + const auto bad_color = make_png_header(1, 1, std::byte { 8 }, std::byte { 5 }); + + PP_EXPECT(h, parse_png_metadata(truncated).status().code == StatusCode::out_of_range); + PP_EXPECT(h, parse_png_metadata(bad_signature).status().code == StatusCode::invalid_argument); + PP_EXPECT(h, parse_png_metadata(bad_ihdr).status().code == StatusCode::invalid_argument); + PP_EXPECT(h, parse_png_metadata(zero_width).status().code == StatusCode::out_of_range); + PP_EXPECT(h, parse_png_metadata(too_large).status().code == StatusCode::out_of_range); + PP_EXPECT(h, parse_png_metadata(bad_depth).status().code == StatusCode::invalid_argument); + PP_EXPECT(h, parse_png_metadata(bad_color).status().code == StatusCode::invalid_argument); +} + +} + +int main() +{ + pp::tests::Harness harness; + harness.run("parses_png_ihdr_metadata", parses_png_ihdr_metadata); + harness.run("maps_png_color_type_components", maps_png_color_type_components); + harness.run("rejects_corrupt_or_extreme_png_metadata", rejects_corrupt_or_extreme_png_metadata); + return harness.finish(); +}