Compare commits

...

10 Commits

25 changed files with 2850 additions and 67 deletions

View File

@@ -98,11 +98,20 @@ target_link_libraries(pp_foundation
add_library(pp_assets STATIC
src/assets/image_format.cpp
src/assets/image_metadata.cpp
src/assets/image_pixels.cpp
src/assets/ppi_header.cpp
src/assets/settings_document.cpp)
target_include_directories(pp_assets
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src")
target_include_directories(pp_assets
SYSTEM PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/libs/stb")
if(MSVC)
set_source_files_properties(src/assets/image_pixels.cpp
PROPERTIES
COMPILE_OPTIONS "/analyze-")
endif()
target_link_libraries(pp_assets
PUBLIC
pp_foundation
@@ -113,7 +122,8 @@ target_link_libraries(pp_assets
add_library(pp_paint STATIC
src/paint/brush.cpp
src/paint/blend.cpp
src/paint/stroke.cpp)
src/paint/stroke.cpp
src/paint/stroke_script.cpp)
target_include_directories(pp_paint
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src")
@@ -125,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

@@ -90,14 +90,32 @@ 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, PNG metadata, PPI
header, settings document, document frame move/duration coverage, paint
brush/stroke coverage, renderer shader descriptor coverage, UI color
parsing, and layout XML parse coverage.
including foundation event/logging/task queue coverage, PNG metadata and
decode, PPI header/layout, settings document, document
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
`pano_cli_inspect_png_metadata_smoke` with a tiny IHDR fixture.
- `pano_cli inspect-project` reports validated PPI thumbnail/body byte layout,
body summary fields, layer/frame descriptors, and dirty-face PNG payload
metadata, and is covered by `pano_cli_inspect_project_layout_smoke` with a
minimal PPI fixture.
- `pp_assets_image_pixels_tests` decodes PNG payloads to RGBA8 and rejects
corrupt image payloads.
- `pp_document_ppi_import_tests` attaches decoded PPI dirty-face payloads to
`pp_document` layer/frame storage and rejects payloads outside document
layers.
- `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`.
- `pano_cli simulate-stroke` exposes the pure stroke sampler for scripted
automation and is covered by `pano_cli_simulate_stroke_smoke`.
- `pano_cli simulate-stroke-script` loads a text stroke script fixture and is
covered by `pano_cli_simulate_stroke_script_smoke`.
- `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.

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 but is not yet wired to legacy `Canvas`, PPI 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` | 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` 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 |
| 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

@@ -249,10 +249,10 @@ Goal: make each component reachable by automated tools and future agents.
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, 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.
with JSON automation commands for creating a `pp_document` model, metadata-only
PPI project loading, and 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:
@@ -312,25 +312,39 @@ 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,
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.
PNG IHDR metadata parsing, PPI header/project byte-layout/body-summary
recognition, layer/frame indexing, dirty-face PNG payload metadata validation,
asset-level RGBA PNG payload decoding, 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
started with a pure canvas/layer/frame model, layer metadata operations, frame
move/duration queries, and layer/frame/undo-redo history invariant tests.
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, 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
the paint blend reference. `pp_ui_core` has started with XML-layout-facing
length parsing, color parsing, tinyxml-backed layout XML parsing, and invalid
input tests.
`pano_cli inspect-image` exposes PNG IHDR metadata as JSON, and
`pano_cli inspect-image` exposes PNG IHDR metadata as JSON,
`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 `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. `pano_cli parse-layout` exercises the XML layout path.
Continue expanding document behavior toward legacy Canvas parity and then port
OpenGL classes behind the renderer boundary.
frame count/duration, and `pano_cli simulate-stroke` exercises the pure stroke
sampler for scripted-stroke automation. `pano_cli simulate-stroke-script`
loads stroke script fixtures, parses them through `pp_paint`, and samples every
stroke. `pano_cli parse-layout` exercises the XML layout path. Continue
expanding document behavior toward legacy Canvas parity and then port OpenGL
classes behind the renderer boundary.
Implementation tasks:
@@ -536,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_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
@@ -562,13 +576,22 @@ Results:
- `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_image_pixels_tests` passed, including RGBA8 PNG decode and corrupt
payload rejection.
- `pp_assets_ppi_header_tests` passed, including PPI thumbnail/body layout,
body summary validation, layer/frame indexing, dirty-face PNG payload
metadata validation, and decoded dirty-face payload coverage.
- `pp_assets_settings_document_tests` passed.
- `pp_paint_brush_tests` passed.
- `pp_paint_blend_tests` passed.
- `pp_paint_stroke_tests` passed.
- `pp_document_tests` passed, including frame move, duration, and history
invariants.
- `pp_paint_stroke_script_tests` passed.
- `pp_document_tests` passed, including snapshot construction, alpha-lock
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.
@@ -581,7 +604,17 @@ Results:
test.
- `pano_cli_inspect_png_metadata_smoke` passed and reports PNG metadata JSON
for the tiny IHDR fixture.
- `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 `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.
- `pano_cli_simulate_stroke_script_smoke` passed and reports deterministic
aggregate stroke-script counts/distances.
- `panopainter_validate_shaders` passed, validating 25 shader programs and 7
shader includes for stage markers and include graph integrity.
- PowerShell analyze automation returns JSON summaries and includes the shader

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

@@ -0,0 +1,94 @@
#include "assets/image_pixels.h"
#include "assets/image_metadata.h"
#include <limits>
#include <utility>
#define STB_IMAGE_STATIC
#define STB_IMAGE_IMPLEMENTATION
#include <stb/stb_image.h>
namespace pp::assets {
namespace {
[[nodiscard]] pp::foundation::Result<std::size_t> rgba_byte_size(
std::uint32_t width,
std::uint32_t height) noexcept
{
const auto pixels = static_cast<std::uint64_t>(width) * static_cast<std::uint64_t>(height);
constexpr auto channels = 4ULL;
if (pixels > std::numeric_limits<std::uint64_t>::max() / channels) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("RGBA byte size overflows"));
}
const auto bytes = pixels * channels;
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("RGBA byte size exceeds addressable memory"));
}
return pp::foundation::Result<std::size_t>::success(static_cast<std::size_t>(bytes));
}
}
pp::foundation::Result<Rgba8Image> decode_png_rgba8(std::span<const std::byte> bytes)
{
const auto metadata = parse_png_metadata(bytes);
if (!metadata) {
return pp::foundation::Result<Rgba8Image>::failure(metadata.status());
}
if (bytes.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
return pp::foundation::Result<Rgba8Image>::failure(
pp::foundation::Status::out_of_range("PNG payload is too large for the decoder"));
}
int width = 0;
int height = 0;
int source_components = 0;
auto* decoded = stbi_load_from_memory(
reinterpret_cast<const stbi_uc*>(bytes.data()),
static_cast<int>(bytes.size()),
&width,
&height,
&source_components,
4);
if (decoded == nullptr) {
return pp::foundation::Result<Rgba8Image>::failure(
pp::foundation::Status::invalid_argument("PNG payload could not be decoded"));
}
const auto cleanup = [decoded]() noexcept {
stbi_image_free(decoded);
};
if (width <= 0 || height <= 0
|| static_cast<std::uint32_t>(width) != metadata.value().width
|| static_cast<std::uint32_t>(height) != metadata.value().height) {
cleanup();
return pp::foundation::Result<Rgba8Image>::failure(
pp::foundation::Status::invalid_argument("decoded PNG dimensions are inconsistent"));
}
const auto byte_count = rgba_byte_size(metadata.value().width, metadata.value().height);
if (!byte_count) {
cleanup();
return pp::foundation::Result<Rgba8Image>::failure(byte_count.status());
}
Rgba8Image image {
.width = metadata.value().width,
.height = metadata.value().height,
.pixels = {},
};
image.pixels.assign(decoded, decoded + byte_count.value());
cleanup();
return pp::foundation::Result<Rgba8Image>::success(std::move(image));
}
}

21
src/assets/image_pixels.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include "foundation/result.h"
#include <cstddef>
#include <cstdint>
#include <span>
#include <vector>
namespace pp::assets {
struct Rgba8Image {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::vector<std::uint8_t> pixels;
};
[[nodiscard]] pp::foundation::Result<Rgba8Image> decode_png_rgba8(
std::span<const std::byte> bytes);
}

View File

@@ -1,7 +1,12 @@
#include "assets/ppi_header.h"
#include "assets/image_metadata.h"
#include "foundation/binary_stream.h"
#include <bit>
#include <limits>
#include <utility>
namespace pp::assets {
namespace {
@@ -11,6 +16,93 @@ namespace {
return reader.read_u32_le();
}
[[nodiscard]] pp::foundation::Result<std::uint32_t> read_positive_i32(
pp::foundation::ByteReader& reader,
const char* message) noexcept
{
const auto value = reader.read_u32_le();
if (!value) {
return value;
}
if (value.value() > static_cast<std::uint32_t>(std::numeric_limits<std::int32_t>::max())) {
return pp::foundation::Result<std::uint32_t>::failure(
pp::foundation::Status::out_of_range(message));
}
return value;
}
[[nodiscard]] pp::foundation::Result<float> read_f32(pp::foundation::ByteReader& reader) noexcept
{
const auto bits = reader.read_u32_le();
if (!bits) {
return pp::foundation::Result<float>::failure(bits.status());
}
return pp::foundation::Result<float>::success(std::bit_cast<float>(bits.value()));
}
[[nodiscard]] pp::foundation::Status skip_bytes(
pp::foundation::ByteReader& reader,
std::size_t bytes) noexcept
{
const auto skipped = reader.read_bytes(bytes);
if (!skipped) {
return skipped.status();
}
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Status validate_canvas_size(std::uint32_t width, std::uint32_t height) noexcept
{
if (width == 0 || height == 0) {
return pp::foundation::Status::invalid_argument("PPI canvas dimensions must be greater than zero");
}
if (width > max_ppi_canvas_dimension || height > max_ppi_canvas_dimension) {
return pp::foundation::Status::out_of_range("PPI canvas dimensions exceed the configured limit");
}
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Status add_payload_bytes(PpiBodySummary& summary, std::uint32_t bytes) noexcept
{
const auto next = summary.compressed_face_bytes + static_cast<std::uint64_t>(bytes);
if (next > max_ppi_face_payload_bytes) {
return pp::foundation::Status::out_of_range("PPI compressed face payload exceeds the configured limit");
}
summary.compressed_face_bytes = next;
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Result<ImageMetadata> validate_face_png_payload(
std::span<const std::byte> payload,
std::uint32_t width,
std::uint32_t height) noexcept
{
const auto metadata = parse_png_metadata(payload);
if (!metadata) {
return pp::foundation::Result<ImageMetadata>::failure(metadata.status());
}
if (metadata.value().width != width || metadata.value().height != height) {
return pp::foundation::Result<ImageMetadata>::failure(
pp::foundation::Status::invalid_argument("PPI face PNG dimensions do not match the dirty box"));
}
if (metadata.value().bit_depth != 8U || metadata.value().components != 4U
|| metadata.value().color_type != ImageColorType::rgba) {
return pp::foundation::Result<ImageMetadata>::failure(
pp::foundation::Status::invalid_argument("PPI face PNG payload must be 8-bit RGBA"));
}
return pp::foundation::Result<ImageMetadata>::success(metadata.value());
}
}
pp::foundation::Result<PpiHeaderInfo> parse_ppi_header(std::span<const std::byte> bytes) noexcept
@@ -70,4 +162,458 @@ pp::foundation::Result<PpiHeaderInfo> parse_ppi_header(std::span<const std::byte
return pp::foundation::Result<PpiHeaderInfo>::success(info);
}
pp::foundation::Result<std::size_t> ppi_thumbnail_byte_size(PpiThumbnailInfo thumbnail) noexcept
{
if (thumbnail.width == 0 || thumbnail.height == 0 || thumbnail.components == 0) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::invalid_argument("PPI thumbnail descriptor is invalid"));
}
const auto width = static_cast<std::uint64_t>(thumbnail.width);
const auto height = static_cast<std::uint64_t>(thumbnail.height);
const auto components = static_cast<std::uint64_t>(thumbnail.components);
if (width > std::numeric_limits<std::uint64_t>::max() / height) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("PPI thumbnail byte size overflows"));
}
const auto pixels = width * height;
if (pixels > std::numeric_limits<std::uint64_t>::max() / components) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::out_of_range("PPI thumbnail byte size overflows"));
}
const auto bytes = pixels * components;
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("PPI thumbnail byte size exceeds addressable memory"));
}
return pp::foundation::Result<std::size_t>::success(static_cast<std::size_t>(bytes));
}
pp::foundation::Result<PpiProjectLayout> parse_ppi_project_layout(std::span<const std::byte> bytes) noexcept
{
const auto header = parse_ppi_header(bytes);
if (!header) {
return pp::foundation::Result<PpiProjectLayout>::failure(header.status());
}
const auto thumbnail_bytes = ppi_thumbnail_byte_size(header.value().thumbnail);
if (!thumbnail_bytes) {
return pp::foundation::Result<PpiProjectLayout>::failure(thumbnail_bytes.status());
}
if (thumbnail_bytes.value() > std::numeric_limits<std::size_t>::max() - ppi_header_size) {
return pp::foundation::Result<PpiProjectLayout>::failure(
pp::foundation::Status::out_of_range("PPI thumbnail byte size overflows"));
}
const auto body_offset = ppi_header_size + thumbnail_bytes.value();
if (bytes.size() < body_offset) {
return pp::foundation::Result<PpiProjectLayout>::failure(
pp::foundation::Status::out_of_range("PPI thumbnail payload is truncated"));
}
return pp::foundation::Result<PpiProjectLayout>::success(PpiProjectLayout {
.header = header.value(),
.thumbnail_offset = ppi_header_size,
.thumbnail_bytes = thumbnail_bytes.value(),
.body_offset = body_offset,
.body_bytes = bytes.size() - body_offset,
});
}
namespace {
pp::foundation::Result<PpiBodySummary> parse_ppi_body_impl(
PpiHeaderInfo header,
std::span<const std::byte> body,
PpiBodyIndex* index) noexcept
{
if (index != nullptr) {
index->summary = {};
index->layers.clear();
}
pp::foundation::ByteReader reader(body);
const auto width = read_positive_i32(reader, "PPI canvas width is outside the supported range");
const auto height = read_positive_i32(reader, "PPI canvas height is outside the supported range");
const auto layer_count = read_positive_i32(reader, "PPI layer count is outside the supported range");
if (!width || !height || !layer_count) {
return pp::foundation::Result<PpiBodySummary>::failure(
!width ? width.status() : (!height ? height.status() : layer_count.status()));
}
const auto canvas_status = validate_canvas_size(width.value(), height.value());
if (!canvas_status.ok()) {
return pp::foundation::Result<PpiBodySummary>::failure(canvas_status);
}
if (layer_count.value() == 0 || layer_count.value() > max_ppi_layer_count) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI layer count is outside the configured range"));
}
PpiBodySummary summary {
.width = width.value(),
.height = height.value(),
.layer_count = layer_count.value(),
.declared_frame_count = 1,
};
std::vector<bool> seen_orders;
if (index != nullptr) {
index->layers.resize(summary.layer_count);
seen_orders.assign(summary.layer_count, false);
}
if (header.document_version.minor >= 3U) {
const auto declared_frames = read_positive_i32(reader, "PPI declared frame count is outside the supported range");
if (!declared_frames) {
return pp::foundation::Result<PpiBodySummary>::failure(declared_frames.status());
}
if (declared_frames.value() == 0 || declared_frames.value() > max_ppi_frame_count) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI declared frame count is outside the configured range"));
}
summary.declared_frame_count = declared_frames.value();
}
for (std::uint32_t layer_index = 0; layer_index < summary.layer_count; ++layer_index) {
const auto order = read_positive_i32(reader, "PPI layer order is outside the supported range");
const auto opacity = read_f32(reader);
const auto name_length = read_positive_i32(reader, "PPI layer name length is outside the supported range");
if (!order || !opacity || !name_length) {
return pp::foundation::Result<PpiBodySummary>::failure(
!order ? order.status() : (!opacity ? opacity.status() : name_length.status()));
}
if (order.value() >= summary.layer_count) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI layer order is outside the layer list"));
}
if (index != nullptr) {
if (seen_orders[order.value()]) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::invalid_argument("PPI layer order is duplicated"));
}
seen_orders[order.value()] = true;
}
if (opacity.value() < 0.0F || opacity.value() > 1.0F) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI layer opacity is outside the supported range"));
}
if (name_length.value() > max_ppi_layer_name_length) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI layer name exceeds the configured limit"));
}
const auto name_bytes = reader.read_bytes(name_length.value());
if (!name_bytes) {
return pp::foundation::Result<PpiBodySummary>::failure(name_bytes.status());
}
PpiLayerSummary layer_summary;
if (index != nullptr) {
layer_summary.stored_order = order.value();
layer_summary.opacity = opacity.value();
layer_summary.name.reserve(name_bytes.value().size());
for (const auto byte : name_bytes.value()) {
layer_summary.name.push_back(static_cast<char>(std::to_integer<unsigned char>(byte)));
}
}
if (header.document_version.minor >= 2U) {
const auto blend_mode = read_positive_i32(reader, "PPI layer blend mode is outside the supported range");
const auto alpha_locked = reader.read_u8();
const auto visible = reader.read_u8();
if (!blend_mode || !alpha_locked || !visible) {
return pp::foundation::Result<PpiBodySummary>::failure(
!blend_mode ? blend_mode.status() : (!alpha_locked ? alpha_locked.status() : visible.status()));
}
if (alpha_locked.value() > 1U || visible.value() > 1U) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::invalid_argument("PPI layer boolean field is invalid"));
}
if (index != nullptr) {
layer_summary.blend_mode = blend_mode.value();
layer_summary.alpha_locked = alpha_locked.value() != 0U;
layer_summary.visible = visible.value() != 0U;
}
}
std::uint32_t layer_frames = 1;
if (header.document_version.minor >= 3U) {
const auto frame_count = read_positive_i32(reader, "PPI layer frame count is outside the supported range");
if (!frame_count) {
return pp::foundation::Result<PpiBodySummary>::failure(frame_count.status());
}
if (frame_count.value() == 0 || frame_count.value() > max_ppi_frame_count) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI layer frame count is outside the configured range"));
}
layer_frames = frame_count.value();
}
if (summary.total_layer_frames > max_ppi_frame_count - layer_frames) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI total frame count exceeds the configured limit"));
}
summary.total_layer_frames += layer_frames;
if (index != nullptr) {
layer_summary.frames.resize(layer_frames);
}
for (std::uint32_t frame_index = 0; frame_index < layer_frames; ++frame_index) {
if (header.document_version.minor >= 3U) {
const auto duration = read_positive_i32(reader, "PPI frame duration is outside the supported range");
if (!duration) {
return pp::foundation::Result<PpiBodySummary>::failure(duration.status());
}
if (duration.value() == 0) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::invalid_argument("PPI frame duration must be greater than zero"));
}
if (index != nullptr) {
layer_summary.frames[frame_index].duration_ms = duration.value();
}
}
for (std::uint32_t face = 0; face < 6U; ++face) {
const auto has_data = read_positive_i32(reader, "PPI face data flag is outside the supported range");
if (!has_data) {
return pp::foundation::Result<PpiBodySummary>::failure(has_data.status());
}
if (has_data.value() > 1U) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::invalid_argument("PPI face data flag is invalid"));
}
if (has_data.value() == 0U) {
continue;
}
++summary.dirty_face_count;
const auto x0 = read_positive_i32(reader, "PPI dirty box coordinate is outside the supported range");
const auto y0 = read_positive_i32(reader, "PPI dirty box coordinate is outside the supported range");
const auto x1 = read_positive_i32(reader, "PPI dirty box coordinate is outside the supported range");
const auto y1 = read_positive_i32(reader, "PPI dirty box coordinate is outside the supported range");
const auto data_size = read_positive_i32(reader, "PPI compressed face data size is outside the supported range");
if (!x0 || !y0 || !x1 || !y1 || !data_size) {
return pp::foundation::Result<PpiBodySummary>::failure(
!x0 ? x0.status()
: (!y0 ? y0.status() : (!x1 ? x1.status() : (!y1 ? y1.status() : data_size.status()))));
}
if (x0.value() >= x1.value() || y0.value() >= y1.value() || x1.value() > summary.width
|| y1.value() > summary.height) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::out_of_range("PPI dirty box is outside the canvas"));
}
if (data_size.value() == 0U) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::invalid_argument("PPI compressed face payload must not be empty"));
}
const auto byte_status = add_payload_bytes(summary, data_size.value());
if (!byte_status.ok()) {
return pp::foundation::Result<PpiBodySummary>::failure(byte_status);
}
const auto payload_offset = reader.position();
const auto payload = reader.read_bytes(data_size.value());
if (!payload) {
return pp::foundation::Result<PpiBodySummary>::failure(payload.status());
}
const auto png_metadata = validate_face_png_payload(
payload.value(),
x1.value() - x0.value(),
y1.value() - y0.value());
if (!png_metadata) {
return pp::foundation::Result<PpiBodySummary>::failure(png_metadata.status());
}
++summary.rgba_face_payload_count;
if (index != nullptr) {
layer_summary.frames[frame_index].faces[face] = PpiFacePayloadSummary {
.has_data = true,
.x0 = x0.value(),
.y0 = y0.value(),
.x1 = x1.value(),
.y1 = y1.value(),
.body_payload_offset = static_cast<std::uint32_t>(payload_offset),
.payload_bytes = data_size.value(),
.png_width = png_metadata.value().width,
.png_height = png_metadata.value().height,
};
}
}
}
if (index != nullptr) {
index->layers[order.value()] = std::move(layer_summary);
}
}
if (header.document_version.minor >= 3U && summary.total_layer_frames != summary.declared_frame_count) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::invalid_argument("PPI declared frame count does not match layer frames"));
}
if (header.document_version.minor >= 4U) {
const auto info_bytes = read_positive_i32(reader, "PPI info block size is outside the supported range");
if (!info_bytes) {
return pp::foundation::Result<PpiBodySummary>::failure(info_bytes.status());
}
summary.info_bytes = info_bytes.value();
const auto info_status = skip_bytes(reader, summary.info_bytes);
if (!info_status.ok()) {
return pp::foundation::Result<PpiBodySummary>::failure(info_status);
}
}
if (!reader.empty()) {
return pp::foundation::Result<PpiBodySummary>::failure(
pp::foundation::Status::invalid_argument("PPI body has trailing bytes"));
}
if (index != nullptr) {
index->summary = summary;
}
return pp::foundation::Result<PpiBodySummary>::success(summary);
}
}
pp::foundation::Result<PpiBodySummary> parse_ppi_body_summary(
PpiHeaderInfo header,
std::span<const std::byte> body) noexcept
{
return parse_ppi_body_impl(header, body, nullptr);
}
pp::foundation::Result<PpiBodyIndex> parse_ppi_body_index(
PpiHeaderInfo header,
std::span<const std::byte> body)
{
PpiBodyIndex index;
const auto summary = parse_ppi_body_impl(header, body, &index);
if (!summary) {
return pp::foundation::Result<PpiBodyIndex>::failure(summary.status());
}
return pp::foundation::Result<PpiBodyIndex>::success(std::move(index));
}
pp::foundation::Result<PpiProjectSummary> parse_ppi_project_summary(std::span<const std::byte> bytes) noexcept
{
const auto layout = parse_ppi_project_layout(bytes);
if (!layout) {
return pp::foundation::Result<PpiProjectSummary>::failure(layout.status());
}
const auto body = parse_ppi_body_summary(
layout.value().header,
bytes.subspan(layout.value().body_offset, layout.value().body_bytes));
if (!body) {
return pp::foundation::Result<PpiProjectSummary>::failure(body.status());
}
return pp::foundation::Result<PpiProjectSummary>::success(PpiProjectSummary {
.layout = layout.value(),
.body = body.value(),
});
}
pp::foundation::Result<PpiProjectIndex> parse_ppi_project_index(std::span<const std::byte> bytes)
{
const auto layout = parse_ppi_project_layout(bytes);
if (!layout) {
return pp::foundation::Result<PpiProjectIndex>::failure(layout.status());
}
const auto body = parse_ppi_body_index(
layout.value().header,
bytes.subspan(layout.value().body_offset, layout.value().body_bytes));
if (!body) {
return pp::foundation::Result<PpiProjectIndex>::failure(body.status());
}
return pp::foundation::Result<PpiProjectIndex>::success(PpiProjectIndex {
.layout = layout.value(),
.body = body.value(),
});
}
pp::foundation::Result<PpiDecodedProjectImages> decode_ppi_project_images(std::span<const std::byte> bytes)
{
auto project = parse_ppi_project_index(bytes);
if (!project) {
return pp::foundation::Result<PpiDecodedProjectImages>::failure(project.status());
}
PpiDecodedProjectImages decoded {
.project = project.value(),
.faces = {},
};
decoded.faces.reserve(decoded.project.body.summary.rgba_face_payload_count);
const auto body = bytes.subspan(decoded.project.layout.body_offset, decoded.project.layout.body_bytes);
for (std::size_t layer_index = 0; layer_index < decoded.project.body.layers.size(); ++layer_index) {
const auto& layer = decoded.project.body.layers[layer_index];
for (std::size_t frame_index = 0; frame_index < layer.frames.size(); ++frame_index) {
const auto& frame = layer.frames[frame_index];
for (std::size_t face_index = 0; face_index < frame.faces.size(); ++face_index) {
const auto& face = frame.faces[face_index];
if (!face.has_data) {
continue;
}
if (face.body_payload_offset > body.size()
|| face.payload_bytes > body.size() - face.body_payload_offset) {
return pp::foundation::Result<PpiDecodedProjectImages>::failure(
pp::foundation::Status::out_of_range("PPI face payload range is outside the body"));
}
const auto image = decode_png_rgba8(
body.subspan(face.body_payload_offset, face.payload_bytes));
if (!image) {
return pp::foundation::Result<PpiDecodedProjectImages>::failure(image.status());
}
if (image.value().width != face.png_width || image.value().height != face.png_height) {
return pp::foundation::Result<PpiDecodedProjectImages>::failure(
pp::foundation::Status::invalid_argument("decoded PPI face payload dimensions changed"));
}
decoded.faces.push_back(PpiDecodedFacePayload {
.layer_index = static_cast<std::uint32_t>(layer_index),
.frame_index = static_cast<std::uint32_t>(frame_index),
.face_index = static_cast<std::uint32_t>(face_index),
.descriptor = face,
.image = image.value(),
});
}
}
}
return pp::foundation::Result<PpiDecodedProjectImages>::success(std::move(decoded));
}
}

View File

@@ -1,14 +1,23 @@
#pragma once
#include "assets/image_pixels.h"
#include "foundation/result.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>
#include <string>
#include <vector>
namespace pp::assets {
constexpr std::size_t ppi_header_size = 40;
constexpr std::uint32_t max_ppi_canvas_dimension = 131072;
constexpr std::uint32_t max_ppi_layer_count = 1024;
constexpr std::uint32_t max_ppi_frame_count = 100000;
constexpr std::size_t max_ppi_layer_name_length = 128;
constexpr std::uint64_t max_ppi_face_payload_bytes = 1024ULL * 1024ULL * 1024ULL;
struct PpiVersion {
std::uint32_t major = 0;
@@ -34,7 +43,104 @@ struct PpiHeaderInfo {
PpiThumbnailInfo thumbnail;
};
struct PpiProjectLayout {
PpiHeaderInfo header;
std::size_t thumbnail_offset = 0;
std::size_t thumbnail_bytes = 0;
std::size_t body_offset = 0;
std::size_t body_bytes = 0;
};
struct PpiBodySummary {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::uint32_t layer_count = 0;
std::uint32_t declared_frame_count = 0;
std::uint32_t total_layer_frames = 0;
std::uint32_t dirty_face_count = 0;
std::uint32_t rgba_face_payload_count = 0;
std::uint64_t compressed_face_bytes = 0;
std::uint32_t info_bytes = 0;
};
struct PpiProjectSummary {
PpiProjectLayout layout;
PpiBodySummary body;
};
struct PpiFacePayloadSummary {
bool has_data = false;
std::uint32_t x0 = 0;
std::uint32_t y0 = 0;
std::uint32_t x1 = 0;
std::uint32_t y1 = 0;
std::uint32_t body_payload_offset = 0;
std::uint32_t payload_bytes = 0;
std::uint32_t png_width = 0;
std::uint32_t png_height = 0;
};
struct PpiFrameSummary {
std::uint32_t duration_ms = 100;
std::array<PpiFacePayloadSummary, 6> faces;
};
struct PpiLayerSummary {
std::uint32_t stored_order = 0;
std::string name;
float opacity = 1.0F;
std::uint32_t blend_mode = 0;
bool alpha_locked = false;
bool visible = true;
std::vector<PpiFrameSummary> frames;
};
struct PpiBodyIndex {
PpiBodySummary summary;
std::vector<PpiLayerSummary> layers;
};
struct PpiProjectIndex {
PpiProjectLayout layout;
PpiBodyIndex body;
};
struct PpiDecodedFacePayload {
std::uint32_t layer_index = 0;
std::uint32_t frame_index = 0;
std::uint32_t face_index = 0;
PpiFacePayloadSummary descriptor;
Rgba8Image image;
};
struct PpiDecodedProjectImages {
PpiProjectIndex project;
std::vector<PpiDecodedFacePayload> faces;
};
[[nodiscard]] pp::foundation::Result<PpiHeaderInfo> parse_ppi_header(
std::span<const std::byte> bytes) noexcept;
[[nodiscard]] pp::foundation::Result<std::size_t> ppi_thumbnail_byte_size(PpiThumbnailInfo thumbnail) noexcept;
[[nodiscard]] pp::foundation::Result<PpiProjectLayout> parse_ppi_project_layout(
std::span<const std::byte> bytes) noexcept;
[[nodiscard]] pp::foundation::Result<PpiBodySummary> parse_ppi_body_summary(
PpiHeaderInfo header,
std::span<const std::byte> body) noexcept;
[[nodiscard]] pp::foundation::Result<PpiBodyIndex> parse_ppi_body_index(
PpiHeaderInfo header,
std::span<const std::byte> body);
[[nodiscard]] pp::foundation::Result<PpiProjectSummary> parse_ppi_project_summary(
std::span<const std::byte> bytes) noexcept;
[[nodiscard]] pp::foundation::Result<PpiProjectIndex> parse_ppi_project_index(
std::span<const std::byte> bytes);
[[nodiscard]] pp::foundation::Result<PpiDecodedProjectImages> decode_ppi_project_images(
std::span<const std::byte> bytes);
}

View File

@@ -2,6 +2,8 @@
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
namespace pp::document {
@@ -33,6 +35,15 @@ namespace {
return "Layer " + std::to_string(index + 1U);
}
[[nodiscard]] std::uint64_t frame_duration_sum(std::span<const AnimationFrame> frames) noexcept
{
std::uint64_t duration = 0;
for (const auto& frame : frames) {
duration += frame.duration_ms;
}
return duration;
}
[[nodiscard]] pp::foundation::Status validate_layer_name(std::string_view name) noexcept
{
if (name.empty()) {
@@ -46,6 +57,38 @@ namespace {
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Status validate_layer_opacity(float opacity) noexcept
{
if (!std::isfinite(opacity) || opacity < 0.0F || opacity > 1.0F) {
return pp::foundation::Status::out_of_range("layer opacity must be finite and within 0..1");
}
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Status validate_blend_mode(pp::paint::BlendMode blend_mode) noexcept
{
switch (blend_mode) {
case pp::paint::BlendMode::normal:
case pp::paint::BlendMode::multiply:
case pp::paint::BlendMode::screen:
case pp::paint::BlendMode::color_dodge:
case pp::paint::BlendMode::overlay:
return pp::foundation::Status::success();
}
return pp::foundation::Status::invalid_argument("layer blend mode is not supported");
}
[[nodiscard]] pp::foundation::Status validate_frame_duration(std::uint32_t duration_ms) noexcept
{
if (duration_ms < min_frame_duration_ms) {
return pp::foundation::Status::invalid_argument("frame duration must be greater than zero");
}
return pp::foundation::Status::success();
}
[[nodiscard]] pp::foundation::Status validate_layer_index(std::size_t index, std::size_t layer_count) noexcept
{
if (index >= layer_count) {
@@ -64,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)
@@ -76,11 +180,97 @@ pp::foundation::Result<CanvasDocument> CanvasDocument::create(DocumentConfig con
CanvasDocument document;
document.width_ = config.width;
document.height_ = config.height;
document.frames_.push_back(AnimationFrame {});
document.layers_.reserve(config.layer_count);
for (std::uint32_t i = 0; i < config.layer_count; ++i) {
document.layers_.push_back(Layer { .name = default_layer_name(i) });
document.layers_.push_back(Layer {
.name = default_layer_name(i),
.frames = document.frames_,
});
}
return pp::foundation::Result<CanvasDocument>::success(document);
}
pp::foundation::Result<CanvasDocument> CanvasDocument::create_from_snapshot(DocumentSnapshotConfig config)
{
const auto status = validate_config(DocumentConfig {
.width = config.width,
.height = config.height,
.layer_count = static_cast<std::uint32_t>(config.layers.size()),
});
if (!status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(status);
}
if (config.frames.empty()) {
return pp::foundation::Result<CanvasDocument>::failure(
pp::foundation::Status::invalid_argument("document must contain at least one frame"));
}
if (config.frames.size() > max_frame_count) {
return pp::foundation::Result<CanvasDocument>::failure(
pp::foundation::Status::out_of_range("document frame count exceeds the configured limit"));
}
CanvasDocument document;
document.width_ = config.width;
document.height_ = config.height;
document.layers_.reserve(config.layers.size());
for (const auto& layer_config : config.layers) {
const auto name_status = validate_layer_name(layer_config.name);
if (!name_status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(name_status);
}
const auto opacity_status = validate_layer_opacity(layer_config.opacity);
if (!opacity_status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(opacity_status);
}
const auto blend_status = validate_blend_mode(layer_config.blend_mode);
if (!blend_status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(blend_status);
}
const auto layer_frames = layer_config.frames.empty() ? config.frames : layer_config.frames;
if (layer_frames.empty()) {
return pp::foundation::Result<CanvasDocument>::failure(
pp::foundation::Status::invalid_argument("document layer must contain at least one frame"));
}
if (layer_frames.size() > max_frame_count) {
return pp::foundation::Result<CanvasDocument>::failure(
pp::foundation::Status::out_of_range("document layer frame count exceeds the configured limit"));
}
for (const auto& frame_config : layer_frames) {
const auto duration_status = validate_frame_duration(frame_config.duration_ms);
if (!duration_status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(duration_status);
}
}
document.layers_.push_back(Layer {
.name = std::string(layer_config.name),
.visible = layer_config.visible,
.alpha_locked = layer_config.alpha_locked,
.opacity = layer_config.opacity,
.blend_mode = layer_config.blend_mode,
.frames = {},
});
document.layers_.back().frames.assign(layer_frames.begin(), layer_frames.end());
}
document.frames_.reserve(config.frames.size());
for (const auto& frame_config : config.frames) {
const auto duration_status = validate_frame_duration(frame_config.duration_ms);
if (!duration_status.ok()) {
return pp::foundation::Result<CanvasDocument>::failure(duration_status);
}
document.frames_.push_back(frame_config);
}
document.frames_.push_back(AnimationFrame {});
return pp::foundation::Result<CanvasDocument>::success(document);
}
@@ -107,13 +297,34 @@ std::size_t CanvasDocument::active_frame_index() const noexcept
std::uint64_t CanvasDocument::animation_duration_ms() const noexcept
{
std::uint64_t duration = 0;
for (const auto& frame : frames_) {
duration += frame.duration_ms;
std::uint64_t duration = frame_duration_sum(frames_);
for (const auto& layer : layers_) {
duration = std::max(duration, frame_duration_sum(layer.frames));
}
return duration;
}
pp::foundation::Result<std::uint64_t> CanvasDocument::layer_animation_duration_ms(std::size_t index) const noexcept
{
const auto index_status = validate_layer_index(index, layers_.size());
if (!index_status.ok()) {
return pp::foundation::Result<std::uint64_t>::failure(index_status);
}
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_;
@@ -141,6 +352,7 @@ pp::foundation::Result<std::size_t> CanvasDocument::add_layer(std::string_view n
}
layer.name = std::string(name);
}
layer.frames = frames_;
layers_.push_back(layer);
active_layer_index_ = layers_.size() - 1U;
return pp::foundation::Result<std::size_t>::success(active_layer_index_);
@@ -229,6 +441,17 @@ pp::foundation::Status CanvasDocument::set_layer_visible(std::size_t index, bool
return pp::foundation::Status::success();
}
pp::foundation::Status CanvasDocument::set_layer_alpha_locked(std::size_t index, bool alpha_locked) noexcept
{
const auto index_status = validate_layer_index(index, layers_.size());
if (!index_status.ok()) {
return index_status;
}
layers_[index].alpha_locked = alpha_locked;
return pp::foundation::Status::success();
}
pp::foundation::Status CanvasDocument::set_layer_opacity(std::size_t index, float opacity) noexcept
{
const auto index_status = validate_layer_index(index, layers_.size());
@@ -236,8 +459,9 @@ pp::foundation::Status CanvasDocument::set_layer_opacity(std::size_t index, floa
return index_status;
}
if (!std::isfinite(opacity) || opacity < 0.0F || opacity > 1.0F) {
return pp::foundation::Status::out_of_range("layer opacity must be finite and within 0..1");
const auto opacity_status = validate_layer_opacity(opacity);
if (!opacity_status.ok()) {
return opacity_status;
}
layers_[index].opacity = opacity;
@@ -251,17 +475,13 @@ pp::foundation::Status CanvasDocument::set_layer_blend_mode(std::size_t index, p
return index_status;
}
switch (blend_mode) {
case pp::paint::BlendMode::normal:
case pp::paint::BlendMode::multiply:
case pp::paint::BlendMode::screen:
case pp::paint::BlendMode::color_dodge:
case pp::paint::BlendMode::overlay:
layers_[index].blend_mode = blend_mode;
return pp::foundation::Status::success();
const auto blend_status = validate_blend_mode(blend_mode);
if (!blend_status.ok()) {
return blend_status;
}
return pp::foundation::Status::invalid_argument("layer blend mode is not supported");
layers_[index].blend_mode = blend_mode;
return pp::foundation::Status::success();
}
pp::foundation::Result<std::size_t> CanvasDocument::add_frame(std::uint32_t duration_ms)
@@ -271,12 +491,16 @@ pp::foundation::Result<std::size_t> CanvasDocument::add_frame(std::uint32_t dura
pp::foundation::Status::out_of_range("document frame count exceeds the configured limit"));
}
if (duration_ms < min_frame_duration_ms) {
const auto duration_status = validate_frame_duration(duration_ms);
if (!duration_status.ok()) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::invalid_argument("frame duration must be greater than zero"));
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, .face_pixels = {} });
}
active_frame_index_ = frames_.size() - 1U;
return pp::foundation::Result<std::size_t>::success(active_frame_index_);
}
@@ -296,6 +520,13 @@ pp::foundation::Result<std::size_t> CanvasDocument::duplicate_frame(std::size_t
const auto insert_at = index + 1U;
frames_.insert(frames_.begin() + static_cast<std::ptrdiff_t>(insert_at), frames_[index]);
for (auto& layer : layers_) {
if (index < layer.frames.size()) {
layer.frames.insert(
layer.frames.begin() + static_cast<std::ptrdiff_t>(insert_at),
layer.frames[index]);
}
}
active_frame_index_ = insert_at;
return pp::foundation::Result<std::size_t>::success(active_frame_index_);
}
@@ -312,6 +543,11 @@ pp::foundation::Status CanvasDocument::remove_frame(std::size_t index)
}
frames_.erase(frames_.begin() + static_cast<std::ptrdiff_t>(index));
for (auto& layer : layers_) {
if (index < layer.frames.size() && layer.frames.size() > 1U) {
layer.frames.erase(layer.frames.begin() + static_cast<std::ptrdiff_t>(index));
}
}
if (active_frame_index_ >= frames_.size()) {
active_frame_index_ = frames_.size() - 1U;
} else if (active_frame_index_ > index) {
@@ -334,6 +570,13 @@ pp::foundation::Status CanvasDocument::move_frame(std::size_t from, std::size_t
const auto frame = frames_[from];
frames_.erase(frames_.begin() + static_cast<std::ptrdiff_t>(from));
frames_.insert(frames_.begin() + static_cast<std::ptrdiff_t>(to), frame);
for (auto& layer : layers_) {
if (from < layer.frames.size() && to < layer.frames.size()) {
const auto layer_frame = layer.frames[from];
layer.frames.erase(layer.frames.begin() + static_cast<std::ptrdiff_t>(from));
layer.frames.insert(layer.frames.begin() + static_cast<std::ptrdiff_t>(to), layer_frame);
}
}
if (active_frame_index_ == from) {
active_frame_index_ = to;
@@ -353,11 +596,17 @@ pp::foundation::Status CanvasDocument::set_frame_duration(std::size_t index, std
return index_status;
}
if (duration_ms < min_frame_duration_ms) {
return pp::foundation::Status::invalid_argument("frame duration must be greater than zero");
const auto duration_status = validate_frame_duration(duration_ms);
if (!duration_status.ok()) {
return duration_status;
}
frames_[index].duration_ms = duration_ms;
for (auto& layer : layers_) {
if (index < layer.frames.size()) {
layer.frames[index].duration_ms = duration_ms;
}
}
return pp::foundation::Status::success();
}
@@ -372,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,26 +28,57 @@ struct DocumentConfig {
std::uint32_t layer_count = 1;
};
struct Layer {
std::string name;
bool visible = true;
float opacity = 1.0F;
pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal;
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 {
std::string name;
bool visible = true;
bool alpha_locked = false;
float opacity = 1.0F;
pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal;
std::vector<AnimationFrame> frames;
};
struct DocumentLayerConfig {
std::string_view name;
bool visible = true;
bool alpha_locked = false;
float opacity = 1.0F;
pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal;
std::span<const AnimationFrame> frames;
};
struct DocumentSnapshotConfig {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::span<const DocumentLayerConfig> layers;
std::span<const AnimationFrame> frames;
};
class CanvasDocument {
public:
[[nodiscard]] static pp::foundation::Result<CanvasDocument> create(DocumentConfig config);
[[nodiscard]] static pp::foundation::Result<CanvasDocument> create_from_snapshot(DocumentSnapshotConfig config);
[[nodiscard]] std::uint32_t width() const noexcept;
[[nodiscard]] std::uint32_t height() const noexcept;
[[nodiscard]] std::size_t active_layer_index() const noexcept;
[[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;
@@ -54,6 +88,7 @@ public:
[[nodiscard]] pp::foundation::Status set_active_layer(std::size_t index) noexcept;
[[nodiscard]] pp::foundation::Status rename_layer(std::size_t index, std::string_view name);
[[nodiscard]] pp::foundation::Status set_layer_visible(std::size_t index, bool visible) noexcept;
[[nodiscard]] pp::foundation::Status set_layer_alpha_locked(std::size_t index, bool alpha_locked) noexcept;
[[nodiscard]] pp::foundation::Status set_layer_opacity(std::size_t index, float opacity) noexcept;
[[nodiscard]] pp::foundation::Status set_layer_blend_mode(std::size_t index, pp::paint::BlendMode blend_mode) noexcept;
@@ -63,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);
}

210
src/paint/stroke_script.cpp Normal file
View File

@@ -0,0 +1,210 @@
#include "paint/stroke_script.h"
#include <array>
#include <cerrno>
#include <cmath>
#include <cstdlib>
namespace pp::paint {
namespace {
[[nodiscard]] std::string_view trim(std::string_view text) noexcept
{
while (!text.empty() && (text.front() == ' ' || text.front() == '\t' || text.front() == '\r')) {
text.remove_prefix(1);
}
while (!text.empty() && (text.back() == ' ' || text.back() == '\t' || text.back() == '\r')) {
text.remove_suffix(1);
}
return text;
}
[[nodiscard]] std::string_view strip_comment(std::string_view line) noexcept
{
const auto comment = line.find('#');
if (comment == std::string_view::npos) {
return line;
}
return line.substr(0, comment);
}
[[nodiscard]] pp::foundation::Result<float> parse_float_token(std::string_view token) noexcept
{
token = trim(token);
if (token.empty() || token.size() >= 64U) {
return pp::foundation::Result<float>::failure(
pp::foundation::Status::invalid_argument("stroke script numeric token is invalid"));
}
std::array<char, 64> buffer {};
for (std::size_t i = 0; i < token.size(); ++i) {
buffer[i] = token[i];
}
char* end = nullptr;
errno = 0;
const auto value = std::strtof(buffer.data(), &end);
if (errno != 0 || end != buffer.data() + static_cast<std::ptrdiff_t>(token.size()) || !std::isfinite(value)) {
return pp::foundation::Result<float>::failure(
pp::foundation::Status::invalid_argument("stroke script numeric token is invalid"));
}
return pp::foundation::Result<float>::success(value);
}
[[nodiscard]] pp::foundation::Result<std::size_t> split_tokens(
std::string_view line,
std::array<std::string_view, 8>& tokens) noexcept
{
std::size_t count = 0;
std::size_t offset = 0;
while (offset < line.size()) {
while (offset < line.size() && (line[offset] == ' ' || line[offset] == '\t')) {
++offset;
}
if (offset >= line.size()) {
break;
}
const auto token_start = offset;
while (offset < line.size() && line[offset] != ' ' && line[offset] != '\t') {
++offset;
}
if (count >= tokens.size()) {
return pp::foundation::Result<std::size_t>::failure(
pp::foundation::Status::invalid_argument("stroke script line has too many tokens"));
}
tokens[count] = line.substr(token_start, offset - token_start);
++count;
}
return pp::foundation::Result<std::size_t>::success(count);
}
[[nodiscard]] pp::foundation::Result<StrokeScriptStroke> parse_stroke_line(std::string_view line) noexcept
{
std::array<std::string_view, 8> tokens {};
const auto token_count = split_tokens(line, tokens);
if (!token_count) {
return pp::foundation::Result<StrokeScriptStroke>::failure(token_count.status());
}
if (token_count.value() != tokens.size() || tokens[0] != "stroke") {
return pp::foundation::Result<StrokeScriptStroke>::failure(
pp::foundation::Status::invalid_argument("stroke script line must be 'stroke x1 y1 p1 x2 y2 p2 spacing'"));
}
const auto x1 = parse_float_token(tokens[1]);
const auto y1 = parse_float_token(tokens[2]);
const auto p1 = parse_float_token(tokens[3]);
const auto x2 = parse_float_token(tokens[4]);
const auto y2 = parse_float_token(tokens[5]);
const auto p2 = parse_float_token(tokens[6]);
const auto spacing = parse_float_token(tokens[7]);
if (!x1) {
return pp::foundation::Result<StrokeScriptStroke>::failure(x1.status());
}
if (!y1) {
return pp::foundation::Result<StrokeScriptStroke>::failure(y1.status());
}
if (!p1) {
return pp::foundation::Result<StrokeScriptStroke>::failure(p1.status());
}
if (!x2) {
return pp::foundation::Result<StrokeScriptStroke>::failure(x2.status());
}
if (!y2) {
return pp::foundation::Result<StrokeScriptStroke>::failure(y2.status());
}
if (!p2) {
return pp::foundation::Result<StrokeScriptStroke>::failure(p2.status());
}
if (!spacing) {
return pp::foundation::Result<StrokeScriptStroke>::failure(spacing.status());
}
if (spacing.value() <= 0.0F) {
return pp::foundation::Result<StrokeScriptStroke>::failure(
pp::foundation::Status::invalid_argument("stroke script spacing must be greater than zero"));
}
return pp::foundation::Result<StrokeScriptStroke>::success(StrokeScriptStroke {
.start = StrokePoint {
.x = x1.value(),
.y = y1.value(),
.pressure = p1.value(),
},
.end = StrokePoint {
.x = x2.value(),
.y = y2.value(),
.pressure = p2.value(),
},
.spacing = spacing.value(),
});
}
}
pp::foundation::Result<StrokeScript> parse_stroke_script(std::string_view text)
{
if (text.empty()) {
return pp::foundation::Result<StrokeScript>::failure(
pp::foundation::Status::invalid_argument("stroke script must not be empty"));
}
if (text.size() > max_stroke_script_bytes) {
return pp::foundation::Result<StrokeScript>::failure(
pp::foundation::Status::out_of_range("stroke script exceeds the configured size limit"));
}
StrokeScript script;
std::size_t offset = 0;
while (offset <= text.size()) {
const auto line_start = offset;
const auto line_end = text.find('\n', line_start);
if (line_end == std::string_view::npos) {
offset = text.size() + 1U;
} else {
offset = line_end + 1U;
}
auto line = text.substr(line_start, (line_end == std::string_view::npos) ? std::string_view::npos : line_end - line_start);
if (line.size() > max_stroke_script_line_length) {
return pp::foundation::Result<StrokeScript>::failure(
pp::foundation::Status::out_of_range("stroke script line exceeds the configured length limit"));
}
line = trim(strip_comment(line));
if (line.empty()) {
continue;
}
if (script.strokes.size() >= max_stroke_script_strokes) {
return pp::foundation::Result<StrokeScript>::failure(
pp::foundation::Status::out_of_range("stroke script stroke count exceeds the configured limit"));
}
const auto stroke = parse_stroke_line(line);
if (!stroke) {
return pp::foundation::Result<StrokeScript>::failure(stroke.status());
}
script.strokes.push_back(stroke.value());
}
if (script.strokes.empty()) {
return pp::foundation::Result<StrokeScript>::failure(
pp::foundation::Status::invalid_argument("stroke script must contain at least one stroke"));
}
return pp::foundation::Result<StrokeScript>::success(script);
}
}

28
src/paint/stroke_script.h Normal file
View File

@@ -0,0 +1,28 @@
#pragma once
#include "foundation/result.h"
#include "paint/stroke.h"
#include <cstddef>
#include <string_view>
#include <vector>
namespace pp::paint {
constexpr std::size_t max_stroke_script_bytes = 1024 * 1024;
constexpr std::size_t max_stroke_script_line_length = 512;
constexpr std::size_t max_stroke_script_strokes = 10000;
struct StrokeScriptStroke {
StrokePoint start;
StrokePoint end;
float spacing = 1.0F;
};
struct StrokeScript {
std::vector<StrokeScriptStroke> strokes;
};
[[nodiscard]] pp::foundation::Result<StrokeScript> parse_stroke_script(std::string_view text);
}

View File

@@ -86,6 +86,16 @@ add_test(NAME pp_assets_image_metadata_tests COMMAND pp_assets_image_metadata_te
set_tests_properties(pp_assets_image_metadata_tests PROPERTIES
LABELS "assets;desktop-fast")
add_executable(pp_assets_image_pixels_tests
assets/image_pixels_tests.cpp)
target_link_libraries(pp_assets_image_pixels_tests PRIVATE
pp_assets
pp_test_harness)
add_test(NAME pp_assets_image_pixels_tests COMMAND pp_assets_image_pixels_tests)
set_tests_properties(pp_assets_image_pixels_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
@@ -136,6 +146,16 @@ add_test(NAME pp_paint_stroke_tests COMMAND pp_paint_stroke_tests)
set_tests_properties(pp_paint_stroke_tests PROPERTIES
LABELS "paint;desktop-fast")
add_executable(pp_paint_stroke_script_tests
paint/stroke_script_tests.cpp)
target_link_libraries(pp_paint_stroke_script_tests PRIVATE
pp_paint
pp_test_harness)
add_test(NAME pp_paint_stroke_script_tests COMMAND pp_paint_stroke_script_tests)
set_tests_properties(pp_paint_stroke_script_tests PROPERTIES
LABELS "paint;desktop-fast")
add_executable(pp_document_tests
document/document_tests.cpp)
target_link_libraries(pp_document_tests PRIVATE
@@ -146,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
@@ -220,8 +250,32 @@ if(TARGET pano_cli)
LABELS "assets;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"format\":\"png\".*\"width\":320.*\"height\":240.*\"components\":4.*\"colorType\":\"rgba\"")
add_test(NAME pano_cli_inspect_project_layout_smoke
COMMAND pano_cli inspect-project --path "${CMAKE_CURRENT_SOURCE_DIR}/data/projects/minimal-project.ppi")
set_tests_properties(pano_cli_inspect_project_layout_smoke PROPERTIES
LABELS "assets;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"thumbnail\":\\{\"width\":128,\"height\":128,\"components\":4,\"bytes\":65536\\}.*\"body\":\\{\"offset\":65576,\"bytes\":73,\"width\":64,\"height\":32,\"layers\":1,\"frames\":1,\"dirtyFaces\":0,\"rgbaFacePayloads\":0,\"compressedBytes\":0,\"infoBytes\":0\\}.*\"layers\":\\[\\{\"index\":0,\"storedOrder\":0,\"name\":\"Ink\"")
add_test(NAME pano_cli_load_project_metadata_smoke
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.*\"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")
set_tests_properties(pano_cli_parse_layout_smoke PROPERTIES
LABELS "ui;integration;desktop-fast")
add_test(NAME pano_cli_simulate_stroke_smoke
COMMAND pano_cli simulate-stroke --x1 0 --y1 0 --x2 10 --y2 0 --spacing 2)
set_tests_properties(pano_cli_simulate_stroke_smoke PROPERTIES
LABELS "paint;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"samples\":6.*\"distance\":10")
add_test(NAME pano_cli_simulate_stroke_script_smoke
COMMAND pano_cli simulate-stroke-script --path "${CMAKE_CURRENT_SOURCE_DIR}/data/strokes/two-strokes.ppstroke")
set_tests_properties(pano_cli_simulate_stroke_script_smoke PROPERTIES
LABELS "paint;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"strokes\":2.*\"samples\":9.*\"distance\":20")
endif()

View File

@@ -0,0 +1,67 @@
#include "assets/image_pixels.h"
#include "test_harness.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>
using pp::assets::decode_png_rgba8;
using pp::foundation::StatusCode;
namespace {
constexpr std::array<std::byte, 68> transparent_png_1x1 {
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 },
};
void decodes_png_to_rgba8_pixels(pp::tests::Harness& h)
{
const auto image = decode_png_rgba8(transparent_png_1x1);
PP_EXPECT(h, image.ok());
PP_EXPECT(h, image.value().width == 1U);
PP_EXPECT(h, image.value().height == 1U);
PP_EXPECT(h, image.value().pixels.size() == 4U);
PP_EXPECT(h, image.value().pixels[0] == 0U);
PP_EXPECT(h, image.value().pixels[1] == 0U);
PP_EXPECT(h, image.value().pixels[2] == 0U);
PP_EXPECT(h, image.value().pixels[3] == 0U);
}
void rejects_corrupt_png_payload(pp::tests::Harness& h)
{
auto corrupt = transparent_png_1x1;
corrupt[0] = std::byte { 0x00 };
const auto image = decode_png_rgba8(corrupt);
PP_EXPECT(h, !image.ok());
PP_EXPECT(h, image.status().code == StatusCode::invalid_argument);
}
}
int main()
{
pp::tests::Harness harness;
harness.run("decodes_png_to_rgba8_pixels", decodes_png_to_rgba8_pixels);
harness.run("rejects_corrupt_png_payload", rejects_corrupt_png_payload);
return harness.finish();
}

View File

@@ -2,12 +2,19 @@
#include "test_harness.h"
#include <array>
#include <bit>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <vector>
using pp::assets::parse_ppi_header;
using pp::assets::decode_ppi_project_images;
using pp::assets::parse_ppi_project_index;
using pp::assets::parse_ppi_project_summary;
using pp::assets::parse_ppi_project_layout;
using pp::assets::ppi_header_size;
using pp::assets::ppi_thumbnail_byte_size;
using pp::foundation::StatusCode;
namespace {
@@ -20,6 +27,26 @@ void append_u32(std::vector<std::byte>& bytes, std::uint32_t value)
bytes.push_back(static_cast<std::byte>((value >> 24U) & 0xffU));
}
void append_u32_be(std::vector<std::byte>& bytes, std::uint32_t value)
{
bytes.push_back(static_cast<std::byte>((value >> 24U) & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 16U) & 0xffU));
bytes.push_back(static_cast<std::byte>((value >> 8U) & 0xffU));
bytes.push_back(static_cast<std::byte>(value & 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> valid_header()
{
std::vector<std::byte> bytes {
@@ -40,6 +67,127 @@ std::vector<std::byte> valid_header()
return bytes;
}
void append_minimal_body(std::vector<std::byte>& bytes)
{
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);
for (std::uint32_t i = 0; i < 6U; ++i) {
append_u32(bytes, 0);
}
append_u32(bytes, 0);
}
std::vector<std::byte> png_ihdr_payload(
std::uint32_t width,
std::uint32_t height,
std::uint8_t bit_depth = 8U,
std::uint8_t color_type = 6U)
{
std::vector<std::byte> 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 },
};
append_u32_be(bytes, 13);
bytes.push_back(std::byte { 'I' });
bytes.push_back(std::byte { 'H' });
bytes.push_back(std::byte { 'D' });
bytes.push_back(std::byte { 'R' });
append_u32_be(bytes, width);
append_u32_be(bytes, height);
bytes.push_back(static_cast<std::byte>(bit_depth));
bytes.push_back(static_cast<std::byte>(color_type));
bytes.push_back(std::byte { 0 });
bytes.push_back(std::byte { 0 });
bytes.push_back(std::byte { 0 });
append_u32_be(bytes, 0);
return bytes;
}
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> minimal_project()
{
auto bytes = valid_header();
bytes.resize(ppi_header_size + (128U * 128U * 4U), std::byte { 0 });
append_minimal_body(bytes);
return bytes;
}
std::vector<std::byte> project_with_single_face_payload(
std::vector<std::byte> payload,
std::uint32_t dirty_width = 8,
std::uint32_t dirty_height = 4)
{
auto bytes = valid_header();
bytes.resize(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, 2 + dirty_width);
append_u32(bytes, 3 + dirty_height);
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 parses_legacy_ppi_header(pp::tests::Harness& h)
{
const auto bytes = valid_header();
@@ -96,6 +244,146 @@ void rejects_unsupported_document_versions(pp::tests::Harness& h)
PP_EXPECT(h, minor_result.status().code == StatusCode::invalid_argument);
}
void parses_project_layout_with_thumbnail_and_body(pp::tests::Harness& h)
{
const auto bytes = minimal_project();
const auto layout = parse_ppi_project_layout(bytes);
PP_EXPECT(h, layout.ok());
PP_EXPECT(h, layout.value().thumbnail_offset == ppi_header_size);
PP_EXPECT(h, layout.value().thumbnail_bytes == 128U * 128U * 4U);
PP_EXPECT(h, layout.value().body_offset == ppi_header_size + (128U * 128U * 4U));
PP_EXPECT(h, layout.value().body_bytes == 73U);
}
void rejects_project_layout_with_truncated_thumbnail(pp::tests::Harness& h)
{
auto bytes = valid_header();
bytes.resize(ppi_header_size + (128U * 128U * 4U) - 1U, std::byte { 0 });
const auto layout = parse_ppi_project_layout(bytes);
PP_EXPECT(h, !layout.ok());
PP_EXPECT(h, layout.status().code == StatusCode::out_of_range);
}
void parses_minimal_project_body_summary(pp::tests::Harness& h)
{
const auto project = minimal_project();
const auto summary = parse_ppi_project_summary(project);
PP_EXPECT(h, summary.ok());
PP_EXPECT(h, summary.value().body.width == 64U);
PP_EXPECT(h, summary.value().body.height == 32U);
PP_EXPECT(h, summary.value().body.layer_count == 1U);
PP_EXPECT(h, summary.value().body.declared_frame_count == 1U);
PP_EXPECT(h, summary.value().body.total_layer_frames == 1U);
PP_EXPECT(h, summary.value().body.dirty_face_count == 0U);
PP_EXPECT(h, summary.value().body.rgba_face_payload_count == 0U);
PP_EXPECT(h, summary.value().body.compressed_face_bytes == 0U);
PP_EXPECT(h, summary.value().body.info_bytes == 0U);
}
void indexes_project_layers_frames_and_faces(pp::tests::Harness& h)
{
const auto project = project_with_single_face_payload(png_ihdr_payload(8, 4));
const auto index = parse_ppi_project_index(project);
PP_EXPECT(h, index.ok());
PP_EXPECT(h, index.value().body.layers.size() == 1U);
PP_EXPECT(h, index.value().body.layers[0].stored_order == 0U);
PP_EXPECT(h, index.value().body.layers[0].name == "Ink");
PP_EXPECT(h, index.value().body.layers[0].visible);
PP_EXPECT(h, index.value().body.layers[0].frames.size() == 1U);
PP_EXPECT(h, index.value().body.layers[0].frames[0].duration_ms == 100U);
PP_EXPECT(h, index.value().body.layers[0].frames[0].faces[0].has_data);
PP_EXPECT(h, index.value().body.layers[0].frames[0].faces[0].x0 == 2U);
PP_EXPECT(h, index.value().body.layers[0].frames[0].faces[0].x1 == 10U);
PP_EXPECT(h, index.value().body.layers[0].frames[0].faces[0].payload_bytes == 33U);
PP_EXPECT(h, index.value().body.layers[0].frames[0].faces[0].png_width == 8U);
PP_EXPECT(h, !index.value().body.layers[0].frames[0].faces[1].has_data);
}
void validates_dirty_face_png_payload_metadata(pp::tests::Harness& h)
{
const auto project = project_with_single_face_payload(png_ihdr_payload(8, 4));
const auto summary = parse_ppi_project_summary(project);
PP_EXPECT(h, summary.ok());
PP_EXPECT(h, summary.value().body.dirty_face_count == 1U);
PP_EXPECT(h, summary.value().body.rgba_face_payload_count == 1U);
PP_EXPECT(h, summary.value().body.compressed_face_bytes == 33U);
}
void decodes_dirty_face_png_payloads(pp::tests::Harness& h)
{
const auto project = project_with_single_face_payload(transparent_png_1x1(), 1, 1);
const auto decoded = decode_ppi_project_images(project);
PP_EXPECT(h, decoded.ok());
PP_EXPECT(h, decoded.value().faces.size() == 1U);
PP_EXPECT(h, decoded.value().faces[0].layer_index == 0U);
PP_EXPECT(h, decoded.value().faces[0].frame_index == 0U);
PP_EXPECT(h, decoded.value().faces[0].face_index == 0U);
PP_EXPECT(h, decoded.value().faces[0].image.width == 1U);
PP_EXPECT(h, decoded.value().faces[0].image.height == 1U);
PP_EXPECT(h, decoded.value().faces[0].image.pixels.size() == 4U);
PP_EXPECT(h, decoded.value().faces[0].image.pixels[3] == 0U);
}
void rejects_metadata_only_payload_when_decoding_pixels(pp::tests::Harness& h)
{
const auto project = project_with_single_face_payload(png_ihdr_payload(8, 4));
const auto decoded = decode_ppi_project_images(project);
PP_EXPECT(h, !decoded.ok());
PP_EXPECT(h, decoded.status().code == StatusCode::invalid_argument);
}
void rejects_invalid_dirty_face_png_payloads(pp::tests::Harness& h)
{
auto mismatched_dimensions = project_with_single_face_payload(png_ihdr_payload(7, 4));
auto non_rgba = project_with_single_face_payload(png_ihdr_payload(8, 4, 8, 2));
auto bad_signature_payload = png_ihdr_payload(8, 4);
bad_signature_payload[0] = std::byte { 0 };
auto bad_signature = project_with_single_face_payload(bad_signature_payload);
const auto mismatched_result = parse_ppi_project_summary(mismatched_dimensions);
const auto non_rgba_result = parse_ppi_project_summary(non_rgba);
const auto bad_signature_result = parse_ppi_project_summary(bad_signature);
PP_EXPECT(h, !mismatched_result.ok());
PP_EXPECT(h, mismatched_result.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !non_rgba_result.ok());
PP_EXPECT(h, non_rgba_result.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !bad_signature_result.ok());
PP_EXPECT(h, bad_signature_result.status().code == StatusCode::invalid_argument);
}
void rejects_invalid_project_body_summaries(pp::tests::Harness& h)
{
auto truncated = minimal_project();
truncated.pop_back();
auto mismatched_frames = minimal_project();
mismatched_frames[ppi_header_size + (128U * 128U * 4U) + 12U] = std::byte { 2 };
auto bad_layer_name = minimal_project();
bad_layer_name[ppi_header_size + (128U * 128U * 4U) + 24U] = std::byte { 255 };
const auto truncated_result = parse_ppi_project_summary(truncated);
const auto mismatched_frames_result = parse_ppi_project_summary(mismatched_frames);
const auto bad_layer_name_result = parse_ppi_project_summary(bad_layer_name);
PP_EXPECT(h, !truncated_result.ok());
PP_EXPECT(h, truncated_result.status().code == StatusCode::out_of_range);
PP_EXPECT(h, !mismatched_frames_result.ok());
PP_EXPECT(h, mismatched_frames_result.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !bad_layer_name_result.ok());
PP_EXPECT(h, bad_layer_name_result.status().code == StatusCode::out_of_range);
}
}
int main()
@@ -104,5 +392,14 @@ int main()
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);
harness.run("parses_project_layout_with_thumbnail_and_body", parses_project_layout_with_thumbnail_and_body);
harness.run("rejects_project_layout_with_truncated_thumbnail", rejects_project_layout_with_truncated_thumbnail);
harness.run("parses_minimal_project_body_summary", parses_minimal_project_body_summary);
harness.run("indexes_project_layers_frames_and_faces", indexes_project_layers_frames_and_faces);
harness.run("validates_dirty_face_png_payload_metadata", validates_dirty_face_png_payload_metadata);
harness.run("decodes_dirty_face_png_payloads", decodes_dirty_face_png_payloads);
harness.run("rejects_metadata_only_payload_when_decoding_pixels", rejects_metadata_only_payload_when_decoding_pixels);
harness.run("rejects_invalid_dirty_face_png_payloads", rejects_invalid_dirty_face_png_payloads);
harness.run("rejects_invalid_project_body_summaries", rejects_invalid_project_body_summaries);
return harness.finish();
}

Binary file not shown.

View File

@@ -0,0 +1,3 @@
# PanoPainter automation stroke script
stroke 0 0 0.25 10 0 0.75 2
stroke 10 0 1 10 10 0.5 5

View File

@@ -8,6 +8,10 @@ using pp::paint::BlendMode;
using pp::document::CanvasDocument;
using pp::document::DocumentHistory;
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;
@@ -31,6 +35,8 @@ void creates_document_with_default_layers(pp::tests::Harness& h)
PP_EXPECT(h, document.value().active_layer_index() == 0U);
PP_EXPECT(h, document.value().frames().size() == 1U);
PP_EXPECT(h, document.value().frames()[0].duration_ms == 100U);
PP_EXPECT(h, document.value().layers()[0].frames.size() == 1U);
PP_EXPECT(h, document.value().layers()[0].frames[0].duration_ms == 100U);
PP_EXPECT(h, document.value().animation_duration_ms() == 100U);
PP_EXPECT(h, document.value().active_frame_index() == 0U);
}
@@ -106,11 +112,13 @@ void updates_layer_metadata(pp::tests::Harness& h)
PP_EXPECT(h, document.rename_layer(1, "Ink").ok());
PP_EXPECT(h, document.set_layer_visible(1, false).ok());
PP_EXPECT(h, document.set_layer_alpha_locked(1, true).ok());
PP_EXPECT(h, document.set_layer_opacity(1, 0.25F).ok());
PP_EXPECT(h, document.set_layer_blend_mode(1, BlendMode::multiply).ok());
PP_EXPECT(h, document.layers()[1].name == std::string_view("Ink"));
PP_EXPECT(h, !document.layers()[1].visible);
PP_EXPECT(h, document.layers()[1].alpha_locked);
PP_EXPECT(h, std::fabs(document.layers()[1].opacity - 0.25F) < 0.0001F);
PP_EXPECT(h, document.layers()[1].blend_mode == BlendMode::multiply);
}
@@ -129,6 +137,7 @@ void rejects_invalid_layer_metadata(pp::tests::Harness& h)
const auto bad_opacity_high = document.set_layer_opacity(0, 1.1F);
const auto bad_opacity_nan = document.set_layer_opacity(0, std::nanf(""));
const auto missing_visible = document.set_layer_visible(2, true);
const auto missing_alpha_lock = document.set_layer_alpha_locked(2, true);
const auto missing_blend = document.set_layer_blend_mode(2, BlendMode::normal);
const auto bad_blend = document.set_layer_blend_mode(0, static_cast<BlendMode>(255));
const auto bad_add_layer = document.add_layer(std::string(max_layer_name_length + 1U, 'x'));
@@ -147,6 +156,8 @@ void rejects_invalid_layer_metadata(pp::tests::Harness& h)
PP_EXPECT(h, bad_opacity_nan.code == StatusCode::out_of_range);
PP_EXPECT(h, !missing_visible.ok());
PP_EXPECT(h, missing_visible.code == StatusCode::out_of_range);
PP_EXPECT(h, !missing_alpha_lock.ok());
PP_EXPECT(h, missing_alpha_lock.code == StatusCode::out_of_range);
PP_EXPECT(h, !missing_blend.ok());
PP_EXPECT(h, missing_blend.code == StatusCode::out_of_range);
PP_EXPECT(h, !bad_blend.ok());
@@ -155,6 +166,143 @@ void rejects_invalid_layer_metadata(pp::tests::Harness& h)
PP_EXPECT(h, bad_add_layer.status().code == StatusCode::out_of_range);
}
void creates_document_from_snapshot_metadata(pp::tests::Harness& h)
{
const DocumentLayerConfig layers[] {
{
.name = "Ink",
.visible = false,
.alpha_locked = true,
.opacity = 0.5F,
.blend_mode = BlendMode::screen,
.frames = {},
},
{
.name = "Glaze",
.visible = true,
.alpha_locked = false,
.opacity = 0.75F,
.blend_mode = BlendMode::overlay,
.frames = {},
},
};
const AnimationFrame frames[] {
{ .duration_ms = 100, .face_pixels = {} },
{ .duration_ms = 250, .face_pixels = {} },
};
const auto document_result = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 128,
.height = 64,
.layers = layers,
.frames = frames,
});
PP_EXPECT(h, document_result.ok());
PP_EXPECT(h, document_result.value().layers().size() == 2U);
PP_EXPECT(h, document_result.value().layers()[0].name == std::string_view("Ink"));
PP_EXPECT(h, !document_result.value().layers()[0].visible);
PP_EXPECT(h, document_result.value().layers()[0].alpha_locked);
PP_EXPECT(h, document_result.value().layers()[0].blend_mode == BlendMode::screen);
PP_EXPECT(h, document_result.value().layers()[0].frames.size() == 2U);
PP_EXPECT(h, document_result.value().layers()[0].frames[1].duration_ms == 250U);
PP_EXPECT(h, document_result.value().frames().size() == 2U);
PP_EXPECT(h, document_result.value().animation_duration_ms() == 350U);
}
void preserves_per_layer_snapshot_timelines(pp::tests::Harness& h)
{
const AnimationFrame project_frames[] {
{ .duration_ms = 100, .face_pixels = {} },
};
const AnimationFrame short_layer_frames[] {
{ .duration_ms = 100, .face_pixels = {} },
{ .duration_ms = 150, .face_pixels = {} },
};
const AnimationFrame long_layer_frames[] {
{ .duration_ms = 500, .face_pixels = {} },
};
const DocumentLayerConfig layers[] {
{
.name = "Short",
.frames = short_layer_frames,
},
{
.name = "Long",
.frames = long_layer_frames,
},
};
const auto document_result = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 128,
.height = 64,
.layers = layers,
.frames = project_frames,
});
PP_EXPECT(h, document_result.ok());
PP_EXPECT(h, document_result.value().frames().size() == 1U);
PP_EXPECT(h, document_result.value().layers()[0].frames.size() == 2U);
PP_EXPECT(h, document_result.value().layers()[1].frames.size() == 1U);
PP_EXPECT(h, document_result.value().layers()[0].frames[1].duration_ms == 150U);
PP_EXPECT(h, document_result.value().layers()[1].frames[0].duration_ms == 500U);
PP_EXPECT(h, document_result.value().animation_duration_ms() == 500U);
const auto layer_duration = document_result.value().layer_animation_duration_ms(0);
PP_EXPECT(h, layer_duration.ok());
PP_EXPECT(h, layer_duration.value() == 250U);
}
void rejects_invalid_snapshot_metadata(pp::tests::Harness& h)
{
const DocumentLayerConfig layers[] { { .name = "Ink", .frames = {} } };
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 } };
const auto no_layers = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 64,
.height = 64,
.layers = {},
.frames = frames,
});
const auto no_frames = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 64,
.height = 64,
.layers = layers,
.frames = {},
});
const auto bad_frame = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 64,
.height = 64,
.layers = layers,
.frames = bad_frames,
});
const auto bad_layer = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 64,
.height = 64,
.layers = bad_layers,
.frames = frames,
});
const auto bad_layer_frame = CanvasDocument::create_from_snapshot(DocumentSnapshotConfig {
.width = 64,
.height = 64,
.layers = bad_layer_frames,
.frames = frames,
});
PP_EXPECT(h, !no_layers.ok());
PP_EXPECT(h, no_layers.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !no_frames.ok());
PP_EXPECT(h, no_frames.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !bad_frame.ok());
PP_EXPECT(h, bad_frame.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !bad_layer.ok());
PP_EXPECT(h, bad_layer.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !bad_layer_frame.ok());
PP_EXPECT(h, bad_layer_frame.status().code == StatusCode::invalid_argument);
}
void manages_animation_frames_and_duration(pp::tests::Harness& h)
{
auto document_result = CanvasDocument::create(
@@ -167,6 +315,7 @@ void manages_animation_frames_and_duration(pp::tests::Harness& h)
PP_EXPECT(h, added.value() == 1U);
PP_EXPECT(h, document.active_frame_index() == 1U);
PP_EXPECT(h, document.frames()[1].duration_ms == 250U);
PP_EXPECT(h, document.layers()[0].frames[1].duration_ms == 250U);
const auto duplicated = document.duplicate_frame(1);
PP_EXPECT(h, duplicated.ok());
@@ -174,6 +323,7 @@ void manages_animation_frames_and_duration(pp::tests::Harness& h)
PP_EXPECT(h, document.frames()[2].duration_ms == 250U);
PP_EXPECT(h, document.set_frame_duration(2, 333).ok());
PP_EXPECT(h, document.frames()[2].duration_ms == 333U);
PP_EXPECT(h, document.layers()[0].frames[2].duration_ms == 333U);
PP_EXPECT(h, document.animation_duration_ms() == 683U);
PP_EXPECT(h, document.remove_frame(1).ok());
@@ -244,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(
@@ -366,9 +625,15 @@ int main()
harness.run("moves_layers_and_preserves_active_layer_identity", moves_layers_and_preserves_active_layer_identity);
harness.run("updates_layer_metadata", updates_layer_metadata);
harness.run("rejects_invalid_layer_metadata", rejects_invalid_layer_metadata);
harness.run("creates_document_from_snapshot_metadata", creates_document_from_snapshot_metadata);
harness.run("preserves_per_layer_snapshot_timelines", preserves_per_layer_snapshot_timelines);
harness.run("rejects_invalid_snapshot_metadata", rejects_invalid_snapshot_metadata);
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

@@ -0,0 +1,86 @@
#include "paint/stroke_script.h"
#include "test_harness.h"
#include <string>
#include <string_view>
using pp::foundation::StatusCode;
using pp::paint::max_stroke_script_bytes;
using pp::paint::max_stroke_script_line_length;
using pp::paint::parse_stroke_script;
namespace {
void parses_comments_and_multiple_strokes(pp::tests::Harness& h)
{
constexpr std::string_view script_text =
"# scripted automation fixture\n"
"stroke 0 0 0.25 10 0 0.75 2\n"
"\n"
"stroke 10 0 1 10 10 0.5 5 # trailing comment\n";
const auto script = parse_stroke_script(script_text);
PP_EXPECT(h, script.ok());
PP_EXPECT(h, script.value().strokes.size() == 2U);
PP_EXPECT(h, script.value().strokes[0].start.x == 0.0F);
PP_EXPECT(h, script.value().strokes[0].start.pressure == 0.25F);
PP_EXPECT(h, script.value().strokes[0].end.x == 10.0F);
PP_EXPECT(h, script.value().strokes[0].spacing == 2.0F);
PP_EXPECT(h, script.value().strokes[1].end.y == 10.0F);
PP_EXPECT(h, script.value().strokes[1].end.pressure == 0.5F);
}
void rejects_malformed_stroke_scripts(pp::tests::Harness& h)
{
const auto empty = parse_stroke_script("");
const auto comments_only = parse_stroke_script("# nope\n\n");
const auto unknown = parse_stroke_script("move 0 0 1 10 0 1 2\n");
const auto missing_tokens = parse_stroke_script("stroke 0 0 1 10 0 1\n");
const auto too_many_tokens = parse_stroke_script("stroke 0 0 1 10 0 1 2 extra\n");
const auto bad_number = parse_stroke_script("stroke 0 0 1 10 nope 1 2\n");
const auto nan_number = parse_stroke_script("stroke 0 0 1 10 nan 1 2\n");
const auto zero_spacing = parse_stroke_script("stroke 0 0 1 10 0 1 0\n");
PP_EXPECT(h, !empty.ok());
PP_EXPECT(h, empty.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !comments_only.ok());
PP_EXPECT(h, comments_only.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !unknown.ok());
PP_EXPECT(h, unknown.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !missing_tokens.ok());
PP_EXPECT(h, missing_tokens.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !too_many_tokens.ok());
PP_EXPECT(h, too_many_tokens.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !bad_number.ok());
PP_EXPECT(h, bad_number.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !nan_number.ok());
PP_EXPECT(h, nan_number.status().code == StatusCode::invalid_argument);
PP_EXPECT(h, !zero_spacing.ok());
PP_EXPECT(h, zero_spacing.status().code == StatusCode::invalid_argument);
}
void rejects_oversized_stroke_scripts(pp::tests::Harness& h)
{
const std::string oversized_script(max_stroke_script_bytes + 1U, 'x');
const std::string oversized_line(max_stroke_script_line_length + 1U, 'x');
const auto too_large_script = parse_stroke_script(oversized_script);
const auto too_large_line = parse_stroke_script(oversized_line);
PP_EXPECT(h, !too_large_script.ok());
PP_EXPECT(h, too_large_script.status().code == StatusCode::out_of_range);
PP_EXPECT(h, !too_large_line.ok());
PP_EXPECT(h, too_large_line.status().code == StatusCode::out_of_range);
}
}
int main()
{
pp::tests::Harness harness;
harness.run("parses_comments_and_multiple_strokes", parses_comments_and_multiple_strokes);
harness.run("rejects_malformed_stroke_scripts", rejects_malformed_stroke_scripts);
harness.run("rejects_oversized_stroke_scripts", rejects_oversized_stroke_scripts);
return harness.finish();
}

View File

@@ -2,8 +2,11 @@
#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"
#include "paint/stroke_script.h"
#include "ui_core/layout_xml.h"
#include <cstdint>
@@ -37,12 +40,66 @@ struct InspectProjectArgs {
std::string path;
};
struct SimulateStrokeArgs {
std::uint32_t x1 = 0;
std::uint32_t y1 = 0;
std::uint32_t x2 = 0;
std::uint32_t y2 = 0;
std::uint32_t spacing = 1;
};
struct SimulateStrokeScriptArgs {
std::string path;
};
void print_error(std::string_view command, std::string_view message)
{
std::cout << "{\"ok\":false,\"command\":\"" << command
<< "\",\"error\":\"" << message << "\"}\n";
}
std::string json_escape(std::string_view value)
{
constexpr char hex[] = "0123456789abcdef";
std::string escaped;
escaped.reserve(value.size());
for (const unsigned char ch : value) {
switch (ch) {
case '"':
escaped += "\\\"";
break;
case '\\':
escaped += "\\\\";
break;
case '\b':
escaped += "\\b";
break;
case '\f':
escaped += "\\f";
break;
case '\n':
escaped += "\\n";
break;
case '\r':
escaped += "\\r";
break;
case '\t':
escaped += "\\t";
break;
default:
if (ch < 0x20U) {
escaped += "\\u00";
escaped.push_back(hex[(ch >> 4U) & 0x0fU]);
escaped.push_back(hex[ch & 0x0fU]);
} else {
escaped.push_back(static_cast<char>(ch));
}
break;
}
}
return escaped;
}
void print_help()
{
std::cout
@@ -50,7 +107,10 @@ void print_help()
<< " create-document --width N --height N [--layers N] [--frames N] [--frame-duration-ms N]\n"
<< " inspect-image --path FILE\n"
<< " inspect-project --path FILE\n"
<< " load-project --path FILE\n"
<< " parse-layout --path FILE\n"
<< " simulate-stroke --x1 N --y1 N --x2 N --y2 N [--spacing N]\n"
<< " simulate-stroke-script --path FILE\n"
<< " --help\n";
}
@@ -271,23 +331,296 @@ int inspect_project(int argc, char** argv)
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);
const auto project = pp::assets::parse_ppi_project_index(std::span<const std::byte>(data, chars.size()));
if (!project) {
print_error("inspect-project", project.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";
<< ",\"documentVersion\":\"" << project.value().layout.header.document_version.major
<< "." << project.value().layout.header.document_version.minor << "\""
<< ",\"softwareVersion\":\"" << project.value().layout.header.software_version.major
<< "." << project.value().layout.header.software_version.minor
<< "." << project.value().layout.header.software_version.fix
<< "." << project.value().layout.header.software_version.build << "\""
<< ",\"thumbnail\":{\"width\":" << project.value().layout.header.thumbnail.width
<< ",\"height\":" << project.value().layout.header.thumbnail.height
<< ",\"components\":" << project.value().layout.header.thumbnail.components
<< ",\"bytes\":" << project.value().layout.thumbnail_bytes
<< "},\"body\":{\"offset\":" << project.value().layout.body_offset
<< ",\"bytes\":" << project.value().layout.body_bytes
<< ",\"width\":" << project.value().body.summary.width
<< ",\"height\":" << project.value().body.summary.height
<< ",\"layers\":" << project.value().body.summary.layer_count
<< ",\"frames\":" << project.value().body.summary.declared_frame_count
<< ",\"dirtyFaces\":" << project.value().body.summary.dirty_face_count
<< ",\"rgbaFacePayloads\":" << project.value().body.summary.rgba_face_payload_count
<< ",\"compressedBytes\":" << project.value().body.summary.compressed_face_bytes
<< ",\"infoBytes\":" << project.value().body.summary.info_bytes
<< "},\"layers\":[";
for (std::size_t layer_index = 0; layer_index < project.value().body.layers.size(); ++layer_index) {
const auto& layer = project.value().body.layers[layer_index];
if (layer_index != 0U) {
std::cout << ",";
}
std::cout << "{\"index\":" << layer_index
<< ",\"storedOrder\":" << layer.stored_order
<< ",\"name\":\"" << json_escape(layer.name) << "\""
<< ",\"opacity\":" << layer.opacity
<< ",\"blendMode\":" << layer.blend_mode
<< ",\"alphaLocked\":" << (layer.alpha_locked ? "true" : "false")
<< ",\"visible\":" << (layer.visible ? "true" : "false")
<< ",\"frames\":[";
for (std::size_t frame_index = 0; frame_index < layer.frames.size(); ++frame_index) {
const auto& frame = layer.frames[frame_index];
if (frame_index != 0U) {
std::cout << ",";
}
std::cout << "{\"index\":" << frame_index
<< ",\"durationMs\":" << frame.duration_ms
<< ",\"dirtyFaces\":[";
bool first_face = true;
for (std::size_t face_index = 0; face_index < frame.faces.size(); ++face_index) {
const auto& face = frame.faces[face_index];
if (!face.has_data) {
continue;
}
if (!first_face) {
std::cout << ",";
}
first_face = false;
std::cout << "{\"face\":" << face_index
<< ",\"box\":[" << face.x0 << "," << face.y0 << "," << face.x1 << "," << face.y1 << "]"
<< ",\"bodyPayloadOffset\":" << face.body_payload_offset
<< ",\"bytes\":" << face.payload_bytes
<< ",\"png\":{\"width\":" << face.png_width
<< ",\"height\":" << face.png_height
<< "}}";
}
std::cout << "]}";
}
std::cout << "]}";
}
std::cout << "]}\n";
return 0;
}
int load_project(int argc, char** argv)
{
InspectProjectArgs args;
const auto status = parse_inspect_project_args(argc, argv, args);
if (!status.ok()) {
print_error("load-project", status.message);
return 2;
}
std::ifstream stream(args.path, std::ios::binary);
if (!stream) {
print_error("load-project", "project file could not be opened");
return 2;
}
const std::string chars {
std::istreambuf_iterator<char>(stream),
std::istreambuf_iterator<char>()
};
const auto* data = reinterpret_cast<const std::byte*>(chars.data());
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 = pp::document::import_ppi_project_document(project.value());
if (!document_result) {
print_error("load-project", document_result.status().message);
return 2;
}
const auto& document = document_result.value();
std::cout << "{\"ok\":true,\"command\":\"load-project\""
<< ",\"source\":\"ppi\""
<< ",\"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()
<< ",\"frames\":" << document.frames().size()
<< ",\"animationDurationMs\":" << document.animation_duration_ms()
<< ",\"layerNames\":[";
for (std::size_t layer_index = 0; layer_index < document.layers().size(); ++layer_index) {
if (layer_index != 0U) {
std::cout << ",";
}
std::cout << "\"" << json_escape(document.layers()[layer_index].name) << "\"";
}
std::cout << "],\"layerFrameCounts\":[";
for (std::size_t layer_index = 0; layer_index < document.layers().size(); ++layer_index) {
if (layer_index != 0U) {
std::cout << ",";
}
std::cout << document.layers()[layer_index].frames.size();
}
std::cout << "],\"layerDurationsMs\":[";
for (std::size_t layer_index = 0; layer_index < document.layers().size(); ++layer_index) {
if (layer_index != 0U) {
std::cout << ",";
}
const auto duration = document.layer_animation_duration_ms(layer_index);
std::cout << (duration ? duration.value() : 0U);
}
std::cout << "]}}\n";
return 0;
}
pp::foundation::Status parse_simulate_stroke_args(int argc, char** argv, SimulateStrokeArgs& args)
{
for (int i = 2; i < argc; ++i) {
const std::string_view key(argv[i]);
if (key == "--x1" || key == "--y1" || key == "--x2" || key == "--y2" || key == "--spacing") {
if (i + 1 >= argc) {
return pp::foundation::Status::invalid_argument("missing value for option");
}
const auto value = pp::foundation::parse_u32(argv[++i]);
if (!value) {
return value.status();
}
if (key == "--x1") {
args.x1 = value.value();
} else if (key == "--y1") {
args.y1 = value.value();
} else if (key == "--x2") {
args.x2 = value.value();
} else if (key == "--y2") {
args.y2 = value.value();
} else {
args.spacing = value.value();
}
} else {
return pp::foundation::Status::invalid_argument("unknown option");
}
}
if (args.spacing == 0) {
return pp::foundation::Status::invalid_argument("stroke spacing must be greater than zero");
}
return pp::foundation::Status::success();
}
int simulate_stroke(int argc, char** argv)
{
SimulateStrokeArgs args;
const auto status = parse_simulate_stroke_args(argc, argv, args);
if (!status.ok()) {
print_error("simulate-stroke", status.message);
return 2;
}
const pp::paint::StrokePoint points[] {
pp::paint::StrokePoint {
.x = static_cast<float>(args.x1),
.y = static_cast<float>(args.y1),
.pressure = 1.0F,
},
pp::paint::StrokePoint {
.x = static_cast<float>(args.x2),
.y = static_cast<float>(args.y2),
.pressure = 1.0F,
},
};
const auto samples = pp::paint::sample_stroke(
points,
pp::paint::StrokeSamplingConfig {
.spacing = static_cast<float>(args.spacing),
});
if (!samples) {
print_error("simulate-stroke", samples.status().message);
return 2;
}
const auto& first = samples.value().front();
const auto& last = samples.value().back();
std::cout << "{\"ok\":true,\"command\":\"simulate-stroke\""
<< ",\"samples\":" << samples.value().size()
<< ",\"first\":{\"x\":" << first.x << ",\"y\":" << first.y << "}"
<< ",\"last\":{\"x\":" << last.x << ",\"y\":" << last.y
<< ",\"distance\":" << last.distance << "}}\n";
return 0;
}
pp::foundation::Status parse_simulate_stroke_script_args(int argc, char** argv, SimulateStrokeScriptArgs& 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 simulate_stroke_script(int argc, char** argv)
{
SimulateStrokeScriptArgs args;
const auto status = parse_simulate_stroke_script_args(argc, argv, args);
if (!status.ok()) {
print_error("simulate-stroke-script", status.message);
return 2;
}
std::ifstream stream(args.path, std::ios::binary);
if (!stream) {
print_error("simulate-stroke-script", "stroke script file could not be opened");
return 2;
}
const std::string text {
std::istreambuf_iterator<char>(stream),
std::istreambuf_iterator<char>()
};
const auto script = pp::paint::parse_stroke_script(text);
if (!script) {
print_error("simulate-stroke-script", script.status().message);
return 2;
}
std::size_t total_samples = 0;
float total_distance = 0.0F;
for (const auto& stroke : script.value().strokes) {
const pp::paint::StrokePoint points[] { stroke.start, stroke.end };
const auto samples = pp::paint::sample_stroke(
points,
pp::paint::StrokeSamplingConfig {
.spacing = stroke.spacing,
});
if (!samples) {
print_error("simulate-stroke-script", samples.status().message);
return 2;
}
total_samples += samples.value().size();
total_distance += samples.value().back().distance;
}
std::cout << "{\"ok\":true,\"command\":\"simulate-stroke-script\""
<< ",\"strokes\":" << script.value().strokes.size()
<< ",\"samples\":" << total_samples
<< ",\"distance\":" << total_distance << "}\n";
return 0;
}
@@ -372,6 +705,18 @@ int main(int argc, char** argv)
return inspect_project(argc, argv);
}
if (command == "load-project") {
return load_project(argc, argv);
}
if (command == "simulate-stroke") {
return simulate_stroke(argc, argv);
}
if (command == "simulate-stroke-script") {
return simulate_stroke_script(argc, argv);
}
if (command == "parse-layout") {
return parse_layout(argc, argv);
}