diff --git a/CMakeLists.txt b/CMakeLists.txt index e63493f..9ff0ac6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,7 +113,8 @@ target_link_libraries(pp_assets add_library(pp_paint STATIC src/paint/brush.cpp src/paint/blend.cpp - src/paint/stroke.cpp) + src/paint/stroke.cpp + src/paint/stroke_script.cpp) target_include_directories(pp_paint PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 9fb47da..fc5f518 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -92,14 +92,16 @@ Known local toolchain state: `pp_ui_core`, `pano_cli`, and their current headless test binaries, including foundation event/logging/task queue coverage, PNG metadata, PPI header, settings document, document frame move/duration coverage, paint - brush/stroke coverage, renderer shader descriptor coverage, UI color - parsing, and layout XML parse coverage. + brush/stroke/stroke-script coverage, renderer shader descriptor coverage, UI + color parsing, and layout XML parse coverage. - `pano_cli inspect-image` reports PNG IHDR metadata as JSON and is covered by `pano_cli_inspect_png_metadata_smoke` with a tiny IHDR fixture. - `pano_cli create-document` supports `--frames` and `--frame-duration-ms` and is covered by `pano_cli_create_animation_document_smoke`. - `pano_cli simulate-stroke` exposes the pure stroke sampler for scripted automation and is covered by `pano_cli_simulate_stroke_smoke`. +- `pano_cli simulate-stroke-script` loads a text stroke script fixture and is + covered by `pano_cli_simulate_stroke_script_smoke`. - `panopainter_validate_shaders` validates the current combined GLSL shader files for one vertex stage marker, one fragment stage marker, valid marker order, and existing relative includes. diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index e3b9858..a42defe 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -317,7 +317,8 @@ document model, with corrupt/truncated/unsupported, extreme-dimension, and key/value limit tests. `pp_paint` has started with pure brush parameter validation/stamp evaluation, CPU reference math for the five current shader blend modes, and deterministic -stroke spacing/interpolation. `pp_document` has +stroke spacing/interpolation plus a pure text stroke-script parser. +`pp_document` has started with a pure canvas/layer/frame model, layer metadata operations, frame move/duration queries, and layer/frame/undo-redo history invariant tests. `pp_renderer_api` has started with renderer-neutral @@ -329,9 +330,11 @@ input tests. `pano_cli inspect-image` exposes PNG IHDR metadata as JSON, and `pano_cli create-document` can create simple animation documents with explicit frame count/duration, and `pano_cli simulate-stroke` exercises the pure stroke -sampler for scripted-stroke automation. `pano_cli parse-layout` exercises the -XML layout path. Continue expanding document behavior toward legacy Canvas -parity and then port OpenGL classes behind the renderer boundary. +sampler for scripted-stroke automation. `pano_cli simulate-stroke-script` +loads stroke script fixtures, parses them through `pp_paint`, and samples every +stroke. `pano_cli parse-layout` exercises the XML layout path. Continue +expanding document behavior toward legacy Canvas parity and then port OpenGL +classes behind the renderer boundary. Implementation tasks: @@ -568,6 +571,7 @@ Results: - `pp_paint_brush_tests` passed. - `pp_paint_blend_tests` passed. - `pp_paint_stroke_tests` passed. +- `pp_paint_stroke_script_tests` passed. - `pp_document_tests` passed, including frame move, duration, and history invariants. - `pp_renderer_api_tests` passed, including shader descriptor validation. @@ -585,6 +589,8 @@ Results: - `pano_cli_parse_layout_smoke` passed. - `pano_cli_simulate_stroke_smoke` passed and reports deterministic stroke sample counts/distances. +- `pano_cli_simulate_stroke_script_smoke` passed and reports deterministic + aggregate stroke-script counts/distances. - `panopainter_validate_shaders` passed, validating 25 shader programs and 7 shader includes for stage markers and include graph integrity. - PowerShell analyze automation returns JSON summaries and includes the shader diff --git a/scripts/automation/platform-build.ps1 b/scripts/automation/platform-build.ps1 index 488036e..a961546 100644 --- a/scripts/automation/platform-build.ps1 +++ b/scripts/automation/platform-build.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( [string[]]$Presets = @("android-arm64"), - [string[]]$Targets = @("pp_foundation", "pp_assets", "pp_paint", "pp_document", "pp_renderer_api", "pp_paint_renderer", "pp_ui_core", "pano_cli", "pp_foundation_binary_stream_tests", "pp_foundation_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_ppi_header_tests", "pp_assets_settings_document_tests", "pp_paint_brush_tests", "pp_paint_blend_tests", "pp_paint_stroke_tests", "pp_paint_stroke_script_tests", "pp_document_tests", "pp_renderer_api_tests", "pp_paint_renderer_compositor_tests", "pp_ui_core_color_tests", "pp_ui_core_layout_value_tests", "pp_ui_core_layout_xml_tests") ) $ErrorActionPreference = "Stop" diff --git a/scripts/automation/platform-build.sh b/scripts/automation/platform-build.sh index 5da107a..f3a8408 100644 --- a/scripts/automation/platform-build.sh +++ b/scripts/automation/platform-build.sh @@ -3,7 +3,7 @@ set -u preset="${1:-android-arm64}" shift || true -targets="${*:-pp_foundation pp_assets pp_paint pp_document pp_renderer_api pp_paint_renderer pp_ui_core pano_cli pp_foundation_binary_stream_tests pp_foundation_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_ppi_header_tests pp_assets_settings_document_tests pp_paint_brush_tests pp_paint_blend_tests pp_paint_stroke_tests pp_paint_stroke_script_tests pp_document_tests pp_renderer_api_tests pp_paint_renderer_compositor_tests pp_ui_core_color_tests pp_ui_core_layout_value_tests pp_ui_core_layout_xml_tests}" start="$(date +%s)" cmake --preset "$preset" diff --git a/src/paint/stroke_script.cpp b/src/paint/stroke_script.cpp new file mode 100644 index 0000000..118c77f --- /dev/null +++ b/src/paint/stroke_script.cpp @@ -0,0 +1,210 @@ +#include "paint/stroke_script.h" + +#include +#include +#include +#include + +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 parse_float_token(std::string_view token) noexcept +{ + token = trim(token); + if (token.empty() || token.size() >= 64U) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("stroke script numeric token is invalid")); + } + + std::array 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(token.size()) || !std::isfinite(value)) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("stroke script numeric token is invalid")); + } + + return pp::foundation::Result::success(value); +} + +[[nodiscard]] pp::foundation::Result split_tokens( + std::string_view line, + std::array& 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::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::success(count); +} + +[[nodiscard]] pp::foundation::Result parse_stroke_line(std::string_view line) noexcept +{ + std::array tokens {}; + const auto token_count = split_tokens(line, tokens); + if (!token_count) { + return pp::foundation::Result::failure(token_count.status()); + } + + if (token_count.value() != tokens.size() || tokens[0] != "stroke") { + return pp::foundation::Result::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::failure(x1.status()); + } + if (!y1) { + return pp::foundation::Result::failure(y1.status()); + } + if (!p1) { + return pp::foundation::Result::failure(p1.status()); + } + if (!x2) { + return pp::foundation::Result::failure(x2.status()); + } + if (!y2) { + return pp::foundation::Result::failure(y2.status()); + } + if (!p2) { + return pp::foundation::Result::failure(p2.status()); + } + if (!spacing) { + return pp::foundation::Result::failure(spacing.status()); + } + + if (spacing.value() <= 0.0F) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("stroke script spacing must be greater than zero")); + } + + return pp::foundation::Result::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 parse_stroke_script(std::string_view text) +{ + if (text.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("stroke script must not be empty")); + } + + if (text.size() > max_stroke_script_bytes) { + return pp::foundation::Result::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::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::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::failure(stroke.status()); + } + + script.strokes.push_back(stroke.value()); + } + + if (script.strokes.empty()) { + return pp::foundation::Result::failure( + pp::foundation::Status::invalid_argument("stroke script must contain at least one stroke")); + } + + return pp::foundation::Result::success(script); +} + +} diff --git a/src/paint/stroke_script.h b/src/paint/stroke_script.h new file mode 100644 index 0000000..7a4d9bf --- /dev/null +++ b/src/paint/stroke_script.h @@ -0,0 +1,28 @@ +#pragma once + +#include "foundation/result.h" +#include "paint/stroke.h" + +#include +#include +#include + +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 strokes; +}; + +[[nodiscard]] pp::foundation::Result parse_stroke_script(std::string_view text); + +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f7285f6..4eac920 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -136,6 +136,16 @@ add_test(NAME pp_paint_stroke_tests COMMAND pp_paint_stroke_tests) set_tests_properties(pp_paint_stroke_tests PROPERTIES LABELS "paint;desktop-fast") +add_executable(pp_paint_stroke_script_tests + paint/stroke_script_tests.cpp) +target_link_libraries(pp_paint_stroke_script_tests PRIVATE + pp_paint + pp_test_harness) + +add_test(NAME pp_paint_stroke_script_tests COMMAND pp_paint_stroke_script_tests) +set_tests_properties(pp_paint_stroke_script_tests PROPERTIES + LABELS "paint;desktop-fast") + add_executable(pp_document_tests document/document_tests.cpp) target_link_libraries(pp_document_tests PRIVATE @@ -230,4 +240,10 @@ if(TARGET pano_cli) 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() diff --git a/tests/data/strokes/two-strokes.ppstroke b/tests/data/strokes/two-strokes.ppstroke new file mode 100644 index 0000000..bf35ad8 --- /dev/null +++ b/tests/data/strokes/two-strokes.ppstroke @@ -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 diff --git a/tests/paint/stroke_script_tests.cpp b/tests/paint/stroke_script_tests.cpp new file mode 100644 index 0000000..34e45d8 --- /dev/null +++ b/tests/paint/stroke_script_tests.cpp @@ -0,0 +1,86 @@ +#include "paint/stroke_script.h" +#include "test_harness.h" + +#include +#include + +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(); +} diff --git a/tools/pano_cli/main.cpp b/tools/pano_cli/main.cpp index c3ac4b2..ce7dd95 100644 --- a/tools/pano_cli/main.cpp +++ b/tools/pano_cli/main.cpp @@ -5,6 +5,7 @@ #include "foundation/parse.h" #include "foundation/result.h" #include "paint/stroke.h" +#include "paint/stroke_script.h" #include "ui_core/layout_xml.h" #include @@ -46,6 +47,10 @@ struct SimulateStrokeArgs { std::uint32_t spacing = 1; }; +struct SimulateStrokeScriptArgs { + std::string path; +}; + void print_error(std::string_view command, std::string_view message) { std::cout << "{\"ok\":false,\"command\":\"" << command @@ -61,6 +66,7 @@ void print_help() << " inspect-project --path FILE\n" << " parse-layout --path FILE\n" << " simulate-stroke --x1 N --y1 N --x2 N --y2 N [--spacing N]\n" + << " simulate-stroke-script --path FILE\n" << " --help\n"; } @@ -379,6 +385,78 @@ int simulate_stroke(int argc, char** argv) 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(stream), + std::istreambuf_iterator() + }; + 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; +} + pp::foundation::Status parse_layout_args(int argc, char** argv, ParseLayoutArgs& args) { for (int i = 2; i < argc; ++i) { @@ -464,6 +542,10 @@ int main(int argc, char** argv) return simulate_stroke(argc, argv); } + if (command == "simulate-stroke-script") { + return simulate_stroke_script(argc, argv); + } + if (command == "parse-layout") { return parse_layout(argc, argv); }