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 add_library(pp_assets STATIC
src/assets/image_format.cpp src/assets/image_format.cpp
src/assets/image_metadata.cpp src/assets/image_metadata.cpp
src/assets/image_pixels.cpp
src/assets/ppi_header.cpp src/assets/ppi_header.cpp
src/assets/settings_document.cpp) src/assets/settings_document.cpp)
target_include_directories(pp_assets target_include_directories(pp_assets
PUBLIC PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src") "${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 target_link_libraries(pp_assets
PUBLIC PUBLIC
pp_foundation pp_foundation
@@ -113,7 +122,8 @@ target_link_libraries(pp_assets
add_library(pp_paint STATIC add_library(pp_paint STATIC
src/paint/brush.cpp src/paint/brush.cpp
src/paint/blend.cpp src/paint/blend.cpp
src/paint/stroke.cpp) src/paint/stroke.cpp
src/paint/stroke_script.cpp)
target_include_directories(pp_paint target_include_directories(pp_paint
PUBLIC PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src") "${CMAKE_CURRENT_SOURCE_DIR}/src")
@@ -125,13 +135,15 @@ target_link_libraries(pp_paint
pp_project_warnings) pp_project_warnings)
add_library(pp_document STATIC add_library(pp_document STATIC
src/document/document.cpp) src/document/document.cpp
src/document/ppi_import.cpp)
target_include_directories(pp_document target_include_directories(pp_document
PUBLIC PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src") "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(pp_document target_link_libraries(pp_document
PUBLIC PUBLIC
pp_foundation pp_foundation
pp_assets
pp_paint pp_paint
pp_project_options pp_project_options
PRIVATE PRIVATE

View File

@@ -90,14 +90,32 @@ Known local toolchain state:
`platform-build` automation wrapper for `pp_foundation`, `pp_assets`, `platform-build` automation wrapper for `pp_foundation`, `pp_assets`,
`pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`, `pp_paint`, `pp_document`, `pp_renderer_api`, `pp_paint_renderer`,
`pp_ui_core`, `pano_cli`, and their current headless test binaries, `pp_ui_core`, `pano_cli`, and their current headless test binaries,
including foundation event/logging/task queue coverage, PNG metadata, PPI including foundation event/logging/task queue coverage, PNG metadata and
header, settings document, document frame move/duration coverage, paint decode, PPI header/layout, settings document, document
brush/stroke coverage, renderer shader descriptor coverage, UI color snapshot/per-layer-frame/move/duration/face-pixel coverage, paint
parsing, and layout XML parse coverage. 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-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_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 - `pano_cli create-document` supports `--frames` and `--frame-duration-ms` and
is covered by `pano_cli_create_animation_document_smoke`. 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 - `panopainter_validate_shaders` validates the current combined GLSL shader
files for one vertex stage marker, one fragment stage marker, valid marker files for one vertex stage marker, one fragment stage marker, valid marker
order, and existing relative includes. 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-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-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-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-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-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 | | 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 ## 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 Status: in progress. `tests/` exists, `desktop-fast` runs headlessly, and
PowerShell/bash wrappers exist for PowerShell/bash wrappers exist for
configure/build/test/analyze/platform-build/package-smoke. `pano_cli` exists configure/build/test/analyze/platform-build/package-smoke. `pano_cli` exists
with JSON automation commands for creating a `pp_document` model and with JSON automation commands for creating a `pp_document` model, metadata-only
inspecting image signatures, PPI headers, and layout XML; full document/app PPI project loading, and inspecting image signatures, PPI headers, and layout
integration is debt-tracked as DEBT-0010 and full PPI body parsing is XML; full document/app integration is debt-tracked as DEBT-0010 and full PPI
debt-tracked as DEBT-0013. body parsing is debt-tracked as DEBT-0013.
Implementation tasks: Implementation tasks:
@@ -312,25 +312,39 @@ input. A synchronous event dispatcher, structured logging facade, bounded FIFO
task queue, and deterministic `TraceRecorder` now record task queue, and deterministic `TraceRecorder` now record
component/name/thread/frame/stroke metadata with filtering, capacity, and component/name/thread/frame/stroke metadata with filtering, capacity, and
invalid-end tests. `pp_assets` has started with PNG/JPEG signature detection, invalid-end tests. `pp_assets` has started with PNG/JPEG signature detection,
PNG IHDR metadata parsing, PPI header recognition, and a pure typed settings PNG IHDR metadata parsing, PPI header/project byte-layout/body-summary
document model, with corrupt/truncated/unsupported, extreme-dimension, and recognition, layer/frame indexing, dirty-face PNG payload metadata validation,
key/value limit tests. 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, `pp_paint` has started with pure brush parameter validation/stamp evaluation,
CPU reference math for the five current shader blend modes, and deterministic CPU reference math for the five current shader blend modes, and deterministic
stroke spacing/interpolation. `pp_document` has stroke spacing/interpolation plus a pure text stroke-script parser.
started with a pure canvas/layer/frame model, layer metadata operations, frame `pp_document` has
move/duration queries, and layer/frame/undo-redo history invariant tests. 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 `pp_renderer_api` has started with renderer-neutral
texture/readback descriptors and validation tests. `pp_paint_renderer` has texture/readback descriptors and validation tests. `pp_paint_renderer` has
started with deterministic CPU layer compositing over renderer extents using started with deterministic CPU layer compositing over renderer extents using
the paint blend reference. `pp_ui_core` has started with XML-layout-facing the paint blend reference. `pp_ui_core` has started with XML-layout-facing
length parsing, color parsing, tinyxml-backed layout XML parsing, and invalid length parsing, color parsing, tinyxml-backed layout XML parsing, and invalid
input tests. 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 `pano_cli create-document` can create simple animation documents with explicit
frame count/duration. `pano_cli parse-layout` exercises the XML layout path. frame count/duration, and `pano_cli simulate-stroke` exercises the pure stroke
Continue expanding document behavior toward legacy Canvas parity and then port sampler for scripted-stroke automation. `pano_cli simulate-stroke-script`
OpenGL classes behind the renderer boundary. 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: Implementation tasks:
@@ -536,7 +550,7 @@ Last verified on 2026-06-01:
```powershell ```powershell
cmake --preset windows-msvc-default 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 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\test.ps1 -Preset desktop-fast -Configuration Debug
powershell -ExecutionPolicy Bypass -File scripts\automation\build.ps1 -Preset windows-msvc-default -Configuration Debug -Target pano_cli 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_foundation_trace_tests` passed.
- `pp_assets_image_format_tests` passed. - `pp_assets_image_format_tests` passed.
- `pp_assets_image_metadata_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_assets_settings_document_tests` passed.
- `pp_paint_brush_tests` passed. - `pp_paint_brush_tests` passed.
- `pp_paint_blend_tests` passed. - `pp_paint_blend_tests` passed.
- `pp_paint_stroke_tests` passed. - `pp_paint_stroke_tests` passed.
- `pp_document_tests` passed, including frame move, duration, and history - `pp_paint_stroke_script_tests` passed.
invariants. - `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_renderer_api_tests` passed, including shader descriptor validation.
- `pp_paint_renderer_compositor_tests` passed. - `pp_paint_renderer_compositor_tests` passed.
- `pp_ui_core_color_tests` passed. - `pp_ui_core_color_tests` passed.
@@ -581,7 +604,17 @@ Results:
test. test.
- `pano_cli_inspect_png_metadata_smoke` passed and reports PNG metadata JSON - `pano_cli_inspect_png_metadata_smoke` passed and reports PNG metadata JSON
for the tiny IHDR fixture. 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_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 - `panopainter_validate_shaders` passed, validating 25 shader programs and 7
shader includes for stage markers and include graph integrity. shader includes for stage markers and include graph integrity.
- PowerShell analyze automation returns JSON summaries and includes the shader - PowerShell analyze automation returns JSON summaries and includes the shader

View File

@@ -1,7 +1,7 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[string[]]$Presets = @("android-arm64"), [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" $ErrorActionPreference = "Stop"

View File

@@ -3,7 +3,7 @@ set -u
preset="${1:-android-arm64}" preset="${1:-android-arm64}"
shift || true 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)" start="$(date +%s)"
cmake --preset "$preset" 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/ppi_header.h"
#include "assets/image_metadata.h"
#include "foundation/binary_stream.h" #include "foundation/binary_stream.h"
#include <bit>
#include <limits>
#include <utility>
namespace pp::assets { namespace pp::assets {
namespace { namespace {
@@ -11,6 +16,93 @@ namespace {
return reader.read_u32_le(); 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 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); 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 #pragma once
#include "assets/image_pixels.h"
#include "foundation/result.h" #include "foundation/result.h"
#include <array>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <span> #include <span>
#include <string>
#include <vector>
namespace pp::assets { namespace pp::assets {
constexpr std::size_t ppi_header_size = 40; 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 { struct PpiVersion {
std::uint32_t major = 0; std::uint32_t major = 0;
@@ -34,7 +43,104 @@ struct PpiHeaderInfo {
PpiThumbnailInfo thumbnail; 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( [[nodiscard]] pp::foundation::Result<PpiHeaderInfo> parse_ppi_header(
std::span<const std::byte> bytes) noexcept; 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 <algorithm>
#include <cmath> #include <cmath>
#include <limits>
#include <utility>
namespace pp::document { namespace pp::document {
@@ -33,6 +35,15 @@ namespace {
return "Layer " + std::to_string(index + 1U); 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 [[nodiscard]] pp::foundation::Status validate_layer_name(std::string_view name) noexcept
{ {
if (name.empty()) { if (name.empty()) {
@@ -46,6 +57,38 @@ namespace {
return pp::foundation::Status::success(); 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 [[nodiscard]] pp::foundation::Status validate_layer_index(std::size_t index, std::size_t layer_count) noexcept
{ {
if (index >= layer_count) { if (index >= layer_count) {
@@ -64,6 +107,67 @@ namespace {
return pp::foundation::Status::success(); 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) pp::foundation::Result<CanvasDocument> CanvasDocument::create(DocumentConfig config)
@@ -76,11 +180,97 @@ pp::foundation::Result<CanvasDocument> CanvasDocument::create(DocumentConfig con
CanvasDocument document; CanvasDocument document;
document.width_ = config.width; document.width_ = config.width;
document.height_ = config.height; document.height_ = config.height;
document.frames_.push_back(AnimationFrame {});
document.layers_.reserve(config.layer_count); document.layers_.reserve(config.layer_count);
for (std::uint32_t i = 0; i < config.layer_count; ++i) { 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); 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 CanvasDocument::animation_duration_ms() const noexcept
{ {
std::uint64_t duration = 0; std::uint64_t duration = frame_duration_sum(frames_);
for (const auto& frame : frames_) { for (const auto& layer : layers_) {
duration += frame.duration_ms; duration = std::max(duration, frame_duration_sum(layer.frames));
} }
return duration; 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 std::span<const Layer> CanvasDocument::layers() const noexcept
{ {
return layers_; 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.name = std::string(name);
} }
layer.frames = frames_;
layers_.push_back(layer); layers_.push_back(layer);
active_layer_index_ = layers_.size() - 1U; active_layer_index_ = layers_.size() - 1U;
return pp::foundation::Result<std::size_t>::success(active_layer_index_); 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(); 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 pp::foundation::Status CanvasDocument::set_layer_opacity(std::size_t index, float opacity) noexcept
{ {
const auto index_status = validate_layer_index(index, layers_.size()); 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; return index_status;
} }
if (!std::isfinite(opacity) || opacity < 0.0F || opacity > 1.0F) { const auto opacity_status = validate_layer_opacity(opacity);
return pp::foundation::Status::out_of_range("layer opacity must be finite and within 0..1"); if (!opacity_status.ok()) {
return opacity_status;
} }
layers_[index].opacity = opacity; layers_[index].opacity = opacity;
@@ -251,17 +475,13 @@ pp::foundation::Status CanvasDocument::set_layer_blend_mode(std::size_t index, p
return index_status; return index_status;
} }
switch (blend_mode) { const auto blend_status = validate_blend_mode(blend_mode);
case pp::paint::BlendMode::normal: if (!blend_status.ok()) {
case pp::paint::BlendMode::multiply: return blend_status;
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();
} }
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) 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")); 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( 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; active_frame_index_ = frames_.size() - 1U;
return pp::foundation::Result<std::size_t>::success(active_frame_index_); 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; const auto insert_at = index + 1U;
frames_.insert(frames_.begin() + static_cast<std::ptrdiff_t>(insert_at), frames_[index]); 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; active_frame_index_ = insert_at;
return pp::foundation::Result<std::size_t>::success(active_frame_index_); 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)); 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()) { if (active_frame_index_ >= frames_.size()) {
active_frame_index_ = frames_.size() - 1U; active_frame_index_ = frames_.size() - 1U;
} else if (active_frame_index_ > index) { } 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]; const auto frame = frames_[from];
frames_.erase(frames_.begin() + static_cast<std::ptrdiff_t>(from)); frames_.erase(frames_.begin() + static_cast<std::ptrdiff_t>(from));
frames_.insert(frames_.begin() + static_cast<std::ptrdiff_t>(to), frame); 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) { if (active_frame_index_ == from) {
active_frame_index_ = to; active_frame_index_ = to;
@@ -353,11 +596,17 @@ pp::foundation::Status CanvasDocument::set_frame_duration(std::size_t index, std
return index_status; return index_status;
} }
if (duration_ms < min_frame_duration_ms) { const auto duration_status = validate_frame_duration(duration_ms);
return pp::foundation::Status::invalid_argument("frame duration must be greater than zero"); if (!duration_status.ok()) {
return duration_status;
} }
frames_[index].duration_ms = duration_ms; 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(); 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(); 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( pp::foundation::Result<DocumentHistory> DocumentHistory::create(
CanvasDocument initial_document, CanvasDocument initial_document,
std::size_t max_entries) 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 min_document_history_entries = 2;
constexpr std::size_t max_document_history_entries = 10000; constexpr std::size_t max_document_history_entries = 10000;
constexpr std::size_t max_layer_name_length = 128; 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 { struct DocumentConfig {
std::uint32_t width = 0; std::uint32_t width = 0;
@@ -25,26 +28,57 @@ struct DocumentConfig {
std::uint32_t layer_count = 1; std::uint32_t layer_count = 1;
}; };
struct Layer { struct LayerFacePixels {
std::string name; std::uint32_t face_index = 0;
bool visible = true; std::uint32_t x = 0;
float opacity = 1.0F; std::uint32_t y = 0;
pp::paint::BlendMode blend_mode = pp::paint::BlendMode::normal; std::uint32_t width = 0;
std::uint32_t height = 0;
std::vector<std::uint8_t> rgba8;
}; };
struct AnimationFrame { struct AnimationFrame {
std::uint32_t duration_ms = 100; 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 { class CanvasDocument {
public: public:
[[nodiscard]] static pp::foundation::Result<CanvasDocument> create(DocumentConfig config); [[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 width() const noexcept;
[[nodiscard]] std::uint32_t height() const noexcept; [[nodiscard]] std::uint32_t height() const noexcept;
[[nodiscard]] std::size_t active_layer_index() const noexcept; [[nodiscard]] std::size_t active_layer_index() const noexcept;
[[nodiscard]] std::size_t active_frame_index() const noexcept; [[nodiscard]] std::size_t active_frame_index() const noexcept;
[[nodiscard]] std::uint64_t animation_duration_ms() 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 Layer> layers() const noexcept;
[[nodiscard]] std::span<const AnimationFrame> frames() 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 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 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_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_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; [[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 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_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_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: private:
std::uint32_t width_ = 0; 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 set_tests_properties(pp_assets_image_metadata_tests PROPERTIES
LABELS "assets;desktop-fast") 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 add_executable(pp_assets_ppi_header_tests
assets/ppi_header_tests.cpp) assets/ppi_header_tests.cpp)
target_link_libraries(pp_assets_ppi_header_tests PRIVATE 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 set_tests_properties(pp_paint_stroke_tests PROPERTIES
LABELS "paint;desktop-fast") 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 add_executable(pp_document_tests
document/document_tests.cpp) document/document_tests.cpp)
target_link_libraries(pp_document_tests PRIVATE 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 set_tests_properties(pp_document_tests PROPERTIES
LABELS "document;desktop-fast") 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 add_executable(pp_renderer_api_tests
renderer_api/renderer_api_tests.cpp) renderer_api/renderer_api_tests.cpp)
target_link_libraries(pp_renderer_api_tests PRIVATE target_link_libraries(pp_renderer_api_tests PRIVATE
@@ -220,8 +250,32 @@ if(TARGET pano_cli)
LABELS "assets;integration;desktop-fast" LABELS "assets;integration;desktop-fast"
PASS_REGULAR_EXPRESSION "\"format\":\"png\".*\"width\":320.*\"height\":240.*\"components\":4.*\"colorType\":\"rgba\"") 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 add_test(NAME pano_cli_parse_layout_smoke
COMMAND pano_cli parse-layout --path "${CMAKE_CURRENT_SOURCE_DIR}/data/layouts/simple-layout.xml") COMMAND pano_cli parse-layout --path "${CMAKE_CURRENT_SOURCE_DIR}/data/layouts/simple-layout.xml")
set_tests_properties(pano_cli_parse_layout_smoke PROPERTIES set_tests_properties(pano_cli_parse_layout_smoke PROPERTIES
LABELS "ui;integration;desktop-fast") 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() 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 "test_harness.h"
#include <array> #include <array>
#include <bit>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <string_view>
#include <vector> #include <vector>
using pp::assets::parse_ppi_header; 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_header_size;
using pp::assets::ppi_thumbnail_byte_size;
using pp::foundation::StatusCode; using pp::foundation::StatusCode;
namespace { 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)); 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> valid_header()
{ {
std::vector<std::byte> bytes { std::vector<std::byte> bytes {
@@ -40,6 +67,127 @@ std::vector<std::byte> valid_header()
return bytes; 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) void parses_legacy_ppi_header(pp::tests::Harness& h)
{ {
const auto bytes = valid_header(); 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); 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() int main()
@@ -104,5 +392,14 @@ int main()
harness.run("parses_legacy_ppi_header", parses_legacy_ppi_header); 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_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("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(); 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::CanvasDocument;
using pp::document::DocumentHistory; using pp::document::DocumentHistory;
using pp::document::DocumentConfig; 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_document_history_entries;
using pp::document::max_canvas_dimension; using pp::document::max_canvas_dimension;
using pp::document::max_frame_count; 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().active_layer_index() == 0U);
PP_EXPECT(h, document.value().frames().size() == 1U); PP_EXPECT(h, document.value().frames().size() == 1U);
PP_EXPECT(h, document.value().frames()[0].duration_ms == 100U); 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().animation_duration_ms() == 100U);
PP_EXPECT(h, document.value().active_frame_index() == 0U); 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.rename_layer(1, "Ink").ok());
PP_EXPECT(h, document.set_layer_visible(1, false).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_opacity(1, 0.25F).ok());
PP_EXPECT(h, document.set_layer_blend_mode(1, BlendMode::multiply).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].name == std::string_view("Ink"));
PP_EXPECT(h, !document.layers()[1].visible); 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, std::fabs(document.layers()[1].opacity - 0.25F) < 0.0001F);
PP_EXPECT(h, document.layers()[1].blend_mode == BlendMode::multiply); 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_high = document.set_layer_opacity(0, 1.1F);
const auto bad_opacity_nan = document.set_layer_opacity(0, std::nanf("")); 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_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 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_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')); 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, bad_opacity_nan.code == StatusCode::out_of_range);
PP_EXPECT(h, !missing_visible.ok()); PP_EXPECT(h, !missing_visible.ok());
PP_EXPECT(h, missing_visible.code == StatusCode::out_of_range); 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.ok());
PP_EXPECT(h, missing_blend.code == StatusCode::out_of_range); PP_EXPECT(h, missing_blend.code == StatusCode::out_of_range);
PP_EXPECT(h, !bad_blend.ok()); 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); 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) void manages_animation_frames_and_duration(pp::tests::Harness& h)
{ {
auto document_result = CanvasDocument::create( 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, added.value() == 1U);
PP_EXPECT(h, document.active_frame_index() == 1U); PP_EXPECT(h, document.active_frame_index() == 1U);
PP_EXPECT(h, document.frames()[1].duration_ms == 250U); 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); const auto duplicated = document.duplicate_frame(1);
PP_EXPECT(h, duplicated.ok()); 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.frames()[2].duration_ms == 250U);
PP_EXPECT(h, document.set_frame_duration(2, 333).ok()); PP_EXPECT(h, document.set_frame_duration(2, 333).ok());
PP_EXPECT(h, document.frames()[2].duration_ms == 333U); 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.animation_duration_ms() == 683U);
PP_EXPECT(h, document.remove_frame(1).ok()); 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()); 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) void records_document_history_and_restores_snapshots(pp::tests::Harness& h)
{ {
auto document_result = CanvasDocument::create( 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("moves_layers_and_preserves_active_layer_identity", moves_layers_and_preserves_active_layer_identity);
harness.run("updates_layer_metadata", updates_layer_metadata); harness.run("updates_layer_metadata", updates_layer_metadata);
harness.run("rejects_invalid_layer_metadata", rejects_invalid_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("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("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("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("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("applying_after_undo_discards_redo_branch", applying_after_undo_discards_redo_branch);
harness.run("bounds_document_history_capacity", bounds_document_history_capacity); 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/image_metadata.h"
#include "assets/ppi_header.h" #include "assets/ppi_header.h"
#include "document/document.h" #include "document/document.h"
#include "document/ppi_import.h"
#include "foundation/parse.h" #include "foundation/parse.h"
#include "foundation/result.h" #include "foundation/result.h"
#include "paint/stroke.h"
#include "paint/stroke_script.h"
#include "ui_core/layout_xml.h" #include "ui_core/layout_xml.h"
#include <cstdint> #include <cstdint>
@@ -37,12 +40,66 @@ struct InspectProjectArgs {
std::string path; 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) void print_error(std::string_view command, std::string_view message)
{ {
std::cout << "{\"ok\":false,\"command\":\"" << command std::cout << "{\"ok\":false,\"command\":\"" << command
<< "\",\"error\":\"" << message << "\"}\n"; << "\",\"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() void print_help()
{ {
std::cout std::cout
@@ -50,7 +107,10 @@ void print_help()
<< " create-document --width N --height N [--layers N] [--frames N] [--frame-duration-ms N]\n" << " create-document --width N --height N [--layers N] [--frames N] [--frame-duration-ms N]\n"
<< " inspect-image --path FILE\n" << " inspect-image --path FILE\n"
<< " inspect-project --path FILE\n" << " inspect-project --path FILE\n"
<< " load-project --path FILE\n"
<< " parse-layout --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"; << " --help\n";
} }
@@ -271,23 +331,296 @@ int inspect_project(int argc, char** argv)
std::istreambuf_iterator<char>() std::istreambuf_iterator<char>()
}; };
const auto* data = reinterpret_cast<const std::byte*>(chars.data()); 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())); const auto project = pp::assets::parse_ppi_project_index(std::span<const std::byte>(data, chars.size()));
if (!header) { if (!project) {
print_error("inspect-project", header.status().message); print_error("inspect-project", project.status().message);
return 2; return 2;
} }
std::cout << "{\"ok\":true,\"command\":\"inspect-project\"" std::cout << "{\"ok\":true,\"command\":\"inspect-project\""
<< ",\"documentVersion\":\"" << header.value().document_version.major << ",\"documentVersion\":\"" << project.value().layout.header.document_version.major
<< "." << header.value().document_version.minor << "\"" << "." << project.value().layout.header.document_version.minor << "\""
<< ",\"softwareVersion\":\"" << header.value().software_version.major << ",\"softwareVersion\":\"" << project.value().layout.header.software_version.major
<< "." << header.value().software_version.minor << "." << project.value().layout.header.software_version.minor
<< "." << header.value().software_version.fix << "." << project.value().layout.header.software_version.fix
<< "." << header.value().software_version.build << "\"" << "." << project.value().layout.header.software_version.build << "\""
<< ",\"thumbnail\":{\"width\":" << header.value().thumbnail.width << ",\"thumbnail\":{\"width\":" << project.value().layout.header.thumbnail.width
<< ",\"height\":" << header.value().thumbnail.height << ",\"height\":" << project.value().layout.header.thumbnail.height
<< ",\"components\":" << header.value().thumbnail.components << ",\"components\":" << project.value().layout.header.thumbnail.components
<< "}}\n"; << ",\"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; return 0;
} }
@@ -372,6 +705,18 @@ int main(int argc, char** argv)
return inspect_project(argc, 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") { if (command == "parse-layout") {
return parse_layout(argc, argv); return parse_layout(argc, argv);
} }