Add PPI header recognition tests

This commit is contained in:
2026-06-01 00:26:06 +02:00
parent 20b5dba41e
commit 126280ff7c
11 changed files with 316 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

73
src/assets/ppi_header.cpp Normal file
View File

@@ -0,0 +1,73 @@
#include "assets/ppi_header.h"
#include "foundation/binary_stream.h"
namespace pp::assets {
namespace {
[[nodiscard]] pp::foundation::Result<std::uint32_t> read_u32(pp::foundation::ByteReader& reader) noexcept
{
return reader.read_u32_le();
}
}
pp::foundation::Result<PpiHeaderInfo> parse_ppi_header(std::span<const std::byte> bytes) noexcept
{
if (bytes.size() < ppi_header_size) {
return pp::foundation::Result<PpiHeaderInfo>::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<PpiHeaderInfo>::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<PpiHeaderInfo>::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<PpiHeaderInfo>::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<PpiHeaderInfo>::failure(
pp::foundation::Status::invalid_argument("PPI thumbnail descriptor is invalid"));
}
return pp::foundation::Result<PpiHeaderInfo>::success(info);
}
}

40
src/assets/ppi_header.h Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
#include "foundation/result.h"
#include <cstddef>
#include <cstdint>
#include <span>
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<PpiHeaderInfo> parse_ppi_header(
std::span<const std::byte> bytes) noexcept;
}

View File

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

View File

@@ -0,0 +1,108 @@
#include "assets/ppi_header.h"
#include "test_harness.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <vector>
using pp::assets::parse_ppi_header;
using pp::assets::ppi_header_size;
using pp::foundation::StatusCode;
namespace {
void append_u32(std::vector<std::byte>& bytes, std::uint32_t value)
{
bytes.push_back(static_cast<std::byte>(value & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 8U) & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 16U) & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 24U) & 0xffU));
}
std::vector<std::byte> valid_header()
{
std::vector<std::byte> 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();
}

View File

@@ -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<char> chars {
std::istreambuf_iterator<char>(stream),
std::istreambuf_iterator<char>()
};
const auto* data = reinterpret_cast<const std::byte*>(chars.data());
const auto header = pp::assets::parse_ppi_header(std::span<const std::byte>(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);
}