From 126280ff7cf9d2e7e8414e9a4bcd1f72927b2311 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 1 Jun 2026 00:26:06 +0200 Subject: [PATCH] Add PPI header recognition tests --- CMakeLists.txt | 3 +- docs/modernization/build-inventory.md | 2 +- docs/modernization/debt.md | 1 + docs/modernization/roadmap.md | 11 ++- scripts/automation/platform-build.ps1 | 2 +- scripts/automation/platform-build.sh | 2 +- src/assets/ppi_header.cpp | 73 +++++++++++++++++ src/assets/ppi_header.h | 40 ++++++++++ tests/CMakeLists.txt | 10 +++ tests/assets/ppi_header_tests.cpp | 108 ++++++++++++++++++++++++++ tools/pano_cli/main.cpp | 72 +++++++++++++++++ 11 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 src/assets/ppi_header.cpp create mode 100644 src/assets/ppi_header.h create mode 100644 tests/assets/ppi_header_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3393218..3385614 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,7 +75,8 @@ target_link_libraries(pp_foundation pp_project_warnings) add_library(pp_assets STATIC - src/assets/image_format.cpp) + src/assets/image_format.cpp + src/assets/ppi_header.cpp) target_include_directories(pp_assets PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 12e643b..9898b4d 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -78,7 +78,7 @@ 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 layout XML parse coverage. + including PPI header and layout XML parse coverage. - `vcpkg` is not on PATH yet; see DEBT-0007. Known warnings after the current CMake app build: diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index f641d54..1dabcbf 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -31,6 +31,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document model but is not yet wired to legacy `Canvas`, PPI load/save, selection masks, or undo/redo | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade | | DEBT-0011 | Open | Modernization | `package-smoke` validates the Windows CMake app artifact only, not AppX/APK/Apple/WebGL package outputs | Platform package targets are not migrated to root CMake yet | `powershell -ExecutionPolicy Bypass -File scripts\automation\package-smoke.ps1 -Preset windows-msvc-default -Configuration Debug` | Package-smoke covers Windows AppX, Android APK variants, Apple bundles, and WebGL output where local toolchains are present | | DEBT-0012 | Open | Modernization | `pp_vendor_tinyxml2` compiles the retained vendored tinyxml2 copy for `pp_ui_core` layout parsing | vcpkg is not validated yet, but layout parsing needs a structured XML parser now | `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | Replace with vcpkg tinyxml2 target once desktop and mobile triplets are validated | +| DEBT-0013 | Open | Modernization | `pp_assets` and `pano_cli inspect-project` recognize only the fixed PPI header, not thumbnail bytes or the project body | Full PPI parsing requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_ppi_header_tests` | Full PPI load/save fixtures cover thumbnail, layers, frames, metadata, corrupt payloads, and round-trip compatibility | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index fb4b1e3..21d55ad 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -245,8 +245,9 @@ Status: in progress. `tests/` exists, `desktop-fast` runs headlessly, and PowerShell/bash wrappers exist for configure/build/test/analyze/platform-build/package-smoke. `pano_cli` exists with JSON automation commands for creating a `pp_document` model and -inspecting image signatures; full document/app integration is debt-tracked as -DEBT-0010. +inspecting image signatures, PPI headers, and layout XML; full document/app +integration is debt-tracked as DEBT-0010 and full PPI body parsing is +debt-tracked as DEBT-0013. Implementation tasks: @@ -304,7 +305,8 @@ boundary/overread tests. It also owns strict decimal `uint32` parsing used by `pano_cli`, with rejection tests for empty, signed, mixed, and overflowing input. A deterministic `TraceRecorder` now records component/name/thread/frame and stroke timing spans with invalid-end tests. `pp_assets` has started with -PNG/JPEG signature detection and corrupt/truncated/unsupported tests. +PNG/JPEG signature detection plus PPI header recognition, with +corrupt/truncated/unsupported tests. `pp_paint` has started with CPU reference math for the five current shader blend modes. `pp_document` has started with a pure canvas/layer/frame model and layer/frame invariant tests. `pp_renderer_api` has started with renderer-neutral @@ -518,7 +520,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_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_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_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_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 @@ -533,6 +535,7 @@ Results: - `pp_foundation_parse_tests` passed. - `pp_foundation_trace_tests` passed. - `pp_assets_image_format_tests` passed. +- `pp_assets_ppi_header_tests` passed. - `pp_paint_blend_tests` passed. - `pp_document_tests` passed. - `pp_renderer_api_tests` passed. diff --git a/scripts/automation/platform-build.ps1 b/scripts/automation/platform-build.ps1 index 10d0169..84eacec 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_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_paint_blend_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_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_parse_tests", "pp_foundation_trace_tests", "pp_assets_image_format_tests", "pp_assets_ppi_header_tests", "pp_paint_blend_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_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 733c235..032d55a 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_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_paint_blend_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_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_parse_tests pp_foundation_trace_tests pp_assets_image_format_tests pp_assets_ppi_header_tests pp_paint_blend_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" start="$(date +%s)" cmake --preset "$preset" diff --git a/src/assets/ppi_header.cpp b/src/assets/ppi_header.cpp new file mode 100644 index 0000000..5245c3f --- /dev/null +++ b/src/assets/ppi_header.cpp @@ -0,0 +1,73 @@ +#include "assets/ppi_header.h" + +#include "foundation/binary_stream.h" + +namespace pp::assets { + +namespace { + +[[nodiscard]] pp::foundation::Result read_u32(pp::foundation::ByteReader& reader) noexcept +{ + return reader.read_u32_le(); +} + +} + +pp::foundation::Result parse_ppi_header(std::span bytes) noexcept +{ + if (bytes.size() < ppi_header_size) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("PPI header is truncated")); + } + + pp::foundation::ByteReader reader(bytes.subspan(0, ppi_header_size)); + const auto magic = reader.read_bytes(4); + if (!magic || magic.value()[0] != std::byte { 'P' } || magic.value()[1] != std::byte { 'P' } + || magic.value()[2] != std::byte { 'I' } || magic.value()[3] != std::byte { 0 }) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI header magic is invalid")); + } + + PpiHeaderInfo info; + const auto doc_major = read_u32(reader); + const auto doc_minor = read_u32(reader); + const auto soft_major = read_u32(reader); + const auto soft_minor = read_u32(reader); + const auto soft_fix = read_u32(reader); + const auto soft_build = read_u32(reader); + const auto thumb_width = read_u32(reader); + const auto thumb_height = read_u32(reader); + const auto thumb_components = read_u32(reader); + if (!doc_major || !doc_minor || !soft_major || !soft_minor || !soft_fix || !soft_build + || !thumb_width || !thumb_height || !thumb_components) { + return pp::foundation::Result::failure( + pp::foundation::Status::out_of_range("PPI header is truncated")); + } + + info.document_version = { doc_major.value(), doc_minor.value() }; + info.software_version = { + soft_major.value(), + soft_minor.value(), + soft_fix.value(), + soft_build.value(), + }; + info.thumbnail = { + thumb_width.value(), + thumb_height.value(), + thumb_components.value(), + }; + + if (info.document_version.major != 0 || info.document_version.minor < 1) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI document version is unsupported")); + } + + if (info.thumbnail.width != 128 || info.thumbnail.height != 128 || info.thumbnail.components != 4) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("PPI thumbnail descriptor is invalid")); + } + + return pp::foundation::Result::success(info); +} + +} diff --git a/src/assets/ppi_header.h b/src/assets/ppi_header.h new file mode 100644 index 0000000..086196c --- /dev/null +++ b/src/assets/ppi_header.h @@ -0,0 +1,40 @@ +#pragma once + +#include "foundation/result.h" + +#include +#include +#include + +namespace pp::assets { + +constexpr std::size_t ppi_header_size = 40; + +struct PpiVersion { + std::uint32_t major = 0; + std::uint32_t minor = 0; +}; + +struct PpiSoftwareVersion { + std::uint32_t major = 0; + std::uint32_t minor = 0; + std::uint32_t fix = 0; + std::uint32_t build = 0; +}; + +struct PpiThumbnailInfo { + std::uint32_t width = 0; + std::uint32_t height = 0; + std::uint32_t components = 0; +}; + +struct PpiHeaderInfo { + PpiVersion document_version; + PpiSoftwareVersion software_version; + PpiThumbnailInfo thumbnail; +}; + +[[nodiscard]] pp::foundation::Result parse_ppi_header( + std::span bytes) noexcept; + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cf7eaf8..b04bb8b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,6 +46,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_ppi_header_tests + assets/ppi_header_tests.cpp) +target_link_libraries(pp_assets_ppi_header_tests PRIVATE + pp_assets + pp_test_harness) + +add_test(NAME pp_assets_ppi_header_tests COMMAND pp_assets_ppi_header_tests) +set_tests_properties(pp_assets_ppi_header_tests PROPERTIES + LABELS "assets;desktop-fast") + add_executable(pp_paint_blend_tests paint/blend_tests.cpp) target_link_libraries(pp_paint_blend_tests PRIVATE diff --git a/tests/assets/ppi_header_tests.cpp b/tests/assets/ppi_header_tests.cpp new file mode 100644 index 0000000..b74af91 --- /dev/null +++ b/tests/assets/ppi_header_tests.cpp @@ -0,0 +1,108 @@ +#include "assets/ppi_header.h" +#include "test_harness.h" + +#include +#include +#include +#include + +using pp::assets::parse_ppi_header; +using pp::assets::ppi_header_size; +using pp::foundation::StatusCode; + +namespace { + +void append_u32(std::vector& bytes, std::uint32_t value) +{ + bytes.push_back(static_cast(value & 0xffU)); + bytes.push_back(static_cast((value >> 8U) & 0xffU)); + bytes.push_back(static_cast((value >> 16U) & 0xffU)); + bytes.push_back(static_cast((value >> 24U) & 0xffU)); +} + +std::vector valid_header() +{ + std::vector bytes { + std::byte { 'P' }, + std::byte { 'P' }, + std::byte { 'I' }, + std::byte { 0 }, + }; + append_u32(bytes, 0); + append_u32(bytes, 4); + append_u32(bytes, 0); + append_u32(bytes, 2); + append_u32(bytes, 3); + append_u32(bytes, 1024); + append_u32(bytes, 128); + append_u32(bytes, 128); + append_u32(bytes, 4); + return bytes; +} + +void parses_legacy_ppi_header(pp::tests::Harness& h) +{ + const auto bytes = valid_header(); + const auto header = parse_ppi_header(bytes); + + PP_EXPECT(h, bytes.size() == ppi_header_size); + PP_EXPECT(h, header.ok()); + PP_EXPECT(h, header.value().document_version.major == 0U); + PP_EXPECT(h, header.value().document_version.minor == 4U); + PP_EXPECT(h, header.value().software_version.fix == 3U); + PP_EXPECT(h, header.value().software_version.build == 1024U); + PP_EXPECT(h, header.value().thumbnail.width == 128U); + PP_EXPECT(h, header.value().thumbnail.height == 128U); + PP_EXPECT(h, header.value().thumbnail.components == 4U); +} + +void rejects_truncated_invalid_magic_and_bad_thumbnail(pp::tests::Harness& h) +{ + auto truncated = valid_header(); + truncated.pop_back(); + + auto bad_magic = valid_header(); + bad_magic[0] = std::byte { 'X' }; + + auto bad_thumb = valid_header(); + bad_thumb[32] = std::byte { 64 }; + + const auto truncated_result = parse_ppi_header(truncated); + const auto magic_result = parse_ppi_header(bad_magic); + const auto thumb_result = parse_ppi_header(bad_thumb); + + PP_EXPECT(h, !truncated_result.ok()); + PP_EXPECT(h, truncated_result.status().code == StatusCode::out_of_range); + PP_EXPECT(h, !magic_result.ok()); + PP_EXPECT(h, magic_result.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !thumb_result.ok()); + PP_EXPECT(h, thumb_result.status().code == StatusCode::invalid_argument); +} + +void rejects_unsupported_document_versions(pp::tests::Harness& h) +{ + auto bad_major = valid_header(); + bad_major[4] = std::byte { 1 }; + + auto bad_minor = valid_header(); + bad_minor[8] = std::byte { 0 }; + + const auto major_result = parse_ppi_header(bad_major); + const auto minor_result = parse_ppi_header(bad_minor); + + PP_EXPECT(h, !major_result.ok()); + PP_EXPECT(h, major_result.status().code == StatusCode::invalid_argument); + PP_EXPECT(h, !minor_result.ok()); + PP_EXPECT(h, minor_result.status().code == StatusCode::invalid_argument); +} + +} + +int main() +{ + pp::tests::Harness harness; + harness.run("parses_legacy_ppi_header", parses_legacy_ppi_header); + harness.run("rejects_truncated_invalid_magic_and_bad_thumbnail", rejects_truncated_invalid_magic_and_bad_thumbnail); + harness.run("rejects_unsupported_document_versions", rejects_unsupported_document_versions); + return harness.finish(); +} diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index 9d17d70..d0e12c3 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -1,4 +1,5 @@ #include "assets/image_format.h" +#include "assets/ppi_header.h" #include "document/document.h" #include "foundation/parse.h" #include "foundation/result.h" @@ -28,6 +29,10 @@ struct ParseLayoutArgs { std::string path; }; +struct InspectProjectArgs { + std::string path; +}; + void print_error(std::string_view command, std::string_view message) { std::cout << "{\"ok\":false,\"command\":\"" << command @@ -40,6 +45,7 @@ void print_help() << "pano_cli commands:\n" << " create-document --width N --height N [--layers N]\n" << " inspect-image --path FILE\n" + << " inspect-project --path FILE\n" << " parse-layout --path FILE\n" << " --help\n"; } @@ -166,6 +172,68 @@ int inspect_image(int argc, char** argv) return 0; } +pp::foundation::Status parse_inspect_project_args(int argc, char** argv, InspectProjectArgs& 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 { + return pp::foundation::Status::invalid_argument("unknown option"); + } + } + + if (args.path.empty()) { + return pp::foundation::Status::invalid_argument("path must not be empty"); + } + + return pp::foundation::Status::success(); +} + +int inspect_project(int argc, char** argv) +{ + InspectProjectArgs args; + const auto status = parse_inspect_project_args(argc, argv, args); + if (!status.ok()) { + print_error("inspect-project", status.message); + return 2; + } + + std::ifstream stream(args.path, std::ios::binary); + if (!stream) { + print_error("inspect-project", "project file could not be opened"); + return 2; + } + + const std::vector chars { + std::istreambuf_iterator(stream), + std::istreambuf_iterator() + }; + const auto* data = reinterpret_cast(chars.data()); + const auto header = pp::assets::parse_ppi_header(std::span(data, chars.size())); + if (!header) { + print_error("inspect-project", header.status().message); + return 2; + } + + std::cout << "{\"ok\":true,\"command\":\"inspect-project\"" + << ",\"documentVersion\":\"" << header.value().document_version.major + << "." << header.value().document_version.minor << "\"" + << ",\"softwareVersion\":\"" << header.value().software_version.major + << "." << header.value().software_version.minor + << "." << header.value().software_version.fix + << "." << header.value().software_version.build << "\"" + << ",\"thumbnail\":{\"width\":" << header.value().thumbnail.width + << ",\"height\":" << header.value().thumbnail.height + << ",\"components\":" << header.value().thumbnail.components + << "}}\n"; + return 0; +} + pp::foundation::Status parse_layout_args(int argc, char** argv, ParseLayoutArgs& args) { for (int i = 2; i < argc; ++i) { @@ -243,6 +311,10 @@ int main(int argc, char** argv) return inspect_image(argc, argv); } + if (command == "inspect-project") { + return inspect_project(argc, argv); + } + if (command == "parse-layout") { return parse_layout(argc, argv); }