Attach PPI pixels to documents

This commit is contained in:
2026-06-01 13:43:27 +02:00
parent 88507df90e
commit ad255a6ddf
14 changed files with 569 additions and 104 deletions

View File

@@ -135,13 +135,15 @@ target_link_libraries(pp_paint
pp_project_warnings)
add_library(pp_document STATIC
src/document/document.cpp)
src/document/document.cpp
src/document/ppi_import.cpp)
target_include_directories(pp_document
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(pp_document
PUBLIC
pp_foundation
pp_assets
pp_paint
pp_project_options
PRIVATE

View File

@@ -92,7 +92,7 @@ Known local toolchain state:
`pp_ui_core`, `pano_cli`, and their current headless test binaries,
including foundation event/logging/task queue coverage, PNG metadata and
decode, PPI header/layout, settings document, document
snapshot/per-layer-frame/move/duration coverage, paint
snapshot/per-layer-frame/move/duration/face-pixel coverage, paint
brush/stroke/stroke-script coverage, renderer shader descriptor coverage,
UI color parsing, and layout XML parse coverage.
- `pano_cli inspect-image` reports PNG IHDR metadata as JSON and is covered by
@@ -103,8 +103,12 @@ Known local toolchain state:
minimal PPI fixture.
- `pp_assets_image_pixels_tests` decodes PNG payloads to RGBA8 and rejects
corrupt image payloads.
- `pano_cli load-project` creates a metadata-only `pp_document` projection with
per-layer frame counts and durations, and is covered by
- `pp_document_ppi_import_tests` attaches decoded PPI dirty-face payloads to
`pp_document` layer/frame storage and rejects payloads outside document
layers.
- `pano_cli load-project` creates a `pp_document` projection with per-layer
frame counts, durations, and decoded face-pixel payloads when present; the
metadata-only minimal fixture remains covered by
`pano_cli_load_project_metadata_smoke`.
- `pano_cli create-document` supports `--frames` and `--frame-duration-ms` and
is covered by `pano_cli_create_animation_document_smoke`.

View File

@@ -28,10 +28,10 @@ agent or engineer to remove them without reconstructing context from chat.
| DEBT-0007 | Open | Modernization | `vcpkg.json` and `windows-msvc-vcpkg-headless` are validated for the headless Windows component matrix, but app targets still use vendored libraries and Android/Apple triplets are not proven | Dependency migration must stay incremental while SDK/patched/vendor dependencies remain in use | `$env:VCPKG_ROOT="C:\Program Files\Microsoft Visual Studio\2022\Community\VC\vcpkg"; cmake --preset windows-msvc-vcpkg-headless`; `ctest --preset desktop-fast-vcpkg --build-config Debug` | Component targets consume vcpkg packages where reliable and desktop app, Android, and Apple triplets are validated or explicitly documented as permanent vendor exceptions |
| DEBT-0008 | Open | Modernization | `windows-msvc-default` preset is used for local validation because the VS 2026 generator is not installed here | The target VS 2026 preset must remain, but this machine configures with Visual Studio 17 2022 | `cmake --preset windows-msvc-default`; `ctest --preset desktop-fast --build-config Debug` | Validate `windows-vs2026-x64` on a machine with Visual Studio 2026 installed and make it the default Windows validation preset |
| DEBT-0009 | Open | Modernization | Android root CMake validation currently builds headless targets only, not APK/package variants | Platform app entrypoints still live in legacy Gradle/CMake projects and need Phase 6 alignment | `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | Android standard, Quest, and Focus/Wave package targets consume shared component targets and have package smoke commands |
| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model with alpha-lock metadata, snapshot construction, and per-layer frame metadata, but it is not yet wired to legacy `Canvas`, PPI pixel load/save, selection masks, or legacy action commands | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2`; `pano_cli load-project --path tests\data\projects\minimal-project.ppi` | Legacy document behavior is represented by `pp_document` tests and the app consumes it through a boundary/facade |
| DEBT-0010 | Open | Modernization | `pp_document` is a pure layer/frame/document/undo-history model with alpha-lock metadata, snapshot construction, per-layer frame metadata, and renderer-free RGBA8 face payload storage, but it is not yet wired to legacy `Canvas`, selection masks, save, or legacy action commands | Keep extraction incremental while preserving app behavior | `ctest --preset desktop-fast --build-config Debug`; `pano_cli create-document --width 64 --height 32 --layers 2`; `pano_cli load-project --path tests\data\projects\minimal-project.ppi`; `pp_document_ppi_import_tests` | 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_ui_core` uses vcpkg tinyxml2 on `windows-msvc-vcpkg-headless`, but retains `pp_vendor_tinyxml2` for default and unproven platform presets | Mobile/AppX/Apple triplets and app packaging still need validation before removing the vendored fallback | `ctest --preset desktop-fast-vcpkg --build-config Debug`; `ctest --preset desktop-fast --build-config Debug`; `powershell -ExecutionPolicy Bypass -File scripts\automation\platform-build.ps1 -Presets android-arm64` | All supported presets consume vcpkg tinyxml2 or document a permanent vendored exception |
| DEBT-0013 | Open | Modernization | `pp_assets`, `pano_cli inspect-project`, and metadata-only `pano_cli load-project` validate the fixed PPI header, thumbnail/body byte layout, layer/frame index, dirty-face descriptors, dirty-face PNG payload metadata, and asset-level RGBA PNG payload decoding, but do not yet attach decoded pixels to `pp_document` layer/frame/face storage | Full PPI parsing requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_image_pixels_tests`; `pp_assets_ppi_header_tests`; `pano_cli_inspect_project_layout_smoke`; `pano_cli_load_project_metadata_smoke` | Full PPI load/save fixtures cover thumbnail, decoded layer face payloads attached to documents, frames, metadata, corrupt payloads, and round-trip compatibility |
| DEBT-0013 | Open | Modernization | `pp_assets`, `pano_cli inspect-project`, and `pano_cli load-project` validate the fixed PPI header, thumbnail/body byte layout, layer/frame index, dirty-face descriptors, dirty-face PNG payload metadata, asset-level RGBA PNG payload decoding, and decoded pixel attachment to `pp_document`, but full PPI save/round-trip fixtures are not yet extracted | Full PPI parsing requires staged extraction of legacy `Canvas` serialization and image/layer payload handling | `ctest --preset desktop-fast --build-config Debug`; `pp_assets_image_pixels_tests`; `pp_assets_ppi_header_tests`; `pp_document_ppi_import_tests`; `pano_cli_inspect_project_layout_smoke`; `pano_cli_load_project_metadata_smoke` | Full PPI load/save fixtures cover thumbnail, decoded layer face payloads attached to documents, frames, metadata, corrupt payloads, and round-trip compatibility |
| DEBT-0014 | Open | Modernization | `windows-clangcl-asan` now configures as a headless Ninja/clang-cl preset and uses the release MSVC runtime required by ASan, but local builds still fail because installed clang-cl 18.1.8 is paired with VS 2026-preview STL headers that require Clang 20 or newer | Sanitizer validation should be local and repeatable, but this machine's compiler/header pairing is incompatible | `cmake --fresh --preset windows-clangcl-asan`; `cmake --build --preset windows-clangcl-asan --target pp_foundation` | Install/use Clang 20+ with the VS 2026 STL, or point the preset at a compatible VS 2022 toolchain, then make `platform-build.ps1 -Presets windows-clangcl-asan` pass for the headless matrix |
## Closed Debt

View File

@@ -323,7 +323,8 @@ stroke spacing/interpolation plus a pure text stroke-script parser.
`pp_document` has
started with a pure canvas/layer/frame model, alpha-lock metadata, snapshot
construction, per-layer frame metadata, layer metadata operations, frame
move/duration queries, and layer/frame/undo-redo history invariant tests.
move/duration queries, renderer-free RGBA8 cube-face payload storage, PPI image
import, and layer/frame/undo-redo history invariant tests.
`pp_renderer_api` has started with renderer-neutral
texture/readback descriptors and validation tests. `pp_paint_renderer` has
started with deterministic CPU layer compositing over renderer extents using
@@ -334,8 +335,9 @@ input tests.
`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
`pano_cli load-project` creates a metadata-only `pp_document` projection with
per-layer frame counts and durations.
`pano_cli load-project` creates a `pp_document` projection with per-layer frame
counts, durations, and decoded face-pixel payload attachment when PPI image
payloads are present.
`pano_cli create-document` can create simple animation documents with explicit
frame count/duration, and `pano_cli simulate-stroke` exercises the pure stroke
sampler for scripted-stroke automation. `pano_cli simulate-stroke-script`
@@ -548,7 +550,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_image_metadata_tests pp_assets_image_pixels_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_image_pixels_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_document_ppi_import_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
@@ -585,8 +587,11 @@ Results:
- `pp_paint_stroke_tests` passed.
- `pp_paint_stroke_script_tests` passed.
- `pp_document_tests` passed, including snapshot construction, alpha-lock
metadata, per-layer frame metadata, frame move, duration, and history
invariants.
metadata, per-layer frame metadata, frame move, duration, face-pixel payload
storage/replacement/rejection, and history invariants.
- `pp_document_ppi_import_tests` passed, including decoded PPI dirty-face
payload attachment to `pp_document` layer/frame storage and out-of-range
payload rejection.
- `pp_renderer_api_tests` passed, including shader descriptor validation.
- `pp_paint_renderer_compositor_tests` passed.
- `pp_ui_core_color_tests` passed.
@@ -602,9 +607,9 @@ Results:
- `pano_cli_inspect_project_layout_smoke` passed and reports PPI
thumbnail/body byte layout, body summary, layer/frame descriptors, and
dirty-face PNG payload metadata JSON.
- `pano_cli_load_project_metadata_smoke` passed and reports a metadata-only
`pp_document` projection with per-layer frame counts and durations for the
minimal PPI fixture.
- `pano_cli_load_project_metadata_smoke` passed and reports a `pp_document`
projection with per-layer frame counts, durations, and zero loaded face
payloads for the minimal PPI fixture.
- `pano_cli_parse_layout_smoke` passed.
- `pano_cli_simulate_stroke_smoke` passed and reports deterministic stroke
sample counts/distances.

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_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_image_pixels_tests", "pp_assets_ppi_header_tests", "pp_assets_settings_document_tests", "pp_paint_brush_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_paint_stroke_script_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_image_pixels_tests", "pp_assets_ppi_header_tests", "pp_assets_settings_document_tests", "pp_paint_brush_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_paint_stroke_script_tests", "pp_document_tests", "pp_document_ppi_import_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"

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_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_image_pixels_tests pp_assets_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_paint_stroke_script_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_image_pixels_tests pp_assets_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_paint_stroke_script_tests pp_document_tests pp_document_ppi_import_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"

View File

@@ -2,6 +2,8 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
namespace pp::document {
@@ -105,6 +107,67 @@ namespace {
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Result<std::size_t> rgba8_byte_size(
std::uint32_t width,
std::uint32_t height) noexcept
{
const auto width64 = static_cast<std::uint64_t>(width);
const auto height64 = static_cast<std::uint64_t>(height);
if (width64 > std::numeric_limits<std::uint64_t>::max() / height64) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("face pixel dimensions overflow"));
}
const auto pixels = width64 * height64;
if (pixels > std::numeric_limits<std::uint64_t>::max() / rgba8_components) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("face pixel byte size overflows"));
}
const auto bytes = pixels * rgba8_components;
if (bytes > max_face_pixel_payload_bytes) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("face pixel payload exceeds the configured limit"));
}
if (bytes > static_cast<std::uint64_t>(std::numeric_limits<std::size_t>::max())) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("face pixel payload exceeds addressable memory"));
}
return pp::foundation::Result<std::size_t>::success(static_cast<std::size_t>(bytes));
}
[[nodiscard]] pp::foundation::Status validate_face_pixels(
LayerFacePixels pixels,
std::uint32_t document_width,
std::uint32_t document_height) noexcept
{
if (pixels.face_index >= cube_face_count) {
return pp::foundation::Status::out_of_range("cube face index is outside the document");
}
if (pixels.width == 0 || pixels.height == 0) {
return pp::foundation::Status::invalid_argument("face pixel dimensions must be greater than zero");
}
if (pixels.x > document_width || pixels.width > document_width - pixels.x
|| pixels.y > document_height || pixels.height > document_height - pixels.y) {
return pp::foundation::Status::out_of_range("face pixel rectangle is outside the document");
}
const auto expected_bytes = rgba8_byte_size(pixels.width, pixels.height);
if (!expected_bytes) {
return expected_bytes.status();
}
if (pixels.rgba8.size() != expected_bytes.value()) {
return pp::foundation::Status::invalid_argument("face pixel payload byte size does not match dimensions");
}
return pp::foundation::Status::success();
}
}
pp::foundation::Result<CanvasDocument> CanvasDocument::create(DocumentConfig config)
@@ -251,6 +314,17 @@ pp::foundation::Result<std::uint64_t> CanvasDocument::layer_animation_duration_m
return pp::foundation::Result<std::uint64_t>::success(frame_duration_sum(layers_[index].frames));
}
std::size_t CanvasDocument::face_pixel_payload_count() const noexcept
{
std::size_t count = 0;
for (const auto& layer : layers_) {
for (const auto& frame : layer.frames) {
count += frame.face_pixels.size();
}
}
return count;
}
std::span<const Layer> CanvasDocument::layers() const noexcept
{
return layers_;
@@ -423,9 +497,9 @@ pp::foundation::Result<std::size_t> CanvasDocument::add_frame(std::uint32_t dura
duration_status);
}
frames_.push_back(AnimationFrame { .duration_ms = duration_ms });
frames_.push_back(AnimationFrame { .duration_ms = duration_ms, .face_pixels = {} });
for (auto& layer : layers_) {
layer.frames.push_back(AnimationFrame { .duration_ms = duration_ms });
layer.frames.push_back(AnimationFrame { .duration_ms = duration_ms, .face_pixels = {} });
}
active_frame_index_ = frames_.size() - 1U;
return pp::foundation::Result<std::size_t>::success(active_frame_index_);
@@ -547,6 +621,41 @@ pp::foundation::Status CanvasDocument::set_active_frame(std::size_t index) noexc
return pp::foundation::Status::success();
}
pp::foundation::Status CanvasDocument::set_layer_frame_face_pixels(
std::size_t layer_index,
std::size_t frame_index,
LayerFacePixels pixels)
{
const auto layer_status = validate_layer_index(layer_index, layers_.size());
if (!layer_status.ok()) {
return layer_status;
}
const auto frame_status = validate_frame_index(frame_index, layers_[layer_index].frames.size());
if (!frame_status.ok()) {
return frame_status;
}
const auto pixels_status = validate_face_pixels(pixels, width_, height_);
if (!pixels_status.ok()) {
return pixels_status;
}
auto& faces = layers_[layer_index].frames[frame_index].face_pixels;
const auto existing = std::find_if(
faces.begin(),
faces.end(),
[face_index = pixels.face_index](const LayerFacePixels& face) {
return face.face_index == face_index;
});
if (existing == faces.end()) {
faces.push_back(std::move(pixels));
} else {
*existing = std::move(pixels);
}
return pp::foundation::Status::success();
}
pp::foundation::Result<DocumentHistory> DocumentHistory::create(
CanvasDocument initial_document,
std::size_t max_entries)

View File

@@ -18,6 +18,9 @@ constexpr std::uint32_t min_frame_duration_ms = 1;
constexpr std::size_t min_document_history_entries = 2;
constexpr std::size_t max_document_history_entries = 10000;
constexpr std::size_t max_layer_name_length = 128;
constexpr std::uint32_t cube_face_count = 6;
constexpr std::uint32_t rgba8_components = 4;
constexpr std::uint64_t max_face_pixel_payload_bytes = 1024ULL * 1024ULL * 1024ULL;
struct DocumentConfig {
std::uint32_t width = 0;
@@ -25,8 +28,18 @@ struct DocumentConfig {
std::uint32_t layer_count = 1;
};
struct LayerFacePixels {
std::uint32_t face_index = 0;
std::uint32_t x = 0;
std::uint32_t y = 0;
std::uint32_t width = 0;
std::uint32_t height = 0;
std::vector<std::uint8_t> rgba8;
};
struct AnimationFrame {
std::uint32_t duration_ms = 100;
std::vector<LayerFacePixels> face_pixels;
};
struct Layer {
@@ -65,6 +78,7 @@ public:
[[nodiscard]] std::size_t active_frame_index() const noexcept;
[[nodiscard]] std::uint64_t animation_duration_ms() const noexcept;
[[nodiscard]] pp::foundation::Result<std::uint64_t> layer_animation_duration_ms(std::size_t index) const noexcept;
[[nodiscard]] std::size_t face_pixel_payload_count() const noexcept;
[[nodiscard]] std::span<const Layer> layers() const noexcept;
[[nodiscard]] std::span<const AnimationFrame> frames() const noexcept;
@@ -84,6 +98,10 @@ public:
[[nodiscard]] pp::foundation::Status move_frame(std::size_t from, std::size_t to);
[[nodiscard]] pp::foundation::Status set_frame_duration(std::size_t index, std::uint32_t duration_ms) noexcept;
[[nodiscard]] pp::foundation::Status set_active_frame(std::size_t index) noexcept;
[[nodiscard]] pp::foundation::Status set_layer_frame_face_pixels(
std::size_t layer_index,
std::size_t frame_index,
LayerFacePixels pixels);
private:
std::uint32_t width_ = 0;

116
src/document/ppi_import.cpp Normal file
View File

@@ -0,0 +1,116 @@
#include "document/ppi_import.h"
#include <utility>
#include <span>
#include <vector>
namespace pp::document {
namespace {
[[nodiscard]] pp::foundation::Result<pp::paint::BlendMode> ppi_layer_blend_mode(
std::uint32_t blend_mode) noexcept
{
switch (blend_mode) {
case 0:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::normal);
case 1:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::multiply);
case 2:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::screen);
case 3:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::color_dodge);
case 4:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::overlay);
default:
return pp::foundation::Result<pp::paint::BlendMode>::failure(
pp::foundation::Status::invalid_argument("PPI layer blend mode is not supported by pp_document"));
}
}
[[nodiscard]] pp::foundation::Result<CanvasDocument> document_from_ppi_index(
const pp::assets::PpiProjectIndex& project)
{
if (project.body.layers.empty()) {
return pp::foundation::Result<CanvasDocument>::failure(
pp::foundation::Status::invalid_argument("PPI project has no layers"));
}
const auto& reference_frames = project.body.layers.front().frames;
if (reference_frames.empty()) {
return pp::foundation::Result<CanvasDocument>::failure(
pp::foundation::Status::invalid_argument("PPI project has no frames"));
}
std::vector<AnimationFrame> frames;
frames.reserve(reference_frames.size());
for (const auto& frame : reference_frames) {
frames.push_back(AnimationFrame { .duration_ms = frame.duration_ms, .face_pixels = {} });
}
std::vector<std::vector<AnimationFrame>> layer_frames;
layer_frames.reserve(project.body.layers.size());
std::vector<DocumentLayerConfig> layers;
layers.reserve(project.body.layers.size());
for (const auto& layer : project.body.layers) {
const auto blend_mode = ppi_layer_blend_mode(layer.blend_mode);
if (!blend_mode) {
return pp::foundation::Result<CanvasDocument>::failure(blend_mode.status());
}
auto& frame_list = layer_frames.emplace_back();
frame_list.reserve(layer.frames.size());
for (const auto& frame : layer.frames) {
frame_list.push_back(AnimationFrame { .duration_ms = frame.duration_ms, .face_pixels = {} });
}
layers.push_back(DocumentLayerConfig {
.name = layer.name,
.visible = layer.visible,
.alpha_locked = layer.alpha_locked,
.opacity = layer.opacity,
.blend_mode = blend_mode.value(),
.frames = std::span<const AnimationFrame>(frame_list.data(), frame_list.size()),
});
}
return CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = project.body.summary.width,
.height = project.body.summary.height,
.layers = layers,
.frames = frames,
});
}
}
pp::foundation::Result<CanvasDocument> import_ppi_project_document(
const pp::assets::PpiDecodedProjectImages& project)
{
auto document = document_from_ppi_index(project.project);
if (!document) {
return document;
}
auto value = document.value();
for (const auto& face : project.faces) {
const auto status = value.set_layer_frame_face_pixels(
face.layer_index,
face.frame_index,
LayerFacePixels {
.face_index = face.face_index,
.x = face.descriptor.x0,
.y = face.descriptor.y0,
.width = face.image.width,
.height = face.image.height,
.rgba8 = face.image.pixels,
});
if (!status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(status);
}
}
return pp::foundation::Result<CanvasDocument>::success(std::move(value));
}
}

11
src/document/ppi_import.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include "assets/ppi_header.h"
#include "document/document.h"
namespace pp::document {
[[nodiscard]] pp::foundation::Result<CanvasDocument> import_ppi_project_document(
const pp::assets::PpiDecodedProjectImages& project);
}

View File

@@ -166,6 +166,16 @@ add_test(NAME pp_document_tests COMMAND pp_document_tests)
set_tests_properties(pp_document_tests PROPERTIES
LABELS "document;desktop-fast")
add_executable(pp_document_ppi_import_tests
document/ppi_import_tests.cpp)
target_link_libraries(pp_document_ppi_import_tests PRIVATE
pp_document
pp_test_harness)
add_test(NAME pp_document_ppi_import_tests COMMAND pp_document_ppi_import_tests)
set_tests_properties(pp_document_ppi_import_tests PROPERTIES
LABELS "assets;document;integration;desktop-fast")
add_executable(pp_renderer_api_tests
renderer_api/renderer_api_tests.cpp)
target_link_libraries(pp_renderer_api_tests PRIVATE
@@ -250,7 +260,7 @@ if(TARGET pano_cli)
COMMAND pano_cli load-project --path "${CMAKE_CURRENT_SOURCE_DIR}/data/projects/minimal-project.ppi")
set_tests_properties(pano_cli_load_project_metadata_smoke PROPERTIES
LABELS "assets;document;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"command\":\"load-project\".*\"pixelDataLoaded\":false.*\"document\":\\{\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"animationDurationMs\":100,\"layerNames\":\\[\"Ink\"\\],\"layerFrameCounts\":\\[1\\],\"layerDurationsMs\":\\[100\\]")
PASS_REGULAR_EXPRESSION "\"command\":\"load-project\".*\"pixelDataLoaded\":false.*\"facePayloads\":0.*\"document\":\\{\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"animationDurationMs\":100,\"layerNames\":\\[\"Ink\"\\],\"layerFrameCounts\":\\[1\\],\"layerDurationsMs\":\\[100\\]")
add_test(NAME pano_cli_parse_layout_smoke
COMMAND pano_cli parse-layout --path "${CMAKE_CURRENT_SOURCE_DIR}/data/layouts/simple-layout.xml")

View File

@@ -11,6 +11,7 @@ using pp::document::DocumentConfig;
using pp::document::DocumentLayerConfig;
using pp::document::DocumentSnapshotConfig;
using pp::document::AnimationFrame;
using pp::document::LayerFacePixels;
using pp::document::max_document_history_entries;
using pp::document::max_canvas_dimension;
using pp::document::max_frame_count;
@@ -186,8 +187,8 @@ void creates_document_from_snapshot_metadata(pp::tests::Harness& h)
},
};
const AnimationFrame frames[] {
{ .duration_ms = 100 },
{ .duration_ms = 250 },
{ .duration_ms = 100, .face_pixels = {} },
{ .duration_ms = 250, .face_pixels = {} },
};
const auto document_result = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
@@ -212,14 +213,14 @@ void creates_document_from_snapshot_metadata(pp::tests::Harness& h)
void preserves_per_layer_snapshot_timelines(pp::tests::Harness& h)
{
const AnimationFrame project_frames[] {
{ .duration_ms = 100 },
{ .duration_ms = 100, .face_pixels = {} },
};
const AnimationFrame short_layer_frames[] {
{ .duration_ms = 100 },
{ .duration_ms = 150 },
{ .duration_ms = 100, .face_pixels = {} },
{ .duration_ms = 150, .face_pixels = {} },
};
const AnimationFrame long_layer_frames[] {
{ .duration_ms = 500 },
{ .duration_ms = 500, .face_pixels = {} },
};
const DocumentLayerConfig layers[] {
{
@@ -254,8 +255,8 @@ void preserves_per_layer_snapshot_timelines(pp::tests::Harness& h)
void rejects_invalid_snapshot_metadata(pp::tests::Harness& h)
{
const DocumentLayerConfig layers[] { { .name = "Ink", .frames = {} } };
const AnimationFrame frames[] { { .duration_ms = 100 } };
const AnimationFrame bad_frames[] { { .duration_ms = 0 } };
const AnimationFrame frames[] { { .duration_ms = 100, .face_pixels = {} } };
const AnimationFrame bad_frames[] { { .duration_ms = 0, .face_pixels = {} } };
const DocumentLayerConfig bad_layers[] { { .name = "", .frames = {} } };
const DocumentLayerConfig bad_layer_frames[] { { .name = "Ink", .frames = bad_frames } };
@@ -393,6 +394,115 @@ void rejects_invalid_animation_frame_operations(pp::tests::Harness& h)
PP_EXPECT(h, max_frame_count > document.frames().size());
}
void attaches_layer_frame_face_pixels(pp::tests::Harness& h)
{
auto document_result = CanvasDocument::create(
DocumentConfig { .width = 64, .height = 32, .layer_count = 1 });
PP_EXPECT(h, document_result.ok());
auto document = document_result.value();
const auto status = document.set_layer_frame_face_pixels(
0,
0,
LayerFacePixels {
.face_index = 2,
.x = 3,
.y = 4,
.width = 1,
.height = 1,
.rgba8 = { 10, 20, 30, 40 },
});
PP_EXPECT(h, status.ok());
PP_EXPECT(h, document.face_pixel_payload_count() == 1U);
PP_EXPECT(h, document.layers()[0].frames[0].face_pixels.size() == 1U);
PP_EXPECT(h, document.layers()[0].frames[0].face_pixels[0].face_index == 2U);
PP_EXPECT(h, document.layers()[0].frames[0].face_pixels[0].x == 3U);
PP_EXPECT(h, document.layers()[0].frames[0].face_pixels[0].rgba8[3] == 40U);
}
void replaces_existing_face_pixel_payload(pp::tests::Harness& h)
{
auto document_result = CanvasDocument::create(
DocumentConfig { .width = 64, .height = 32, .layer_count = 1 });
PP_EXPECT(h, document_result.ok());
auto document = document_result.value();
PP_EXPECT(h, document.set_layer_frame_face_pixels(
0,
0,
LayerFacePixels {
.face_index = 1,
.x = 0,
.y = 0,
.width = 1,
.height = 1,
.rgba8 = { 1, 2, 3, 4 },
}).ok());
PP_EXPECT(h, document.set_layer_frame_face_pixels(
0,
0,
LayerFacePixels {
.face_index = 1,
.x = 2,
.y = 3,
.width = 1,
.height = 1,
.rgba8 = { 5, 6, 7, 8 },
}).ok());
PP_EXPECT(h, document.face_pixel_payload_count() == 1U);
PP_EXPECT(h, document.layers()[0].frames[0].face_pixels[0].x == 2U);
PP_EXPECT(h, document.layers()[0].frames[0].face_pixels[0].rgba8[0] == 5U);
}
void rejects_invalid_face_pixel_payloads(pp::tests::Harness& h)
{
auto document_result = CanvasDocument::create(
DocumentConfig { .width = 64, .height = 32, .layer_count = 1 });
PP_EXPECT(h, document_result.ok());
auto document = document_result.value();
const auto missing_layer = document.set_layer_frame_face_pixels(
9,
0,
LayerFacePixels { .face_index = 0, .x = 0, .y = 0, .width = 1, .height = 1, .rgba8 = { 1, 2, 3, 4 } });
const auto missing_frame = document.set_layer_frame_face_pixels(
0,
9,
LayerFacePixels { .face_index = 0, .x = 0, .y = 0, .width = 1, .height = 1, .rgba8 = { 1, 2, 3, 4 } });
const auto bad_face = document.set_layer_frame_face_pixels(
0,
0,
LayerFacePixels { .face_index = 6, .x = 0, .y = 0, .width = 1, .height = 1, .rgba8 = { 1, 2, 3, 4 } });
const auto zero_width = document.set_layer_frame_face_pixels(
0,
0,
LayerFacePixels { .face_index = 0, .x = 0, .y = 0, .width = 0, .height = 1, .rgba8 = {} });
const auto outside_bounds = document.set_layer_frame_face_pixels(
0,
0,
LayerFacePixels { .face_index = 0, .x = 63, .y = 0, .width = 2, .height = 1, .rgba8 = { 1, 2, 3, 4, 5, 6, 7, 8 } });
const auto bad_byte_count = document.set_layer_frame_face_pixels(
0,
0,
LayerFacePixels { .face_index = 0, .x = 0, .y = 0, .width = 1, .height = 1, .rgba8 = { 1, 2, 3 } });
PP_EXPECT(h, !missing_layer.ok());
PP_EXPECT(h, missing_layer.code == StatusCode::out_of_range);
PP_EXPECT(h, !missing_frame.ok());
PP_EXPECT(h, missing_frame.code == StatusCode::out_of_range);
PP_EXPECT(h, !bad_face.ok());
PP_EXPECT(h, bad_face.code == StatusCode::out_of_range);
PP_EXPECT(h, !zero_width.ok());
PP_EXPECT(h, zero_width.code == StatusCode::invalid_argument);
PP_EXPECT(h, !outside_bounds.ok());
PP_EXPECT(h, outside_bounds.code == StatusCode::out_of_range);
PP_EXPECT(h, !bad_byte_count.ok());
PP_EXPECT(h, bad_byte_count.code == StatusCode::invalid_argument);
PP_EXPECT(h, document.face_pixel_payload_count() == 0U);
}
void records_document_history_and_restores_snapshots(pp::tests::Harness& h)
{
auto document_result = CanvasDocument::create(
@@ -521,6 +631,9 @@ int main()
harness.run("manages_animation_frames_and_duration", manages_animation_frames_and_duration);
harness.run("moves_frames_and_preserves_active_frame_identity", moves_frames_and_preserves_active_frame_identity);
harness.run("rejects_invalid_animation_frame_operations", rejects_invalid_animation_frame_operations);
harness.run("attaches_layer_frame_face_pixels", attaches_layer_frame_face_pixels);
harness.run("replaces_existing_face_pixel_payload", replaces_existing_face_pixel_payload);
harness.run("rejects_invalid_face_pixel_payloads", rejects_invalid_face_pixel_payloads);
harness.run("records_document_history_and_restores_snapshots", records_document_history_and_restores_snapshots);
harness.run("applying_after_undo_discards_redo_branch", applying_after_undo_discards_redo_branch);
harness.run("bounds_document_history_capacity", bounds_document_history_capacity);

View File

@@ -0,0 +1,148 @@
#include "assets/ppi_header.h"
#include "document/ppi_import.h"
#include "test_harness.h"
#include <bit>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <vector>
using pp::assets::decode_ppi_project_images;
using pp::foundation::StatusCode;
using pp::document::import_ppi_project_document;
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));
}
void append_f32(std::vector<std::byte>& bytes, float value)
{
append_u32(bytes, std::bit_cast<std::uint32_t>(value));
}
void append_ascii(std::vector<std::byte>& bytes, std::string_view value)
{
for (const auto ch : value) {
bytes.push_back(static_cast<std::byte>(ch));
}
}
std::vector<std::byte> transparent_png_1x1()
{
return {
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 { 0x49 }, std::byte { 0x48 }, std::byte { 0x44 }, std::byte { 0x52 },
std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x01 },
std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x01 },
std::byte { 0x08 }, std::byte { 0x06 }, std::byte { 0x00 }, std::byte { 0x00 },
std::byte { 0x00 }, std::byte { 0x1f }, std::byte { 0x15 }, std::byte { 0xc4 },
std::byte { 0x89 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 },
std::byte { 0x0b }, std::byte { 0x49 }, std::byte { 0x44 }, std::byte { 0x41 },
std::byte { 0x54 }, std::byte { 0x78 }, std::byte { 0x9c }, std::byte { 0x63 },
std::byte { 0x60 }, std::byte { 0x00 }, std::byte { 0x02 }, std::byte { 0x00 },
std::byte { 0x00 }, std::byte { 0x05 }, std::byte { 0x00 }, std::byte { 0x01 },
std::byte { 0x7a }, std::byte { 0x5e }, std::byte { 0xab }, std::byte { 0x3f },
std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 }, std::byte { 0x00 },
std::byte { 0x49 }, std::byte { 0x45 }, std::byte { 0x4e }, std::byte { 0x44 },
std::byte { 0xae }, std::byte { 0x42 }, std::byte { 0x60 }, std::byte { 0x82 },
};
}
std::vector<std::byte> ppi_project_with_face_payload(std::vector<std::byte> payload)
{
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);
bytes.resize(pp::assets::ppi_header_size + (128U * 128U * 4U), std::byte { 0 });
append_u32(bytes, 64);
append_u32(bytes, 32);
append_u32(bytes, 1);
append_u32(bytes, 1);
append_u32(bytes, 0);
append_f32(bytes, 1.0F);
append_u32(bytes, 3);
append_ascii(bytes, "Ink");
append_u32(bytes, 0);
bytes.push_back(std::byte { 0 });
bytes.push_back(std::byte { 1 });
append_u32(bytes, 1);
append_u32(bytes, 100);
append_u32(bytes, 1);
append_u32(bytes, 2);
append_u32(bytes, 3);
append_u32(bytes, 3);
append_u32(bytes, 4);
append_u32(bytes, static_cast<std::uint32_t>(payload.size()));
bytes.insert(bytes.end(), payload.begin(), payload.end());
for (std::uint32_t i = 1; i < 6U; ++i) {
append_u32(bytes, 0);
}
append_u32(bytes, 0);
return bytes;
}
void imports_decoded_ppi_pixels_into_document(pp::tests::Harness& h)
{
const auto project_bytes = ppi_project_with_face_payload(transparent_png_1x1());
const auto decoded = decode_ppi_project_images(project_bytes);
PP_EXPECT(h, decoded.ok());
const auto document = import_ppi_project_document(decoded.value());
PP_EXPECT(h, document.ok());
PP_EXPECT(h, document.value().width() == 64U);
PP_EXPECT(h, document.value().height() == 32U);
PP_EXPECT(h, document.value().face_pixel_payload_count() == 1U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels.size() == 1U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels[0].face_index == 0U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels[0].x == 2U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels[0].y == 3U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels[0].width == 1U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels[0].height == 1U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels[0].rgba8.size() == 4U);
PP_EXPECT(h, document.value().layers()[0].frames[0].face_pixels[0].rgba8[3] == 0U);
}
void rejects_decoded_payloads_outside_document_layers(pp::tests::Harness& h)
{
const auto project_bytes = ppi_project_with_face_payload(transparent_png_1x1());
const auto decoded = decode_ppi_project_images(project_bytes);
PP_EXPECT(h, decoded.ok());
auto decoded_value = decoded.value();
decoded_value.faces[0].layer_index = 99;
const auto document = import_ppi_project_document(decoded_value);
PP_EXPECT(h, !document.ok());
PP_EXPECT(h, document.status().code == StatusCode::out_of_range);
}
}
int main()
{
pp::tests::Harness harness;
harness.run("imports_decoded_ppi_pixels_into_document", imports_decoded_ppi_pixels_into_document);
harness.run("rejects_decoded_payloads_outside_document_layers", rejects_decoded_payloads_outside_document_layers);
return harness.finish();
}

View File

@@ -2,6 +2,7 @@
#include "assets/image_metadata.h"
#include "assets/ppi_header.h"
#include "document/document.h"
#include "document/ppi_import.h"
#include "foundation/parse.h"
#include "foundation/result.h"
#include "paint/stroke.h"
@@ -405,79 +406,6 @@ int inspect_project(int argc, char** argv)
return 0;
}
pp::foundation::Result<pp::paint::BlendMode> ppi_layer_blend_mode(std::uint32_t blend_mode) noexcept
{
switch (blend_mode) {
case 0:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::normal);
case 1:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::multiply);
case 2:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::screen);
case 3:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::color_dodge);
case 4:
return pp::foundation::Result<pp::paint::BlendMode>::success(pp::paint::BlendMode::overlay);
default:
return pp::foundation::Result<pp::paint::BlendMode>::failure(
pp::foundation::Status::invalid_argument("PPI layer blend mode is not supported by pp_document"));
}
}
pp::foundation::Result<pp::document::CanvasDocument> document_from_ppi_index(
const pp::assets::PpiProjectIndex& project)
{
if (project.body.layers.empty()) {
return pp::foundation::Result<pp::document::CanvasDocument>::failure(
pp::foundation::Status::invalid_argument("PPI project has no layers"));
}
const auto& reference_frames = project.body.layers.front().frames;
if (reference_frames.empty()) {
return pp::foundation::Result<pp::document::CanvasDocument>::failure(
pp::foundation::Status::invalid_argument("PPI project has no frames"));
}
std::vector<pp::document::AnimationFrame> frames;
frames.reserve(reference_frames.size());
for (const auto& frame : reference_frames) {
frames.push_back(pp::document::AnimationFrame { .duration_ms = frame.duration_ms });
}
std::vector<std::vector<pp::document::AnimationFrame>> layer_frames;
layer_frames.reserve(project.body.layers.size());
std::vector<pp::document::DocumentLayerConfig> layers;
layers.reserve(project.body.layers.size());
for (const auto& layer : project.body.layers) {
const auto blend_mode = ppi_layer_blend_mode(layer.blend_mode);
if (!blend_mode) {
return pp::foundation::Result<pp::document::CanvasDocument>::failure(blend_mode.status());
}
auto& frame_list = layer_frames.emplace_back();
frame_list.reserve(layer.frames.size());
for (const auto& frame : layer.frames) {
frame_list.push_back(pp::document::AnimationFrame { .duration_ms = frame.duration_ms });
}
layers.push_back(pp::document::DocumentLayerConfig {
.name = layer.name,
.visible = layer.visible,
.alpha_locked = layer.alpha_locked,
.opacity = layer.opacity,
.blend_mode = blend_mode.value(),
.frames = std::span<const pp::document::AnimationFrame>(frame_list.data(), frame_list.size()),
});
}
return pp::document::CanvasDocument::create_from_snapshot(pp::document::DocumentSnapshotConfig {
.width = project.body.summary.width,
.height = project.body.summary.height,
.layers = layers,
.frames = frames,
});
}
int load_project(int argc, char** argv)
{
InspectProjectArgs args;
@@ -498,13 +426,13 @@ int load_project(int argc, char** argv)
std::istreambuf_iterator<char>()
};
const auto* data = reinterpret_cast<const std::byte*>(chars.data());
const auto project = pp::assets::parse_ppi_project_index(std::span<const std::byte>(data, chars.size()));
const auto project = pp::assets::decode_ppi_project_images(std::span<const std::byte>(data, chars.size()));
if (!project) {
print_error("load-project", project.status().message);
return 2;
}
const auto document_result = document_from_ppi_index(project.value());
const auto document_result = pp::document::import_ppi_project_document(project.value());
if (!document_result) {
print_error("load-project", document_result.status().message);
return 2;
@@ -513,7 +441,8 @@ int load_project(int argc, char** argv)
const auto& document = document_result.value();
std::cout << "{\"ok\":true,\"command\":\"load-project\""
<< ",\"source\":\"ppi\""
<< ",\"pixelDataLoaded\":false"
<< ",\"pixelDataLoaded\":" << (document.face_pixel_payload_count() > 0U ? "true" : "false")
<< ",\"facePayloads\":" << document.face_pixel_payload_count()
<< ",\"document\":{\"width\":" << document.width()
<< ",\"height\":" << document.height()
<< ",\"layers\":" << document.layers().size()